JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 1355
  • Score
    100M100P100Q124482F
  • 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.

Using an AI coding assistant? The astro-seo skill audits and improves the full SEO setup of an Astro site — technical foundation, structured data, sitemaps, IndexNow, agent discovery, and more — and produces drop-in code routed through this package.

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.
buildSeoContext Pure-TS logic that powers <Seo> — returns a flat, normalized projection of SeoProps (resolved title, canonical, OG fields, hreflang entries, robots directives). Exported for users who want to render the head themselves.
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.
createIndexNowKeyRoute Factory returning an APIRoute that serves the IndexNow key-verification file at /<key>.txt. Pair with the indexNow option on the integration to auto-submit built URLs on astro:build:done.

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>

<Seo> behavior notes

  • Robots defaults. max-snippet:-1, max-image-preview:large, and max-video-preview:-1 are always emitted alongside any noindex / nofollow directives, matching the Yoast-style defaults for maximum snippet sizes.
  • Canonical + noindex. When noindex is true the canonical link is omitted per Google's recommendation.
  • Query params. Canonical URLs strip query parameters by default. Pass preserveQueryParams to keep them.
  • Twitter tag dedup. twitter:title, twitter:description, twitter:image, and twitter:image:alt are only emitted when the caller explicitly overrides them via twitter.title / description / image / imageAlt. Otherwise Twitter falls back to the og: counterparts automatically.
  • og:locale:alternate. Emitted automatically from the alternates prop on multilingual pages.
  • Author / publisher. Pass author for <meta name="author"> (falls back to article.authors[0]); pass articlePublisher for the article:publisher Facebook URL.

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 and optional post-build actions. Currently:

  • Warns about built pages with zero or more than one <h1> element (a common SEO and accessibility issue).
  • Warns about duplicate <title> or meta description values across the built corpus.
  • Optionally submits built URLs to IndexNow after the build completes.
  • Optionally generates an llms.txt file at the root of the build output, summarising the site for LLMs.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import seoGraph from '@jdevalk/astro-seo-graph/integration';

export default defineConfig({
    integrations: [
        seoGraph({
            indexNow: {
                key: process.env.INDEXNOW_KEY!,
                host: 'example.com',
                siteUrl: 'https://example.com',
            },
        }),
    ],
});

Options:

Prop Default Description
validateH1 true Warn about pages without exactly one <h1>
validateUniqueMetadata true Warn about duplicate <title> or meta description across pages
indexNow Submit built URLs to IndexNow. See below for sub-options.
llmsTxt Generate llms.txt at the build root. See below for sub-options.

indexNow sub-options: key (8–128 hex chars), host (bare host, e.g. example.com), siteUrl (absolute origin), keyLocation? (defaults to https://<host>/<key>.txt), endpoint? (defaults to api.indexnow.org), filter? (drop URLs for which the callback returns false; composed on top of the built-in /404 exclusion).

The /404 page is always excluded — no one needs search engines notified about it, and submitting it wastes daily IndexNow quota. Use filter to exclude site-specific utility pages:

indexNow: {
    key: process.env.INDEXNOW_KEY!,
    host: 'example.com',
    siteUrl: 'https://example.com',
    // Skip paginated archives like /blog/2/, /videos/3/
    filter: (url) => !/^\/(?:blog|videos)\/\d+\/$/.test(new URL(url).pathname),
},

llmsTxt sub-options: title (required H1), siteUrl (required, used to resolve crawled HTML paths), summary? (rendered as a blockquote), details? (extra paragraphs), sections? (user-supplied sections; when given, no pages are auto-collected), filter? (drop URLs from the auto-generated section), autoSectionName? (defaults to Pages), outputPath? (defaults to llms.txt).

index.html paths are rewritten to their trailing-slash form. Only static pages are checked/submitted (SSR pages aren't on disk at build time).

IndexNow key route

Serve the IndexNow key-verification file at /<key>.txt so participating engines can confirm host ownership:

// src/pages/[your-key-here].txt.ts
import { createIndexNowKeyRoute } from '@jdevalk/astro-seo-graph';

export const GET = createIndexNowKeyRoute({ key: 'your-key-here' });

The filename (minus .txt.ts) must equal the key. Pair this with the indexNow integration option above, or call submitToIndexNow from your own deploy hook.

Deploy the key file first. IndexNow verifies host ownership by fetching https://<host>/<key>.txt on every submission. Submissions sent before the key file is reachable in production get rejected (HTTP 403) and the key is treated as invalid going forward — you'll have to rotate it. Ship the route, deploy, confirm the .txt loads over HTTPS, then enable indexNow in the integration.

Validating your output

The build-time integration checks only catch a narrow set of issues. After deploying, verify the rendered JSON-LD against:

  1. Google Rich Results Test
  2. Schema.org Validator

See AGENTS.md for details on what to look for.

License

MIT © Joost de Valk