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.
21 KiB
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
siteconfig, 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:alternateemitted automatically from thealternatesprop
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(),
}),
});
seoSchemavalidates title (5–120 chars) and description (15–160 chars) — build fails if outside limitsimageSchemarequiresalt— 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-mewithout trailing slash that works via 301 but wastes a round-trip
CI: External Broken Link Checker
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
- Deploy the key route first
- Confirm
https://example.com/your-key.txtreturns 200 - Only then enable
indexNowin 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
No-Vary-Search
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.6–0.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-graphinstalled and<Seo>in all layouts- JSON-LD
@graphwith full entities (WebSite, Person, WebPage, Article, BreadcrumbList) - Trust signals:
publishingPrinciples,knowsAbout,SearchAction seoSchemain content collection with title/desc validationseoGraph()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.txtgenerated automatically<link rel="nlweb">(when endpoint available)No-Vary-Searchheader for UTM params- CDN cache: immutable for
/_astro/* - View Transitions with viewport prefetch
- FuzzyRedirect on 404
- RSS with full content
robots.txtwith Sitemap + Schemamap- Lychee in CI for broken external links
/.well-known/api-catalog(RFC 9727)
References
- astro-seo-graph README
- astro-seo-graph AGENTS.md — 3000+ lines with recipes for 14 site types
- seo-graph-core
- IndexNow documentation
- NLWeb protocol
- satori — JSX → SVG
- sharp — SVG → JPEG/PNG
- No-Vary-Search (MDN)
- RFC 9727 — API Catalog
- llms.txt standard
- Joost: Astro SEO definitive guide
- Joost: Agent-ready static blog