Files
SlipItIn/.github/skills/astro-sites-manager/references/seo-full-stack.md
Tim Krampitz 01046b01e4 Neue Skills, Referenzen & OpenWiki-Doku integriert
Umfangreiche Erweiterung der Skill-Bibliothek: Neue Skills für Humanisierung (Englisch/PT-BR), Design-Validierung, AI-SEO und Coolify-Deployment inkl. Regelwerke, Presets, Pattern-Referenzen, Testfälle und Automatisierungsskripte. Zusätzliche Skills für Revenue-Centric Design, Pier Cloud, OKF, Lebenslauf- und LinkedIn-Optimierung sowie zahlreiche Referenzdateien, Checklisten und YAML/JSON/Markdown-Templates. Einführung einer vollständigen OpenWiki-Dokumentation mit Architektur-, Domain- und Workflow-Beschreibungen, zentralem Index und automatisierten Updates. Modularer Aufbau, restriktive Lizenzen und umfassende Qualitäts- und Evaluationsmechanismen für alle neuen Inhalte.
2026-07-26 14:00:58 +02:00

21 KiB
Raw Blame History

SEO Full Stack for Astro

Complete reference for implementing technical SEO, structured data, agent discovery, and performance in Astro sites. Based on the @jdevalk/astro-seo-graph stack + complementary patterns.

Sources: Astro SEO: the definitive guide by Joost de Valk + official astro-seo-graph documentation.


1. Installation

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

@jdevalk/seo-graph-core is a transitive dep, but depending on it explicitly lets you pin the version and import piece builders directly.


2. <Seo> Component — Unified Head Metadata

A single component replaces all manual <head> management:

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

<Seo
    title="My Post | My Site"
    description="A concise description for search engines."
    canonical="https://example.com/my-post/"
    ogType="article"
    ogImage="https://example.com/og/my-post.jpg"
    ogImageAlt="My Post"
    ogImageWidth={1200}
    ogImageHeight={675}
    siteName="My Site"
    twitter={{ card: 'summary_large_image', site: '@handle' }}
    article={{ publishedTime: publishDate, tags: ['Astro', 'SEO'] }}
    graph={graph}
    extraLinks={[
        { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
        { rel: 'sitemap', href: '/sitemap-index.xml' },
        { rel: 'alternate', type: 'application/rss+xml', href: '/feed.xml', title: 'RSS' },
    ]}
/>

Automatic behaviors

  • Canonical derived from Astro's site config, query params stripped by default (UTMs don't create duplicates)
  • Robots always includes max-snippet:-1, max-image-preview:large, max-video-preview:-1
  • Canonical omitted when noindex: true (per Google's recommendation)
  • Duplicate Twitter tags suppressed — Twitter falls back to OG automatically
  • hreflang alternates with BCP 47 normalization and automatic x-default
  • og:locale:alternate emitted automatically from the alternates prop

3. Connected JSON-LD Graph (@graph)

A standalone BlogPosting isn't enough. The goal is an interlinked graph via @id:

// src/utils/schema.ts
import {
    buildWebSite, buildBlog, buildPerson,
    buildWebPage, buildArticle, buildBreadcrumbList,
    makeIds,
} from '@jdevalk/seo-graph-core';

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

export function buildBlogPostGraph(post: { title: string; url: string; publishDate: Date; description: string }) {
    return {
        '@context': 'https://schema.org',
        '@graph': [
            buildWebSite({
                url: SITE_URL,
                name: 'My Site',
                publisher: { '@id': ids.person },
                potentialAction: {
                    '@type': 'SearchAction',
                    target: { '@type': 'EntryPoint', urlTemplate: `${SITE_URL}/search?q={search_term_string}` },
                    'query-input': 'required name=search_term_string',
                },
            }, ids),
            buildBlog({ url: `${SITE_URL}/blog/`, name: 'Blog', publisher: { '@id': ids.person } }, ids),
            buildPerson({
                url: SITE_URL,
                name: 'Your Name',
                knowsAbout: ['Astro', 'SEO', 'Web Development'],
                sameAs: ['https://github.com/your-user', 'https://linkedin.com/in/your-user'],
            }, ids),
            buildWebPage({
                url: post.url,
                name: post.title,
                isPartOf: { '@id': ids.website },
                breadcrumb: { '@id': ids.breadcrumb(post.url) },
                datePublished: post.publishDate,
            }, ids),
            buildArticle({
                url: post.url,
                isPartOf: { '@id': ids.webPage(post.url) },
                author: { '@id': ids.person },
                publisher: { '@id': ids.person },
                headline: post.title,
                description: post.description,
                datePublished: post.publishDate,
            }, ids, 'BlogPosting'),
        ],
    };
}

Trust Signals in the Schema

Include these to strengthen authority:

Property Where Purpose
publishingPrinciples WebSite / Person Editorial policy
copyrightHolder + copyrightYear WebPage Copyright ownership
knowsAbout Person Topical authority
SearchAction WebSite Tells agents how to search the site
sameAs Person / Organization Social profiles = identity verification

articleBody in Schema

Include full text (up to 10K chars) so agents can access content via structured data without scraping:

buildArticle({
    // ...
    articleBody: post.bodyText.slice(0, 10000),
}, ids, 'BlogPosting'),

4. Breadcrumbs Linked to the Graph

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

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

const items = breadcrumbsFromUrl({
    url: Astro.url,
    siteUrl: 'https://example.com',
    pageName: post.data.title,
    names: { blog: 'Blog', category: 'Category' },
});

const breadcrumb = buildBreadcrumbList({ url: Astro.url.href, items }, ids);

Each breadcrumb item can reference a graph entity via @id, communicating the structural relationship between page and section.


5. Content Schema Validation (Zod)

// 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(),
    }),
});
  • seoSchema validates title (5120 chars) and description (15160 chars) — build fails if outside limits
  • imageSchema requires alt — image without alt won't compile

6. Build-Time Validation

// astro.config.mjs
import seoGraph from '@jdevalk/astro-seo-graph/integration';

export default defineConfig({
    integrations: [
        seoGraph({
            // All enabled by default:
            validateH1: true,                  // 0 or >1 H1 = warning
            validateUniqueMetadata: true,      // Duplicate title/desc across pages
            validateImageAlt: true,            // <img> without alt
            validateMetadataLength: {          // SERP-safe bounds
                title: { min: 30, max: 65 },
                description: { min: 70, max: 200 },
            },
            validateInternalLinks: {           // Broken internal links or missing trailing slash
                skip: (href) => href.startsWith('/api/'),
            },
        }),
    ],
});

What each validation catches:

  • H1: Templates with duplicate or missing H1
  • Duplicates: Paginated pages sharing the same title (corpus-level bug)
  • Alt text: Images missed over the years
  • Meta length: Titles truncated in SERP or invisible descriptions
  • Internal links: /about-me without trailing slash that works via 301 but wastes a round-trip

For external links (internal validation doesn't cover), use lychee in GitHub Actions:

# .github/workflows/links.yml
name: Check Links
on:
  push:
    paths: ['src/content/**']
  schedule:
    - cron: '0 6 * * 1' # Weekly for link rot

jobs:
  links:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: lycheeverse/lychee-action@v2
        with:
          args: --verbose --no-progress 'src/content/**/*.md'

7. Advanced Sitemaps

Per-Collection with chunks

import sitemap from '@astrojs/sitemap';

sitemap({
    entryLimit: 1000,
    chunks: {
        posts: (item) => {
            if (/^\/blog\/[^/]+/.test(new URL(item.url).pathname)) return item;
        },
        pages: (item) => item, // default bucket
    },
});

Produces: sitemap-posts-0.xml, sitemap-pages-0.xml — makes debugging easier in Google Search Console.

Git-based lastmod

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

// In the sitemap serialize callback:
serialize(item) {
    const filePath = urlToFilePath(item.url); // your logic
    const lastmod = gitLastmod(filePath, {
        excludeCommits: ['abc1234'], // bulk imports that don't count
    });
    return { ...item, lastmod: lastmod ?? item.lastmod };
}

gitLastmod uses git log for the real timestamp of the last commit that touched the file — doesn't depend on filesystem mtime (which resets on CI).


8. IndexNow — Active Notification

IndexNow notifies Bing, Yandex, and others that URLs changed, instead of waiting for passive crawl.

Configuration

// astro.config.mjs
seoGraph({
    indexNow: {
        key: process.env.INDEXNOW_KEY!,
        host: 'example.com',
        siteUrl: 'https://example.com',
        filter: (url) => !/^\/blog\/\d+\/$/.test(new URL(url).pathname), // Exclude pagination
    },
});

Key Route (ownership verification)

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

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

Deploy order matters

  1. Deploy the key route first
  2. Confirm https://example.com/your-key.txt returns 200
  3. Only then enable indexNow in the integration

Submissions before the key is reachable = HTTP 403 and key permanently invalidated.

Direct IndexNow API

For manual or custom submission:

# Single URL
curl "https://api.indexnow.org/indexnow?url=https://example.com/new-post/&key=YOUR_KEY"

# Batch (up to 10,000 URLs per POST)
curl -X POST https://api.indexnow.org/indexnow \
  -H "Content-Type: application/json" \
  -d '{
    "host": "example.com",
    "key": "YOUR_KEY",
    "urlList": [
      "https://example.com/post-1/",
      "https://example.com/post-2/"
    ]
  }'

9. Auto-Generated OG Images

Pipeline: satori (JSX → SVG) → sharp (SVG → JPEG)

// src/pages/og/[...slug].jpg.ts
import satori from 'satori';
import sharp from 'sharp';
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
    const posts = await getCollection('blog');
    return posts.map((p) => ({ params: { slug: p.id } }));
}

export async function GET({ params }) {
    const posts = await getCollection('blog');
    const post = posts.find((p) => p.id === params.slug);
    if (!post) return new Response('Not found', { status: 404 });

    const fontData = await fetch('https://cdn.example.com/fonts/Inter-Bold.ttf')
        .then((r) => r.arrayBuffer());

    const svg = await satori(
        {
            type: 'div',
            props: {
                style: {
                    width: '100%', height: '100%',
                    display: 'flex', flexDirection: 'column',
                    justifyContent: 'center', padding: '60px',
                    background: 'linear-gradient(135deg, #1a1a2e, #16213e)',
                    color: '#ffffff', fontFamily: 'Inter',
                },
                children: [
                    { type: 'div', props: { style: { fontSize: '48px', fontWeight: 700, lineHeight: 1.2 }, children: post.data.title } },
                    { type: 'div', props: { style: { fontSize: '24px', marginTop: '20px', opacity: 0.8 }, children: 'example.com' } },
                ],
            },
        },
        { width: 1200, height: 675, fonts: [{ name: 'Inter', data: fontData, weight: 700 }] },
    );

    const jpeg = await sharp(Buffer.from(svg)).jpeg({ quality: 80 }).toBuffer();

    return new Response(jpeg, {
        headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=31536000, immutable' },
    });
}

Why JPEG and not WebP/AVIF? Social platforms don't reliably support modern formats yet.

Size: 1200×675 — Google Discover requires ≥1200px width, and 16:9 works well cross-platform.

The <Seo> component derives the OG image URL from the slug automatically:

const slug = Astro.url.pathname.replace(/^\/|\/$/g, '');
const ogImage = new URL(`/og/${slug || 'index'}.jpg`, SITE_URL).toString();

10. Agent Discovery

Schema Endpoints (corpus-wide JSON-LD)

// 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 }, datePublished: post.data.publishDate }, ids),
            buildArticle({ url, isPartOf: { '@id': ids.webPage(url) }, author: { '@id': ids.person }, headline: post.data.title, description: post.data.description ?? '', datePublished: post.data.publishDate }, ids, 'BlogPosting'),
        ];
    },
});

Schema Map (/schemamap.xml)

// 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() },
        { path: '/schema/page.json', lastModified: new Date() },
    ],
});

API Catalog (RFC 9727)

// src/pages/.well-known/api-catalog.ts
import { createApiCatalog } from '@jdevalk/astro-seo-graph';

export const GET = createApiCatalog({
    siteUrl: 'https://example.com',
    schemaEndpoints: [
        { path: '/schema/post.json', schemaType: 'BlogPosting', serviceDoc: '/about/' },
    ],
    schemaMap: { path: '/schemamap.xml' },
});

Markdown Alternates

Serve a .md version of every page so agents can consume content without HTML parsing:

// src/pages/blog/[...slug].md.ts
import { getCollection } from 'astro:content';
import { createMarkdownEndpoint } from '@jdevalk/astro-seo-graph';

export const getStaticPaths = async () => {
    const posts = await getCollection('blog');
    return posts.map((p) => ({ params: { slug: p.id } }));
};

export const GET = createMarkdownEndpoint({
    entries: () => getCollection('blog'),
    mapper: (post, slug) =>
        post.id !== slug ? null : {
            frontmatter: { title: post.data.title, canonical: `https://example.com/blog/${post.id}/`, pubDate: post.data.publishDate },
            body: post.body ?? '',
        },
});

Enable the discovery link:

// astro.config.mjs
seoGraph({ markdownAlternate: true });

Emits <link rel="alternate" type="text/markdown" href="…"> on every page.

Content Negotiation via Cloudflare (no SSR)

Transform Rule in the dashboard (works on free plan):

When: http.request.headers["accept"][0] contains "text/markdown"
  AND ends_with(http.request.uri.path, "/")
  AND NOT starts_with(http.request.uri.path, "/_")

Rewrite URI path (dynamic): wildcard_replace(http.request.uri.path, "*/", "${1}.md")

Turns /blog/post//blog/post.md before cache lookup. No need for Vary: Accept header — Cloudflare strips custom Vary values.

llms.txt

seoGraph({
    llmsTxt: {
        title: 'My Site',
        siteUrl: 'https://example.com',
        summary: 'A blog about web development, Astro, and SEO.',
    },
});

Generates /llms.txt automatically at build time listing all pages.

NLWeb Discovery

<link> tag for conversational endpoint (Microsoft protocol):

<link rel="nlweb" href="https://example.com/api/nlweb" />

NLWeb allows AI agents to make conversational queries against site content via schema.org structured data. Still early days but the setup is trivial.


11. Performance SEO

UTM params break caching: ?utm_source=linkedin and ?utm_source=email are different resources to the browser. Header that fixes it:

No-Vary-Search: key-order, params=("utm_source" "utm_medium" "utm_campaign" "utm_content" "utm_term")

Status: IETF draft (draft-ietf-httpbis-no-vary-search), supported in Chrome, degrades gracefully elsewhere.

Configure in _headers (Cloudflare Pages / Netlify):

/*
  No-Vary-Search: key-order, params=("utm_source" "utm_medium" "utm_campaign" "utm_content" "utm_term")

CDN Cache Headers

# _headers (Cloudflare Pages)
/_astro/*
  Cache-Control: public, max-age=31536000, immutable

/og/*
  Cache-Control: public, max-age=31536000, immutable

Hashed assets under /_astro/ never need revalidation — the filename changes when content changes.

View Transitions Prefetch

---
// src/layouts/Base.astro
import { ClientRouter } from 'astro:transitions';
---
<head>
    <ClientRouter defaultStrategy="viewport" />
</head>

defaultStrategy: 'viewport' prefetches links as they scroll into view, making navigation feel instant while keeping initial load minimal.

Font Preloading

<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin />

12. Redirects

Per platform

Platform File Format
Cloudflare Pages public/_redirects /old /new 301
Netlify public/_redirects or netlify.toml Same format
Vercel vercel.json { "source": "/old", "destination": "/new", "permanent": true }

FuzzyRedirect on 404

Safety net for URLs that slip through redirect tables:

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

<html lang="en">
<head><title>Page not found</title></head>
<body>
    <h1>Page not found</h1>
    <p>The page you're looking for doesn't exist.</p>
    <FuzzyRedirect />
    <p><a href="/">Go to the homepage</a></p>
</body>
</html>

Behavior:

  • Fetches /sitemap-index.xml, computes Levenshtein similarity
  • 0.60.85 similarity: shows "Did you mean /correct-path/?"
  • >0.85: auto-redirects with window.location.replace
  • <0.6: does nothing

13. RSS with Full Content

// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';

export async function GET(context) {
    const posts = await getCollection('blog');
    return rss({
        title: 'My Blog',
        description: 'Latest posts',
        site: context.site,
        items: posts.map((post) => ({
            title: post.data.title,
            pubDate: post.data.publishDate,
            description: post.data.description,
            link: `/blog/${post.id}/`,
            content: post.body, // Full content, not excerpts
        })),
    });
}

Include full content in the feed — truncated feeds frustrate readers and give AI systems less to work with.


14. Dynamic robots.txt

// src/pages/robots.txt.ts
export function GET() {
    return new Response(
`User-agent: *
Allow: /

Sitemap: https://example.com/sitemap-index.xml
Schemamap: https://example.com/schemamap.xml
`,
        { headers: { 'Content-Type': 'text/plain' } },
    );
}

The Schemamap: directive points agents to the schema map — similar to Sitemap: but for structured data.


15. Implementation Checklist

  • @jdevalk/astro-seo-graph installed and <Seo> in all layouts
  • JSON-LD @graph with full entities (WebSite, Person, WebPage, Article, BreadcrumbList)
  • Trust signals: publishingPrinciples, knowsAbout, SearchAction
  • seoSchema in content collection with title/desc validation
  • seoGraph() integration with all validations enabled
  • Per-collection sitemaps with gitLastmod
  • IndexNow configured and key route deployed
  • Auto-generated OG images (1200×675 JPEG)
  • Schema endpoints + /schemamap.xml
  • Markdown alternates with <link rel="alternate" type="text/markdown">
  • llms.txt generated automatically
  • <link rel="nlweb"> (when endpoint available)
  • No-Vary-Search header for UTM params
  • CDN cache: immutable for /_astro/*
  • View Transitions with viewport prefetch
  • FuzzyRedirect on 404
  • RSS with full content
  • robots.txt with Sitemap + Schemamap
  • Lychee in CI for broken external links
  • /.well-known/api-catalog (RFC 9727)

References