JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 1409
  • Score
    100M100P100Q124726F
  • License MIT

Astro integration for @jdevalk/seo-graph-core. Seo component, route factories, content-collection aggregator, Zod content helpers.

Package Exports

  • @jdevalk/astro-seo-graph
  • @jdevalk/astro-seo-graph/FuzzyRedirect.astro
  • @jdevalk/astro-seo-graph/Seo.astro
  • @jdevalk/astro-seo-graph/integration

Readme

@jdevalk/astro-seo-graph

npm version license

Astro integration for @jdevalk/seo-graph-core. Ships a <Seo> component, route factories for agent-ready schema endpoints, a content-collection aggregator, breadcrumb helpers, a fuzzy 404 redirect component, and Zod helpers for content schemas.

For detailed usage — including all builder signatures, site-type recipes, and schema.org best practices — see AGENTS.md.

What you get

API Purpose
<Seo> (./Seo.astro) Single head component covering <title>, meta description, canonical, Open Graph, Twitter card, hreflang alternates, and optional JSON-LD @graph. Wraps astro-seo for the meta tags.
createSchemaEndpoint Factory returning an Astro APIRoute handler that serves a corpus-wide JSON-LD @graph for a content collection.
createSchemaMap Factory returning an APIRoute handler that emits a sitemap-style XML listing of your site's schema endpoints — the discovery point for agent crawlers.
aggregate Shared engine behind the endpoint factories. Walks a list of entries, runs a caller-supplied mapper, deduplicates by @id.
seoSchema, imageSchema Zod schemas for the seo and image fields on content collections. Import them into src/content.config.ts.
buildAstroSeoProps Pure-TS logic that powers <Seo> — exported for users who want to feed a different head component.
buildAlternateLinks Pure helper that turns a { hreflang, href } entry list into normalized <link rel="alternate"> tags plus an x-default. Used internally by <Seo>'s alternates prop, and exported for non-Astro callers (e.g. CMS plugins feeding their own metadata pipelines).
breadcrumbsFromUrl Derives a breadcrumb trail from an Astro URL. Splits path segments, supports custom display names and segment skipping. Returns BreadcrumbItem[] ready to pass to buildBreadcrumbList.
<FuzzyRedirect> Drop-in 404 component. Fetches your sitemap, fuzzy-matches the current URL against known paths, and suggests or auto-redirects to the closest match.

Installation

pnpm add @jdevalk/astro-seo-graph @jdevalk/seo-graph-core

@jdevalk/seo-graph-core is a direct dep of this package so it's installed transitively, but depending on it explicitly lets you pin the version and import piece builders directly.

<Seo> component

---
import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
import { buildSchemaGraph } from '../utils/schema';

const graph = buildSchemaGraph({
    pageType: 'blogPost',
    canonicalUrl: Astro.url.href,
    title: 'My Post',
    description: '…',
    publishDate: new Date('2026-04-07'),
});
---

<html>
    <head>
        <Seo
            title="My Post"
            titleTemplate="%s | Example"
            description="…"
            ogType="article"
            ogImage="https://example.com/og/my-post.jpg"
            ogImageAlt="My Post"
            ogImageWidth={1200}
            ogImageHeight={675}
            siteName="Example"
            twitter={{ site: '@example', creator: '@author' }}
            article={{ publishedTime: new Date('2026-04-07'), tags: ['tech'] }}
            graph={graph}
            extraLinks={[
                { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
                { rel: 'sitemap', href: '/sitemap-index.xml' },
            ]}
        />
    </head>
    <body>...</body>
</html>

Derive a breadcrumb trail from an Astro page URL instead of computing it manually:

import { breadcrumbsFromUrl } from '@jdevalk/astro-seo-graph';
import { buildBreadcrumbList, makeIds } from '@jdevalk/seo-graph-core';

const ids = makeIds({ siteUrl: 'https://example.com' });
const url = 'https://example.com/blog/open-source/my-post/';

const items = breadcrumbsFromUrl({
    url: Astro.url, // or any URL / string
    siteUrl: 'https://example.com',
    pageName: 'My Post', // display name for the current page
    // homeName: 'Home',                     // optional, defaults to 'Home'
    // names: { blog: 'Articles' },          // optional, custom segment names
    // skip: ['category'],                   // optional, segments to omit
});

const breadcrumb = buildBreadcrumbList({ url, items }, ids);
// items === [
//   { name: 'Home', url: 'https://example.com/' },
//   { name: 'Blog', url: 'https://example.com/blog/' },
//   { name: 'Open Source', url: 'https://example.com/blog/open-source/' },
//   { name: 'My Post', url: 'https://example.com/blog/open-source/my-post/' },
// ]

Segments without a names entry are title-cased from their slug (open-sourceOpen Source). Sites with a base path (e.g. https://example.com/docs) are supported — pass the base path as part of siteUrl.

Fuzzy 404 redirect

When a visitor hits a 404, <FuzzyRedirect> fetches your sitemap, compares the mistyped URL against all known paths, and either suggests the closest match or auto-redirects. Drop it into your 404.astro page:

---
// src/pages/404.astro
import FuzzyRedirect from '@jdevalk/astro-seo-graph/FuzzyRedirect.astro';
---

<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>Page not found</title>
    </head>
    <body>
        <h1>Page not found</h1>
        <p>Sorry, the page you're looking for doesn't exist.</p>
        <p style="font-size: 1.25em; font-weight: bold;">
            <FuzzyRedirect />
        </p>
        <p><a href="/">Go to the homepage</a></p>
    </body>
</html>

When a close match is found, the component renders a message like Did you mean /seo-graph/? inside the element where you place it. Style the surrounding element to make it prominent.

How it works

  1. Fetches /sitemap-index.xml (follows sitemap index → child sitemaps)
  2. Extracts all paths and computes Levenshtein similarity against the current URL
  3. 0.6–0.85 similarity: shows "Did you mean /correct-path/?"
  4. Above 0.85: auto-redirects with window.location.replace
  5. Below 0.6 or exact match: does nothing

Props

Prop Default Description
threshold 0.6 Minimum similarity for a suggestion to appear
autoRedirectThreshold 0.85 Similarity above which the user is auto-redirected
sitemapUrl '/sitemap-index.xml' URL of the sitemap index or sitemap file
suggestionText 'Did you mean' Text shown before the suggested link

hreflang alternates

For multilingual sites, pass an alternates prop with one entry per locale. <Seo> emits a <link rel="alternate"> for every entry plus an x-default, normalizes BCP 47 tags on the way out, and drops entries with relative or non-http(s) URLs.

---
import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
---

<Seo
    title="Hello"
    alternates={{
        defaultLocale: 'en',
        entries: [
            { hreflang: 'en',    href: 'https://example.com/hello/' },
            { hreflang: 'fr-CA', href: 'https://example.com/fr-ca/bonjour/' },
            { hreflang: 'nl',    href: 'https://example.com/nl/hallo/' },
        ],
    }}
/>

Renders roughly:

<link rel="alternate" hreflang="en" href="https://example.com/hello/" />
<link rel="alternate" hreflang="fr-CA" href="https://example.com/fr-ca/bonjour/" />
<link rel="alternate" hreflang="nl" href="https://example.com/nl/hallo/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/hello/" />

Rules

  • Absolute URLs only. Relative (/hello/), protocol-relative (//…), and non-http schemes (mailto:) are dropped silently.
  • Include the current page. Google treats self-referential hreflang as required, not optional.
  • BCP 47 normalization. fr-ca becomes fr-CA, zh-hant-hk becomes zh-Hant-HK. Language subtag lowercase, script subtag title-case, region subtag uppercase.
  • First entry wins. Duplicate normalized tags are collapsed to the first one.
  • Automatic x-default. Points at defaultLocale if it matches an entry; otherwise falls back to the first entry.
  • < 2 entries → nothing emitted. A single-locale page has no meaningful alternates.
  • "x-default" is reserved. Passing it as an input hreflang gets dropped; it's only ever added automatically.

If you're not using <Seo> directly (e.g. you're writing a CMS plugin that contributes to its own metadata pipeline), import buildAlternateLinks from the main package entry:

import { buildAlternateLinks } from '@jdevalk/astro-seo-graph';

const links = buildAlternateLinks({
    defaultLocale: 'en',
    entries: [
        { hreflang: 'en', href: siteEn },
        { hreflang: 'fr', href: siteFr },
    ],
});
// → [{ rel: 'alternate', hreflang: 'en', href: ... }, ..., { hreflang: 'x-default', ... }]

The main package entry is pure TypeScript — importing buildAlternateLinks does not pull in any Astro runtime, so it's safe to use from non-Astro contexts.

Schema endpoints

// src/pages/schema/post.json.ts
import { getCollection } from 'astro:content';
import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
import { buildArticle, buildWebPage, makeIds } from '@jdevalk/seo-graph-core';

const ids = makeIds({ siteUrl: 'https://example.com' });

export const GET = createSchemaEndpoint({
    entries: () => getCollection('blog'),
    mapper: (post) => {
        const url = `https://example.com/${post.id}/`;
        return [
            buildWebPage(
                {
                    url,
                    name: post.data.title,
                    isPartOf: { '@id': ids.website },
                    breadcrumb: { '@id': ids.breadcrumb(url) },
                    datePublished: post.data.publishDate,
                },
                ids,
            ),
            buildArticle(
                {
                    url,
                    isPartOf: { '@id': ids.webPage(url) },
                    author: { '@id': ids.person },
                    publisher: { '@id': ids.person },
                    headline: post.data.title,
                    description: post.data.excerpt ?? '',
                    datePublished: post.data.publishDate,
                },
                ids,
                'BlogPosting',
            ),
        ];
    },
});

Schema map discovery

// src/pages/schemamap.xml.ts
import { createSchemaMap } from '@jdevalk/astro-seo-graph';

export const GET = createSchemaMap({
    siteUrl: 'https://example.com',
    entries: [
        { path: '/schema/post.json', lastModified: new Date('2026-04-07') },
        { path: '/schema/video.json', lastModified: new Date('2026-03-13') },
    ],
});

Zod content helpers

// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { seoSchema, imageSchema } from '@jdevalk/astro-seo-graph';

const blog = defineCollection({
    schema: ({ image }) =>
        z.object({
            title: z.string(),
            publishDate: z.coerce.date(),
            featureImage: imageSchema(image).optional(),
            seo: seoSchema(image).optional(),
        }),
});

imageSchema requires alt — missing alt text is an accessibility failure and an SEO failure. Decorative images should use alt: '' explicitly. If you want the whole image to be optional, wrap the schema: imageSchema(image).optional().

Astro integration

An Astro integration that runs build-time SEO checks. Currently:

  • Warns about built pages with zero or more than one <h1> element (a common SEO and accessibility issue).
// astro.config.mjs
import { defineConfig } from 'astro/config';
import seoGraph from '@jdevalk/astro-seo-graph/integration';

export default defineConfig({
    integrations: [seoGraph()],
});

Options:

Prop Default Description
validateH1 true Warn about pages without exactly one <h1>

Only static pages are checked (SSR pages aren't on disk at build time).

License

MIT © Joost de Valk