Cross-Stack Implementation: applying patterns across all major stacks
- Plain HTML / static CSS / vanilla JS (baseline) - React (Vite, Create React App — pure SPA, no SSR) - Next.js 14+ (App Router) — also notes for Pages Router - Vue 3 + Vite (SPA) - Nuxt 3…
Purpose: every SEO pattern in this library was originally written in plain HTML. This framework translates each pattern into the major modern web stacks so an SEO-aware engineer can lift any control directly into their build.
How to use: when a framework in this library shows an HTML snippet, find the matching pattern below and use the version for your stack. The semantics are identical — the syntax differs.
Stacks covered:
- Plain HTML / static CSS / vanilla JS (baseline)
- React (Vite, Create React App — pure SPA, no SSR)
- Next.js 14+ (App Router) — also notes for Pages Router
- Vue 3 + Vite (SPA)
- Nuxt 3 (SSR/SSG)
- Svelte + Vite (SPA)
- SvelteKit (SSR/SSG)
- Astro
- Hugo
- 11ty (Eleventy)
- Remix
- Gatsby
- WordPress (PHP themes, headless WP)
- Shopify (Liquid)
- Webflow
Tailwind CSS: utility-first styling cuts across all of the above. Tailwind-specific concerns (purge, runtime CSS budget, dark mode, accessibility class patterns) live in framework-tailwind.md.
Section 0 — Stack Capability Matrix
Not every stack ships HTML the same way to crawlers. This matrix tells you whether you need to worry about hydration, prerendering, or pure runtime injection.
| Stack | HTML at first byte? | Default rendering | Hydration | SEO complexity |
|---|---|---|---|---|
| Plain HTML | Yes | Static | None | 1/5 |
| Hugo | Yes | SSG | None | 1/5 |
| 11ty | Yes | SSG | None | 1/5 |
| Astro | Yes | SSG (default), SSR opt-in | Islands only | 1/5 |
| Next.js (App Router) | Yes | RSC + SSR | Selective | 2/5 |
| Next.js (Pages Router) | Yes | SSR/SSG | Full | 2/5 |
| Nuxt 3 | Yes | SSR/SSG | Full | 2/5 |
| SvelteKit | Yes | SSR/SSG | Full | 2/5 |
| Remix | Yes | SSR | Full | 2/5 |
| Gatsby | Yes | SSG | Full | 2/5 |
| WordPress | Yes | PHP render | None (unless block has JS) | 1/5 |
| Shopify | Yes | Liquid render | None | 1/5 |
| Webflow | Yes | Static export | Minimal | 2/5 |
| React (Vite/CRA) | No | CSR | Full | 4/5 |
| Vue 3 (Vite) | No | CSR | Full | 4/5 |
| Svelte (Vite) | No | CSR | Full | 4/5 |
Rule: if "HTML at first byte" is No, your default tools for SEO are weaker. Googlebot will execute JS and render most of it, but other crawlers (Bing, AI agents, social previews, search appliances) often will not. For pure SPAs, see framework-react.md for the prerendering decision tree.
Section 1 — Meta Tags & Document Head
Every page needs: <title>, <meta name="description">, <meta name="robots">, viewport, charset, theme-color (optional). Per-route uniqueness is the SEO requirement; the syntax is what changes.
Plain HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page Title — Brand</title>
<meta name="description" content="One sentence describing this page.">
<meta name="robots" content="index,follow,max-image-preview:large">
<link rel="canonical" href="https://example.com/page">
</head>
React (Vite/CRA, no Next.js) — react-helmet-async
import { Helmet } from 'react-helmet-async';
export default function ProductPage({ product }) {
return (
<>
<Helmet>
<title>{product.name} — Brand</title>
<meta name="description" content={product.shortDescription} />
<meta name="robots" content="index,follow,max-image-preview:large" />
<link rel="canonical" href={`https://example.com/products/${product.slug}`} />
</Helmet>
<main>{/* page */}</main>
</>
);
}
Wrap your tree in <HelmetProvider> once. For pure CSR SPAs, this only updates the head after JS runs — not enough for non-Google crawlers. See prerendering note below.
Next.js App Router — metadata export
// app/products/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({ params }): Promise<Metadata> {
const product = await getProduct(params.slug);
return {
title: `${product.name} — Brand`,
description: product.shortDescription,
robots: { index: true, follow: true, 'max-image-preview': 'large' },
alternates: { canonical: `/products/${params.slug}` },
};
}
Set metadataBase: new URL('https://example.com') in app/layout.tsx so relative canonicals resolve.
Next.js Pages Router — next/head
import Head from 'next/head';
export default function ProductPage({ product }) {
return (
<>
<Head>
<title>{product.name} — Brand</title>
<meta name="description" content={product.shortDescription} />
<link rel="canonical" href={`https://example.com/products/${product.slug}`} />
</Head>
<main>{/* page */}</main>
</>
);
}
Vue 3 + Vite — @unhead/vue
<script setup>
import { useHead } from '@unhead/vue';
const props = defineProps(['product']);
useHead({
title: () => `${props.product.name} — Brand`,
meta: [
{ name: 'description', content: () => props.product.shortDescription },
{ name: 'robots', content: 'index,follow,max-image-preview:large' },
],
link: [
{ rel: 'canonical', href: () => `https://example.com/products/${props.product.slug}` },
],
});
</script>
Nuxt 3 — useSeoMeta (recommended) or useHead
<script setup>
const { data: product } = await useFetch(`/api/products/${useRoute().params.slug}`);
useSeoMeta({
title: () => `${product.value.name} — Brand`,
description: () => product.value.shortDescription,
robots: 'index,follow,max-image-preview:large',
});
useHead({
link: [{ rel: 'canonical', href: () => `https://example.com/products/${product.value.slug}` }],
});
</script>
SvelteKit — <svelte:head>
<script>
export let data; // from +page.server.ts load()
</script>
<svelte:head>
<title>{data.product.name} — Brand</title>
<meta name="description" content={data.product.shortDescription} />
<meta name="robots" content="index,follow,max-image-preview:large" />
<link rel="canonical" href={`https://example.com/products/${data.product.slug}`} />
</svelte:head>
Svelte + Vite (SPA) — svelte-meta-tags or manual store
For pure SPAs, prefer prerendering. If you must do client-only, use svelte-meta-tags or write to document.title and set meta in onMount.
Astro — frontmatter
---
const { product } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8">
<title>{product.name} — Brand</title>
<meta name="description" content={product.shortDescription} />
<link rel="canonical" href={`https://example.com/products/${product.slug}`} />
</head>
Hugo — layouts/_default/baseof.html + page params
<title>{{ .Title }} — {{ .Site.Title }}</title>
<meta name="description" content="{{ .Description | default .Summary }}">
<link rel="canonical" href="{{ .Permalink }}">
In your front matter:
---
title: "Product Name"
description: "One sentence."
---
11ty (Eleventy) — Nunjucks/Liquid layout
<title>{{ title }} — {{ site.name }}</title>
<meta name="description" content="{{ description }}">
<link rel="canonical" href="{{ site.url }}{{ page.url }}">
Remix — meta export
export const meta: MetaFunction<typeof loader> = ({ data }) => [
{ title: `${data.product.name} — Brand` },
{ name: 'description', content: data.product.shortDescription },
{ tagName: 'link', rel: 'canonical', href: `https://example.com/products/${data.product.slug}` },
];
export const loader = async ({ params }) => json({ product: await getProduct(params.slug) });
Gatsby — gatsby-plugin-react-helmet or v5 Head API
export const Head = ({ data }) => (
<>
<title>{data.product.name} — Brand</title>
<meta name="description" content={data.product.shortDescription} />
<link rel="canonical" href={`https://example.com/products/${data.product.slug}`} />
</>
);
WordPress — Yoast / RankMath / Slim SEO, or theme
For most sites use the SEO plugin's title/description fields. If hand-rolling:
// header.php
<title><?php echo esc_html(get_the_title()); ?> — <?php bloginfo('name'); ?></title>
<meta name="description" content="<?php echo esc_attr(get_the_excerpt()); ?>">
<link rel="canonical" href="<?php echo esc_url(get_permalink()); ?>">
Shopify Liquid — theme.liquid
<title>{{ page_title }}{% if current_tags %} – tagged "{{ current_tags | join: ', ' }}"{% endif %}{% if current_page != 1 %} – Page {{ current_page }}{% endif %}{% unless page_title contains shop.name %} – {{ shop.name }}{% endunless %}</title>
<meta name="description" content="{{ page_description | default: shop.description }}">
<link rel="canonical" href="{{ canonical_url }}">
canonical_url is a Shopify-provided global. Don't hand-roll it.
Webflow — Page Settings panel
Set Title Tag, Meta Description, Open Graph fields per page in the Pages panel. For collection (CMS) items, bind the fields to CMS fields under "Collection page". Webflow auto-emits canonical to the page's own URL — override only via custom <head> code if needed.
Headless WordPress (Faust/WPGraphQL + Next.js)
Pull SEO fields via GraphQL, pass into generateMetadata (App Router) or <Head> (Pages Router). See framework-headless.md.
Section 2 — Canonical URLs
Plain HTML
<link rel="canonical" href="https://example.com/page">
React (Helmet)
<Helmet>
<link rel="canonical" href={`https://example.com${pathname}`} />
</Helmet>
Next.js App Router
export const metadata: Metadata = {
alternates: { canonical: '/products/widget' }, // resolved against metadataBase
};
Next.js Pages Router
<Head>
<link rel="canonical" href={`https://example.com${router.asPath.split('?')[0]}`} />
</Head>
Nuxt 3
useHead({
link: [{ rel: 'canonical', href: () => `https://example.com${useRoute().path}` }],
});
SvelteKit (in +page.svelte)
<svelte:head>
<link rel="canonical" href={`https://example.com${$page.url.pathname}`} />
</svelte:head>
Astro
<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href} />
Set site: 'https://example.com' in astro.config.mjs.
Hugo
<link rel="canonical" href="{{ .Permalink }}">
.Permalink already absolute and includes baseURL.
WordPress
Yoast/RankMath emit canonical automatically. To override: filter wpseo_canonical (Yoast) or rank_math/frontend/canonical (RankMath).
Shopify
{{ canonical_url }} — handles tag pages, paginated views, and variant query strings correctly. Don't replace.
Cross-stack rule: canonical must be (a) absolute, (b) self-referencing on the canonical URL itself, (c) resolved per-route, not hardcoded. The most common bug is forgetting to update it when you add query strings or paginate.
Section 3 — JSON-LD Structured Data
JSON-LD is just a <script type="application/ld+json"> block. The data needs to render in the initial HTML for non-Google crawlers and AI agents to consume it. Never inject JSON-LD client-side only.
Plain HTML
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Widget",
"offers": {"@type":"Offer","price":"19.99","priceCurrency":"USD"}
}
</script>
React (Helmet, server-rendered or pre-rendered)
<Helmet>
<script type="application/ld+json">{JSON.stringify({
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
offers: { "@type": "Offer", price: product.price, priceCurrency: "USD" },
})}</script>
</Helmet>
For client-only React, switch to dangerouslySetInnerHTML in a wrapping element pre-rendered, or move to Next.js/prerender.
Next.js (App Router) — server component
export default async function Page({ params }) {
const product = await getProduct(params.slug);
const ld = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
offers: { '@type': 'Offer', price: product.price, priceCurrency: 'USD' },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }}
/>
{/* rest of page */}
</>
);
}
dangerouslySetInnerHTML is correct here — JSON-LD must not be HTML-escaped. React's JSON.stringify is safe, but for user-supplied content sanitize first.
Nuxt 3
useHead({
script: [{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.value.name,
}),
}],
});
SvelteKit
<svelte:head>
{@html `<script type="application/ld+json">${JSON.stringify(ld)}</script>`}
</svelte:head>
Astro
---
const ld = { '@context': 'https://schema.org', '@type': 'Product', name: product.name };
---
<script type="application/ld+json" set:html={JSON.stringify(ld)} />
Hugo
Build the dict in template, marshal to JSON:
{{ $ld := dict
"@context" "https://schema.org"
"@type" "Product"
"name" .Title
"offers" (dict "@type" "Offer" "price" .Params.price "priceCurrency" "USD")
}}
<script type="application/ld+json">{{ $ld | jsonify }}</script>
WordPress
Use the SEO plugin's schema features (Yoast Premium, RankMath built-in), or hook wp_head:
add_action('wp_head', function() {
if (!is_singular('product')) return;
$ld = ['@context'=>'https://schema.org','@type'=>'Product','name'=>get_the_title()];
echo '<script type="application/ld+json">' . wp_json_encode($ld) . '</script>';
});
Shopify Liquid
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": {{ product.title | json }},
"offers": {
"@type": "Offer",
"price": {{ product.price | divided_by: 100.0 | json }},
"priceCurrency": {{ shop.currency | json }}
}
}
</script>
| json is critical — Liquid's json filter handles escaping.
Cross-stack rule for JSON-LD: validate every output with the Schema.org Validator and (where applicable) Google's Rich Results Test. A single malformed property silently breaks the whole block. See framework-schema.md for content patterns.
Section 4 — hreflang & i18n
Plain HTML
<link rel="alternate" hreflang="en-us" href="https://example.com/en-us/page">
<link rel="alternate" hreflang="es-mx" href="https://example.com/es-mx/pagina">
<link rel="alternate" hreflang="x-default" href="https://example.com/page">
Next.js App Router
export const metadata: Metadata = {
alternates: {
canonical: '/en-us/page',
languages: {
'en-us': '/en-us/page',
'es-mx': '/es-mx/pagina',
'x-default': '/page',
},
},
};
Nuxt 3 (@nuxtjs/i18n)
The module emits hreflang automatically when seo: true and per-locale routing configured. Override per page via setI18nParams.
SvelteKit
Compute locale alternates in +page.server.ts load() and emit in <svelte:head>.
Astro
---
const alternates = [
{ hreflang: 'en-us', href: `https://example.com/en-us${Astro.url.pathname.replace(/^\/[a-z-]+/, '')}` },
{ hreflang: 'es-mx', href: `https://example.com/es-mx${Astro.url.pathname.replace(/^\/[a-z-]+/, '')}` },
];
---
{alternates.map(a => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
Hugo (multilingual mode)
{{ range .AllTranslations }}
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">
{{ end }}
WordPress (Polylang / WPML)
WPML and Polylang both auto-emit hreflang. Verify with view-source: because their plugin order matters — sometimes Yoast's hreflang fights WPML's. Disable one.
Shopify
Markets feature emits hreflang automatically when alternate locales/domains configured. For manual control, use the linklists API or hand-roll in theme.liquid.
Cross-stack rule: hreflang must reciprocate. If /en-us/page lists es-mx, then /es-mx/pagina must list en-us. The most common bug is one-directional links. See framework-international.md.
Section 5 — Open Graph & Twitter Cards
Same tags, different injection point per stack.
Plain HTML
<meta property="og:title" content="Page Title">
<meta property="og:description" content="Description">
<meta property="og:image" content="https://example.com/og.jpg">
<meta property="og:type" content="article">
<meta property="og:url" content="https://example.com/page">
<meta name="twitter:card" content="summary_large_image">
Next.js App Router
export const metadata: Metadata = {
openGraph: {
title: 'Page Title',
description: 'Description',
images: [{ url: '/og.jpg', width: 1200, height: 630 }],
type: 'article',
},
twitter: { card: 'summary_large_image' },
};
Use app/<route>/opengraph-image.tsx for dynamic OG images — Next.js will generate the static asset and emit the meta automatically.
Nuxt 3
useSeoMeta({
ogTitle: 'Page Title',
ogDescription: 'Description',
ogImage: 'https://example.com/og.jpg',
ogType: 'article',
twitterCard: 'summary_large_image',
});
Astro
<meta property="og:title" content={title} />
<meta property="og:image" content={new URL('/og.jpg', Astro.site).href} />
Hugo
{{ partial "opengraph.html" . }}
Hugo ships an opengraph internal template: {{ template "_internal/opengraph.html" . }}. Override only if you need custom logic.
Cross-stack rule: OG image must be at least 1200x630 (recommended) and absolute URL — never relative. Test via the Facebook Sharing Debugger and Twitter Card Validator.
Section 6 — Image Optimization
This is the highest-leverage performance pattern across all stacks. The defaults are different.
Plain HTML
<img
src="hero.webp"
alt="Descriptive alt text"
width="1200"
height="630"
loading="lazy"
decoding="async"
sizes="(min-width: 1024px) 800px, 100vw"
srcset="hero-800.webp 800w, hero-1200.webp 1200w, hero-1600.webp 1600w"
>
width and height prevent CLS. loading="lazy" for below-fold; never lazy-load LCP image. decoding="async" is safe everywhere.
Next.js — next/image
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Descriptive alt"
width={1200}
height={630}
priority // for LCP image
sizes="(min-width: 1024px) 800px, 100vw"
/>
Next/Image auto-emits srcset (1x/2x), AVIF/WebP variants, lazy by default (use priority for above-fold), and prevents CLS via width/height. Configure remote domains in next.config.js's images.remotePatterns.
Nuxt 3 — <NuxtImg> from @nuxt/image
<NuxtImg
src="/hero.jpg"
alt="Descriptive alt"
width="1200"
height="630"
format="webp"
loading="eager"
preload
/>
Astro — <Image> from astro:assets
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---
<Image src={hero} alt="Descriptive alt" widths={[400, 800, 1200, 1600]} sizes="(min-width: 1024px) 800px, 100vw" />
Local imports get full optimization (AVIF/WebP, hashed filenames, srcset). For remote images use <Image src="https://..." inferSize />.
SvelteKit — enhanced:img
<script>
import hero from '$lib/hero.jpg?enhanced';
</script>
<enhanced:img src={hero} alt="Descriptive alt" sizes="(min-width: 1024px) 800px, 100vw" />
Requires @sveltejs/enhanced-img Vite plugin.
Hugo — image processing
{{ $hero := resources.Get "hero.jpg" }}
{{ $w800 := $hero.Resize "800x webp" }}
{{ $w1200 := $hero.Resize "1200x webp" }}
<img
src="{{ $w800.RelPermalink }}"
srcset="{{ $w800.RelPermalink }} 800w, {{ $w1200.RelPermalink }} 1200w"
width="{{ $hero.Width }}" height="{{ $hero.Height }}"
alt="Descriptive alt" loading="lazy" decoding="async">
WordPress
Core handles srcset/sizes for the_post_thumbnail() and wp_get_attachment_image(). For modern formats install Imagify, ShortPixel, or use a CDN with format conversion (Cloudflare Polish, Cloudinary).
Shopify Liquid
{{ product.featured_image | image_url: width: 1200 | image_tag:
loading: 'lazy',
widths: '400, 600, 800, 1200, 1600',
sizes: '(min-width: 1024px) 800px, 100vw',
alt: product.featured_image.alt
}}
Shopify's image CDN handles AVIF/WebP via the format parameter automatically based on Accept header.
Cross-stack rules:
- Always set
width/height(or aspect-ratio CSS) to prevent CLS - LCP image:
priority(Next),loading="eager"+fetchpriority="high"(manual) - Below-fold:
loading="lazy"+decoding="async" - Always provide
alt— empty stringalt=""only for decorative images
See framework-imageseo.md for content patterns and framework-pageexperience.md for performance budgets.
Section 7 — Breadcrumbs
Two parts: visible HTML breadcrumb nav, and BreadcrumbList JSON-LD. Both should match.
Plain HTML
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li aria-current="page">Widget</li>
</ol>
</nav>
<script type="application/ld+json">
{
"@context":"https://schema.org",
"@type":"BreadcrumbList",
"itemListElement":[
{"@type":"ListItem","position":1,"name":"Home","item":"https://example.com/"},
{"@type":"ListItem","position":2,"name":"Products","item":"https://example.com/products"},
{"@type":"ListItem","position":3,"name":"Widget"}
]
}
</script>
React component (works in Next/Vite/etc.)
function Breadcrumbs({ items }) {
const ld = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((it, i) => ({
'@type': 'ListItem',
position: i + 1,
name: it.name,
...(it.href && i < items.length - 1 ? { item: `https://example.com${it.href}` } : {}),
})),
};
return (
<>
<nav aria-label="Breadcrumb">
<ol>
{items.map((it, i) => (
<li key={i}>
{it.href && i < items.length - 1
? <a href={it.href}>{it.name}</a>
: <span aria-current="page">{it.name}</span>}
</li>
))}
</ol>
</nav>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
</>
);
}
Hugo partial
{{ define "partials/breadcrumb.html" }}
<nav aria-label="Breadcrumb">
<ol>
{{ template "breadcrumbnav" (dict "p1" . "p2" .) }}
</ol>
</nav>
{{ end }}
{{ define "breadcrumbnav" }}
{{ if .p1.Parent }}{{ template "breadcrumbnav" (dict "p1" .p1.Parent "p2" .p2 ) }}{{ end }}
<li {{ if eq .p1 .p2 }}aria-current="page"{{ end }}>
<a href="{{ .p1.Permalink }}">{{ .p1.Title }}</a>
</li>
{{ end }}
WordPress
Yoast and RankMath both ship breadcrumb shortcodes. For Genesis-based themes use genesis_do_breadcrumbs().
Cross-stack rule: visible breadcrumb labels and JSON-LD name values must match exactly. Mismatches cause Google to drop the rich result.
Section 8 — Internal Linking
Internal links are HTML anchors (<a href>) in every stack. The patterns that vary are programmatic generation and prefetching.
React Router / Next.js / Nuxt / SvelteKit
All ship a <Link> component that prefetches on hover/intersection and emits a real <a> in the DOM. Crawlers see a normal anchor.
// Next.js
import Link from 'next/link';
<Link href="/products/widget" prefetch>Widget</Link>
// React Router
import { Link } from 'react-router-dom';
<Link to="/products/widget">Widget</Link>
// SvelteKit
<a href="/products/widget" data-sveltekit-preload-data>Widget</a>
SEO trap: do not use <button onClick={navigate(...)}> for what should be a link. Crawlers don't follow JS click handlers.
Hugo / 11ty / Astro
Plain <a href> works. For collection links inside templates, generate from data:
{{ range first 5 (where .Site.RegularPages "Section" "blog") }}
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ end }}
WordPress
Use get_permalink() and get_the_title() in templates. For automatic related-posts internal linking install Internal Link Juicer or build into RankMath/Yoast.
Cross-stack rules:
- Anchor text must describe the destination — never "click here", "read more"
- Link from high-authority pages to ones you want to rank
- Don't nofollow internal links unless you have a real reason
- Limit nav menu to 30-40 unique destinations max
See framework-internallinking.md for complete strategy.
Section 9 — Sitemaps & robots.txt
Plain HTML / Static Sites
Hand-written sitemap.xml and robots.txt at site root. For large static sites use a generator like gulp-sitemap or build into your bundler.
Next.js App Router
// app/sitemap.ts
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const products = await getAllProducts();
return [
{ url: 'https://example.com/', lastModified: new Date(), priority: 1.0 },
...products.map(p => ({
url: `https://example.com/products/${p.slug}`,
lastModified: p.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
];
}
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] }],
sitemap: 'https://example.com/sitemap.xml',
};
}
Nuxt 3 — @nuxtjs/sitemap and @nuxtjs/robots
Install both, configure routes in nuxt.config.ts. Dynamic routes via serverMiddleware providing the URL list.
SvelteKit
// src/routes/sitemap.xml/+server.ts
import * as sitemap from 'super-sitemap';
export const GET = async () => sitemap.response({
origin: 'https://example.com',
paths: ['/about', '/contact'],
params: [{ pattern: '/products/[slug]', resolve: async () => (await getProducts()).map(p => ({ slug: p.slug })) }],
});
Astro — @astrojs/sitemap
// astro.config.mjs
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [sitemap({ filter: page => !page.includes('/admin/') })],
});
Hugo
Built-in. hugo --gc outputs public/sitemap.xml automatically. Override via layouts/sitemap.xml if needed.
WordPress
Yoast/RankMath emit XML sitemaps at /sitemap_index.xml. Ensure exactly one plugin owns sitemaps — duplicates split signals.
Shopify
Built-in. /sitemap.xml and per-resource sitemaps. Cannot be edited; control inclusion via collection visibility and product status.
Cross-stack rule: every URL in your sitemap must be (a) canonical, (b) return 200, (c) be noindex-free. The most common audit failure is sitemaps that include redirected, 404, or noindex pages.
See framework-technicalseo.md for full sitemap audit.
Section 10 — Performance & Core Web Vitals
The CWV metrics (LCP, INP, CLS) are stack-agnostic targets. The defaults that affect them differ.
LCP — Largest Contentful Paint (≤2.5s good)
Universal rules:
- LCP image:
<link rel="preload" as="image">for plain HTML;priorityprop for Next/Nuxt;loading="eager" fetchpriority="high"manual. - Avoid client-side data fetching for above-fold content (use SSR/SSG).
- Inline critical CSS for first paint.
| Stack | Default LCP risk | Fix |
|---|---|---|
| Plain HTML | Low | Just preload LCP image |
| Hugo / Astro / 11ty | Low | Same |
| Next.js / Nuxt / SvelteKit (SSR) | Medium | Use server data fetching; mark hero priority |
| React/Vue/Svelte SPA | High | Skeleton states + prerender entry routes |
| WordPress | Medium | Caching plugin (LiteSpeed/WP Rocket) + image CDN |
INP — Interaction to Next Paint (≤200ms good)
INP measures responsiveness across all interactions. Heavy hydration kills INP.
| Stack | INP risk | Fix |
|---|---|---|
| Plain HTML | Very low | N/A |
| Astro | Very low | Islands stay tiny |
| Next.js App Router | Medium | Move state to RSC; client components only where needed |
| Next.js Pages Router | High | Code-split with dynamic imports |
| React/Vue/Svelte SPA | High | React.lazy / dynamicImport for routes |
| WordPress | Low-Medium | Defer non-critical JS, dequeue unused enqueues |
CLS — Cumulative Layout Shift (≤0.1 good)
Universal rules:
- Always set
width/height(or aspect-ratio) on images, videos, iframes - Reserve space for ads / embeds with min-height
- Don't inject DOM above existing content after first paint
- Use font-display: swap with size-adjust to minimize FOUT shift
See framework-pageexperience.md for full performance budgets and framework-pageexperience.md for the audit rubric.
Section 11 — Tailwind CSS Cross-Stack Notes
Tailwind doesn't change SEO patterns — it changes how you apply styles. Key cross-stack concerns:
-
Purge configuration:
tailwind.config.jscontentarray must include every file that uses Tailwind classes, including dynamically generated ones. Missing files = missing classes in production. -
Dynamic classes break purge:
bg-{color}-500won't be detected. Use full class names or safelist:safelist: ['bg-red-500', 'bg-blue-500', 'bg-green-500'] -
Runtime CSS budget: Tailwind v3+ uses JIT — final CSS is small. v2 and earlier could ship 3MB+ unpurged. Verify
npm run buildoutput is under 50KB gzipped. -
Dark mode SEO: dark mode toggle should not affect crawlable content. Use CSS-only or
prefers-color-scheme. Localstorage flicker can affect CLS. -
Accessibility class patterns:
sr-onlyfor screen-reader-only text (use for icon button labels)focus:andfocus-visible:variants for keyboard nav- Avoid
outline-nonewithout a focus replacement aria-*attributes don't have Tailwind variants — apply directly
-
Container queries (Tailwind v3.2+):
@containerlets components adapt to their parent, not viewport. Better for componentized SEO templates.
See framework-tailwind.md for the full Tailwind-specific framework.
Section 12 — Form & Lead-Capture SEO
Forms affect SEO through schema (FAQ, lead, contact), spam (which can drop crawl budget), and conversion tracking (which doesn't affect rankings but does affect ROI accounting).
Plain HTML
<form action="/api/contact" method="POST">
<label for="email">Email <input id="email" name="email" type="email" required></label>
<button type="submit">Send</button>
</form>
React (controlled)
function ContactForm() {
const [email, setEmail] = useState('');
const onSubmit = async (e) => {
e.preventDefault();
await fetch('/api/contact', { method: 'POST', body: JSON.stringify({ email }) });
};
return (
<form onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required value={email} onChange={e => setEmail(e.target.value)} />
<button type="submit">Send</button>
</form>
);
}
Next.js Server Action (App Router)
'use server';
async function submitContact(formData: FormData) {
const email = formData.get('email');
// ... save / email
}
export default function Page() {
return (
<form action={submitContact}>
<input name="email" type="email" required />
<button>Send</button>
</form>
);
}
SvelteKit (Form Actions)
<!-- +page.server.ts -->
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
// ... save / email
},
};
<!-- +page.svelte -->
<form method="POST">
<input name="email" type="email" required />
<button>Send</button>
</form>
Cross-stack rules:
- Always submit to a real endpoint with
<form action method>so it works without JS (good for crawlers and accessibility) - Use
<label>properly —forattribute or wrapping - Spam protection: invisible reCAPTCHA, Cloudflare Turnstile, or honeypot field
- Don't
noindexconfirmation pages — they're often valuable longtail content (or do, depending on strategy)
Section 13 — RSS / Feed Generation
Plain HTML
Hand-roll feed.xml. Tedious; usually only worth it for blog sections.
Next.js
// app/feed.xml/route.ts
export async function GET() {
const posts = await getPosts();
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Blog</title>
<link>https://example.com</link>
${posts.map(p => `<item>
<title>${escapeXml(p.title)}</title>
<link>https://example.com/blog/${p.slug}</link>
<pubDate>${new Date(p.publishedAt).toUTCString()}</pubDate>
<description>${escapeXml(p.excerpt)}</description>
</item>`).join('')}
</channel>
</rss>`;
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml' } });
}
Astro — @astrojs/rss
// src/pages/feed.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export const GET = async (context) => rss({
title: 'Blog',
description: 'Blog feed',
site: context.site,
items: (await getCollection('blog')).map(post => ({
title: post.data.title,
pubDate: post.data.pubDate,
link: `/blog/${post.slug}/`,
})),
});
Hugo
Built-in. index.xml per section. Override via layouts/_default/rss.xml.
WordPress
Built-in at /feed/. Filter via the_excerpt_rss, the_content_feed.
Cross-stack rule: <link rel="alternate" type="application/rss+xml" href="/feed.xml"> in <head> so feed readers and crawlers can discover it.
Section 14 — Server-Sent Status Codes & Redirects
How redirects are configured varies wildly by stack. SEO requirements:
- Permanent moves: 301 (HTTP/1.1) or 308 (HTTP/2; preserves method)
- Temporary: 302 / 307
- Use 410 Gone (not 404) for permanently removed content
- Never chain redirects — every hop costs PageRank
Next.js — next.config.js
module.exports = {
async redirects() {
return [
{ source: '/old-path', destination: '/new-path', permanent: true },
];
},
};
Nuxt 3 — nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/old-path': { redirect: { to: '/new-path', statusCode: 301 } },
},
});
SvelteKit
// src/hooks.server.ts
import { redirect } from '@sveltejs/kit';
export const handle = async ({ event, resolve }) => {
if (event.url.pathname === '/old-path') throw redirect(301, '/new-path');
return resolve(event);
};
Astro
// astro.config.mjs
export default defineConfig({
redirects: { '/old-path': '/new-path' },
});
Hugo
# config.yaml
[[deployment.matchers]]
pattern = "^/old-path$"
statusCode = 301
newURL = "/new-path"
Or use aliases in front matter for many-to-one.
nginx (any stack behind it)
server {
rewrite ^/old-path$ /new-path permanent;
# 410 Gone:
location = /removed-page { return 410; }
}
WordPress
Use Redirection plugin or RankMath's redirect manager. Avoid .htaccess redirects piling up.
Cross-stack rule: redirect on the request path, not after rendering. Server-level redirect is always cheaper than app-level. Never use <meta http-equiv="refresh"> for SEO redirects.
Section 15 — Build-Time Validation Checklist
Run these checks for every stack before deploying:
- Crawlability: every public route returns 200 with valid HTML.
curl -Ieach. - Title uniqueness: scrape all titles, dedupe — should be ~100% unique.
- Description uniqueness: same as above for
<meta name="description">. - Canonical present and absolute: every page has one, pointing at itself or its canonical version.
- No noindex on production pages:
grep -r "noindex"should be empty (or only test pages). - JSON-LD validates: pipe each page's LD blocks through schema.org validator.
- hreflang reciprocity (if i18n): every locale links to every other.
- OG image present and absolute: every page.
- Sitemap covers all canonical URLs: diff
crawl resultsvssitemap.xml. - robots.txt allows crawl: no accidental
Disallow: /. - HTTPS only: no mixed content; HSTS in production.
- Core Web Vitals pass: Lighthouse mobile + PageSpeed Insights field data.
A simple bash one-liner for #2/#3 dedupe:
xargs -n1 curl -s | grep -oE '<title>[^<]+</title>' | sort | uniq -c | sort -n
Section 16 — When to Switch Stacks for SEO
Don't switch unless you have a real reason. But these are real reasons:
- CSR SPA struggling for visibility: client-only React/Vue/Svelte where Bing/AI agents matter → migrate to Next.js / Nuxt / SvelteKit (or add prerendering).
- WordPress page speed plateau: heavily plugin-bloated → migrate to headless WP + Next.js (
framework-headless.md) or static export with WP2Static. - Shopify themes hitting performance limits: liquid-heavy themes → headless Shopify + Hydrogen/Nuxt-Apollo.
- Webflow pricing or developer ergonomics: → Astro or Next.js with same designer asset pipeline.
Migration is a 4-6 week project minimum. Plan with framework-migration.md and framework-migration.md (URL change + redirect discipline).
Cross-Reference Map
This framework is the bridge file — start here, jump to specifics:
- Schema and JSON-LD content patterns:
framework-schema.md - E-A-T signals across stacks:
framework-eeat.md - Image SEO depth:
framework-imageseo.md - Internal linking strategy:
framework-internallinking.md - Page experience / CWV depth:
framework-pageexperience.mdandframework-pageexperience.md - Technical SEO audit rubric:
framework-technicalseo.md - International / hreflang:
framework-international.md - Accessibility (WCAG):
framework-accessibility.md - Platform deep-dives:
framework-nextjs.md,framework-headless.md,framework-astrohugo.md,framework-react.md,framework-tailwind.md,framework-wordpress.md,framework-shopify.md
If a framework in this library shows you HTML and you're not on plain HTML, look it up here. If it's missing, the pattern likely doesn't apply to your stack — but flag it so this file can be updated.
Maintenance: when adding a new stack to the library, add it to:
- Section 0 capability matrix
- Each section's example list (where the pattern is non-trivially different)
- The cross-reference map at the bottom
Want this framework implemented on your site?
ThatDevPro ships these frameworks as productized services. SDVOSB-certified veteran owned. Cassville, Missouri.
See Engine Optimization service ›