DEV Community

Joseph Anady
Joseph Anady

Posted on • Originally published at thatdevpro.com

Cross-Stack SEO Implementation Framework

Originally published at thatdevpro.com. This framework reference is part of the 14-tier Engine Optimization stack from ThatDevPro, an SDVOSB-certified veteran-owned web + AI engineering studio. You are reading the dev.to mirror; the source-of-truth canonical version with embedded validation tools lives at the link above.

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>
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}` },
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Hugo — layouts/_default/baseof.html + page params

<title>{{ .Title }} — {{ .Site.Title }}</title>
<meta name="description" content="{{ .Description | default .Summary }}">
<link rel="canonical" href="{{ .Permalink }}">
Enter fullscreen mode Exit fullscreen mode

In your front matter:

---
title: "Product Name"
description: "One sentence."
---
Enter fullscreen mode Exit fullscreen mode

11ty (Eleventy) — Nunjucks/Liquid layout

<title>{{ title }} — {{ site.name }}</title>
<meta name="description" content="{{ description }}">
<link rel="canonical" href="{{ site.url }}{{ page.url }}">
Enter fullscreen mode Exit fullscreen mode

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) });
Enter fullscreen mode Exit fullscreen mode

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}`} />
  </>
);
Enter fullscreen mode Exit fullscreen mode

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()); ?>">
Enter fullscreen mode Exit fullscreen mode

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 }}">
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

React (Helmet)

<Helmet>
  <link rel="canonical" href={`https://example.com${pathname}`} />
</Helmet>
Enter fullscreen mode Exit fullscreen mode

Next.js App Router

export const metadata: Metadata = {
  alternates: { canonical: '/products/widget' }, // resolved against metadataBase
};
Enter fullscreen mode Exit fullscreen mode

Next.js Pages Router

<Head>
  <link rel="canonical" href={`https://example.com${router.asPath.split('?')[0]}`} />
</Head>
Enter fullscreen mode Exit fullscreen mode

Nuxt 3

useHead({
  link: [{ rel: 'canonical', href: () => `https://example.com${useRoute().path}` }],
});
Enter fullscreen mode Exit fullscreen mode

SvelteKit (in +page.svelte)

<svelte:head>
  <link rel="canonical" href={`https://example.com${$page.url.pathname}`} />
</svelte:head>
Enter fullscreen mode Exit fullscreen mode

Astro

<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href} />
Enter fullscreen mode Exit fullscreen mode

Set site: 'https://example.com' in astro.config.mjs.

Hugo

<link rel="canonical" href="{{ .Permalink }}">
Enter fullscreen mode Exit fullscreen mode

.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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
    }),
  }],
});
Enter fullscreen mode Exit fullscreen mode

SvelteKit

<svelte:head>
  {@html `<script type="application/ld+json">${JSON.stringify(ld)}</script>`}
</svelte:head>
Enter fullscreen mode Exit fullscreen mode

Astro

---
const ld = { '@context': 'https://schema.org', '@type': 'Product', name: product.name };
---
<script type="application/ld+json" set:html={JSON.stringify(ld)} />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>';
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

| 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">
Enter fullscreen mode Exit fullscreen mode

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',
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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} />)}
Enter fullscreen mode Exit fullscreen mode

Hugo (multilingual mode)

{{ range .AllTranslations }}
  <link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">
{{ end }}
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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' },
};
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

Astro

<meta property="og:title" content={title} />
<meta property="og:image" content={new URL('/og.jpg', Astro.site).href} />
Enter fullscreen mode Exit fullscreen mode

Hugo

{{ partial "opengraph.html" . }}
Enter fullscreen mode Exit fullscreen mode

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"
>
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

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
/>
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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
}}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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) }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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',
  };
}
Enter fullscreen mode Exit fullscreen mode

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 })) }],
});
Enter fullscreen mode Exit fullscreen mode

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/') })],
});
Enter fullscreen mode Exit fullscreen mode

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; priority prop 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:

  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']
Enter fullscreen mode Exit fullscreen mode
  1. 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.

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

  3. 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
  4. 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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' } });
}
Enter fullscreen mode Exit fullscreen mode

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}/`,
  })),
});
Enter fullscreen mode Exit fullscreen mode

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 },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

Nuxt 3 — nuxt.config.ts

export default defineNuxtConfig({
  routeRules: {
    '/old-path': { redirect: { to: '/new-path', statusCode: 301 } },
  },
});
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

Astro

// astro.config.mjs
export default defineConfig({
  redirects: { '/old-path': '/new-path' },
});
Enter fullscreen mode Exit fullscreen mode

Hugo

# config.yaml
[[deployment.matchers]]
  pattern = "^/old-path$"
  statusCode = 301
  newURL = "/new-path"
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.md and framework-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:

  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

From the ThatDevPro Engine Optimization framework library. Studio: ThatDevPro (SDVOSB veteran-owned web + AI engineering). Sister property: ThatDeveloperGuy. Source: https://www.thatdevpro.com/insights/framework-cross-stack-implementation/.

Top comments (0)