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

713 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](https://joost.blog/astro-seo-complete-guide/) by Joost de Valk + official [astro-seo-graph](https://github.com/jdevalk/seo-graph/tree/main/packages/astro-seo-graph) documentation.
---
## 1. Installation
```bash
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:
```astro
---
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`:
```typescript
// 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:
```typescript
buildArticle({
// ...
articleBody: post.bodyText.slice(0, 10000),
}, ids, 'BlogPosting'),
```
---
## 4. Breadcrumbs Linked to the Graph
```typescript
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)
```typescript
// 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
```typescript
// 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
### CI: External Broken Link Checker
For external links (internal validation doesn't cover), use [lychee](https://github.com/lycheeverse/lychee-action) in GitHub Actions:
```yaml
# .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
```typescript
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
```typescript
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
```typescript
// 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)
```typescript
// 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:
```bash
# 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)
```typescript
// 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:
```typescript
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)
```typescript
// 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`)
```typescript
// 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)
```typescript
// 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:
```typescript
// 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:
```typescript
// 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
```typescript
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):
```html
<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
```astro
---
// 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
```html
<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:
```astro
---
// 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
```typescript
// 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
```typescript
// 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
- [astro-seo-graph README](https://github.com/jdevalk/seo-graph/tree/main/packages/astro-seo-graph)
- [astro-seo-graph AGENTS.md](https://github.com/jdevalk/seo-graph/blob/main/AGENTS.md) — 3000+ lines with recipes for 14 site types
- [seo-graph-core](https://github.com/jdevalk/seo-graph/tree/main/packages/seo-graph-core)
- [IndexNow documentation](https://www.indexnow.org/documentation)
- [NLWeb protocol](https://github.com/nlweb-ai/NLWeb)
- [satori](https://github.com/vercel/satori) — JSX → SVG
- [sharp](https://sharp.pixelplumbing.com/) — SVG → JPEG/PNG
- [No-Vary-Search (MDN)](https://developer.mozilla.org/docs/Web/HTTP/Reference/Headers/No-Vary-Search)
- [RFC 9727 — API Catalog](https://www.rfc-editor.org/rfc/rfc9727)
- [llms.txt standard](https://llmstxt.org)
- [Joost: Astro SEO definitive guide](https://joost.blog/astro-seo-complete-guide/)
- [Joost: Agent-ready static blog](https://joost.blog/agent-ready/)