SEO & AI Engine Optimization Framework · May 2026

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:

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:

  1. Always set width/height (or aspect-ratio CSS) to prevent CLS
  2. LCP image: priority (Next), loading="eager" + fetchpriority="high" (manual)
  3. Below-fold: loading="lazy" + decoding="async"
  4. Always provide alt — empty string alt="" 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:

  1. Anchor text must describe the destination — never "click here", "read more"
  2. Link from high-authority pages to ones you want to rank
  3. Don't nofollow internal links unless you have a real reason
  4. 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:

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:

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:

  1. Purge configuration: tailwind.config.js content array must include every file that uses Tailwind classes, including dynamically generated ones. Missing files = missing classes in production.

  2. Dynamic classes break purge: bg-{color}-500 won't be detected. Use full class names or safelist:

    safelist: ['bg-red-500', 'bg-blue-500', 'bg-green-500']
    
  3. Runtime CSS budget: Tailwind v3+ uses JIT — final CSS is small. v2 and earlier could ship 3MB+ unpurged. Verify npm run build output is under 50KB gzipped.

  4. Dark mode SEO: dark mode toggle should not affect crawlable content. Use CSS-only or prefers-color-scheme. Localstorage flicker can affect CLS.

  5. Accessibility class patterns:

    • sr-only for screen-reader-only text (use for icon button labels)
    • focus: and focus-visible: variants for keyboard nav
    • Avoid outline-none without a focus replacement
    • aria-* attributes don't have Tailwind variants — apply directly
  6. Container queries (Tailwind v3.2+): @container lets 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:

  1. Always submit to a real endpoint with <form action method> so it works without JS (good for crawlers and accessibility)
  2. Use <label> properly — for attribute or wrapping
  3. Spam protection: invisible reCAPTCHA, Cloudflare Turnstile, or honeypot field
  4. Don't noindex confirmation 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:

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:

  1. Crawlability: every public route returns 200 with valid HTML. curl -I each.
  2. Title uniqueness: scrape all titles, dedupe — should be ~100% unique.
  3. Description uniqueness: same as above for <meta name="description">.
  4. Canonical present and absolute: every page has one, pointing at itself or its canonical version.
  5. No noindex on production pages: grep -r "noindex" should be empty (or only test pages).
  6. JSON-LD validates: pipe each page's LD blocks through schema.org validator.
  7. hreflang reciprocity (if i18n): every locale links to every other.
  8. OG image present and absolute: every page.
  9. Sitemap covers all canonical URLs: diff crawl results vs sitemap.xml.
  10. robots.txt allows crawl: no accidental Disallow: /.
  11. HTTPS only: no mixed content; HSTS in production.
  12. 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:

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:

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:

  1. Section 0 capability matrix
  2. Each section's example list (where the pattern is non-trivially different)
  3. 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 ›