Phase 5 — Advanced Specialist Topics
By the time you reach this module you have already internalized JavaScript rendering from Phase 0, worked through server-side rendering concepts in the Payload track, and debugged real CSR/SSR issues. This module consolidates that knowledge into a structured, comparative framework across the four major meta-frameworks — Next.js, Nuxt, Astro, and SvelteKit — and teaches you to make deliberate, defensible architectural decisions based on how each one delivers HTML to a crawler.
The goal is not a superficial feature comparison. It is to give you a mental model that transfers to any framework, current or future: when does a crawler receive real HTML, how does the framework populate that HTML, and what can break that contract?
Before comparing frameworks, fix the evaluative lens. A search engine crawler is essentially an HTTP client that receives a response body and — after a rendering delay — executes JavaScript. For SEO purposes, every architectural decision reduces to two questions:
Every framework section below will be evaluated against both questions for each rendering mode it supports.
All four frameworks implement variations of the same set of rendering strategies. Nail the definitions once here, then apply them everywhere.
renderToPipeableStream). Improves TTFB perception and LCP. Crawlers handle streaming; the key concern is that critical above-the-fold content is in early chunks, not deferred ones.
Next.js is the most SEO-relevant framework in production today — it underpins the Payload track and is used by an enormous share of modern marketing and e-commerce sites. You need to know it in detail.
Next.js has two routing systems. The Pages Router (pre-v13) uses file-based routing in /pages and handles metadata via the next/head component. The App Router (v13+, stable in v14) uses /app, React Server Components by default, and a dedicated generateMetadata API. All new projects should use the App Router. You will encounter Pages Router in maintenance work — understand both.
// App Router: SSG with generateStaticParams
// Next.js statically generates one HTML file per product at build time.
// Crawlers receive full content in raw HTML — no JS execution needed.
export async function generateStaticParams() {
const products = await fetchAllProducts(); // runs at BUILD time
return products.map((p) => ({ slug: p.slug }));
}
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.slug); // also runs at BUILD time
return <ProductTemplate product={product} />;
}
// App Router: SSR — force dynamic rendering per request
// Use when content changes frequently or is user-specific.
// 'force-dynamic' opts the entire route out of the static cache.
export const dynamic = 'force-dynamic';
export default async function LivePricePage({ params }) {
const data = await fetchLivePrice(params.id); // runs at REQUEST time
return <PriceDisplay data={data} />;
}
// App Router: ISR — revalidate cached static pages every N seconds
// After the interval, the next request triggers background regeneration.
// Excellent balance of performance + freshness for blog posts, category pages.
export const revalidate = 3600; // re-build stale pages every 1 hour
export default async function BlogPost({ params }) {
const post = await fetchPost(params.slug);
return <Article post={post} />;
}
The App Router's generateMetadata function is how you output <title>, meta descriptions, canonical URLs, Open Graph tags, hreflang alternates, and robots directives. It runs on the server — the output is in the raw HTML response. This is the correct pattern. Never inject these client-side.
// app/products/[slug]/page.tsx
import { Metadata } from 'next';
type Props = { params: { slug: string } };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await fetchProduct(params.slug);
return {
title: product.seoTitle || product.name,
description: product.metaDescription,
alternates: {
canonical: `https://example.com/products/${params.slug}`,
languages: {
'en-US': `/en-us/products/${params.slug}`,
'de-DE': `/de-de/products/${params.slug}`,
},
},
openGraph: {
title: product.name,
description: product.metaDescription,
images: [{ url: product.heroImage.url, width: 1200, height: 630 }],
},
robots: {
index: product.isPublished,
follow: true,
},
};
}
Key rules for Next.js metadata:
generateMetadata (or the static metadata export) — never useEffect to set document title or inject meta tags at runtime.alternates.canonical field outputs <link rel="canonical"> in the <head> of the raw HTML. Verify with View Source.robots field outputs a <meta name="robots"> tag. For HTTP header-level control (e.g., for PDFs), use Next.js middleware to add the X-Robots-Tag header.layout.tsx files. Use layout-level metadata for site-wide defaults; override at the page level.// app/sitemap.ts — generates sitemap.xml from your data source
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetchAllPublishedPosts();
const postEntries = posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
return [
{ url: 'https://example.com', lastModified: new Date(), priority: 1 },
...postEntries,
];
}
// app/robots.ts — generates robots.txt
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] },
],
sitemap: 'https://example.com/sitemap.xml',
};
}
<Head> from next/head inside a 'use client' component — metadata will not be in raw HTML for the App Router.'use client' — this forces client-side rendering for the component subtree, losing SSR/SSG benefits.generateStaticParams: Dynamic routes without this function default to SSR (or 404 on static export). Audit which routes are actually pre-rendered.next/image for the LCP element, leading to CLS and slow LCP. The priority prop on the LCP image removes lazy-loading and adds a preload hint.onClick for navigation instead of <Link href="...">. Googlebot does not reliably follow click events — links must be real <a href> elements in the rendered HTML.Nuxt is the Vue ecosystem's equivalent of Next.js. Its rendering model and SEO API surface are analogous, though the syntax and configuration differ. If you encounter a Vue-based site, Nuxt is almost certainly what you are dealing with.
Nuxt offers three primary rendering modes configured in nuxt.config.ts:
ssr: true (default): Server-side rendering per request. Excellent SEO baseline.ssr: false: SPA mode — client-side rendering only. Avoid for indexable content.nuxi generate): Pre-renders all routes at build time to static HTML. Equivalent to SSG in Next.js. For large sites use nitro.prerender configuration to control which routes are pre-rendered.Nuxt 3 also introduced hybrid rendering via route rules — you can set different rendering strategies per route pattern without splitting your project:
// nuxt.config.ts — hybrid rendering with route rules
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }, // SSG — home page
'/blog/**': { swr: 3600 }, // ISR-equivalent: stale-while-revalidate
'/products/**': { ssr: true }, // SSR per request
'/account/**': { ssr: false }, // CSR — authenticated, no-index zone
'/admin/**': { robots: false }, // adds X-Robots-Tag: noindex
},
});
Nuxt provides two composables for metadata. useHead is general-purpose; useSeoMeta is typed specifically for SEO tags and is the recommended approach. When called in a server context (i.e., in a page component without the client-only wrapper), the output lands in the raw HTML.
// pages/products/[slug].vue
<script setup>
const route = useRoute();
const { data: product } = await useAsyncData(
`product-${route.params.slug}`,
() => $fetch(`/api/products/${route.params.slug}`)
);
useSeoMeta({
title: () => product.value?.seoTitle ?? product.value?.name,
description: () => product.value?.metaDescription,
ogTitle: () => product.value?.name,
ogImage: () => product.value?.heroImage,
robots: () => product.value?.isPublished ? 'index, follow' : 'noindex',
});
useHead({
link: [
{
rel: 'canonical',
href: `https://example.com/products/${route.params.slug}`,
},
],
});
</script>
Critical distinction: In Nuxt, useAsyncData runs on the server during SSR/SSG and the resolved data is passed to the client as inline JSON (hydration payload). This means your metadata — which depends on that data — is server-rendered and present in raw HTML. If you use plain fetch inside onMounted, the data is not available until after client-side JS runs, so metadata will be absent from the raw response. Always use useAsyncData or useFetch for data that drives metadata.
<ClientOnly>: Content inside this component is never in raw HTML.ssr: false on a route that contains indexable content.useHead calls: Multiple components setting the same tag without proper deduplication keys leads to duplicate <title> tags or conflicting canonicals.router.trailingSlash and ensure canonicals match.Astro takes a fundamentally different approach from React/Vue meta-frameworks. Its default output is zero JavaScript — pure, static HTML. JavaScript is added only when you explicitly opt a component into the "islands" model. For content-heavy sites (marketing, blogs, documentation), Astro offers the best possible SEO/performance baseline of any framework.
In Astro, .astro components render to static HTML at build time. Framework components (React, Vue, Svelte) can be embedded as interactive islands using the client:* directives. The key insight for SEO: the content of an island's initial render is still included in the static HTML; only the interactivity is hydrated. This means your hero text, product description, or blog content in a React component used inside Astro is in the raw HTML.
<!-- src/pages/products/[slug].astro -->
---
// This runs at BUILD time (SSG) or at REQUEST time (SSR with adapter)
import { getProductBySlug } from '../../lib/products';
import AddToCartButton from '../../components/AddToCartButton.jsx';
const { slug } = Astro.params;
const product = await getProductBySlug(slug);
---
<html lang="en">
<head>
<title>{product.seoTitle}</title>
<meta name="description" content={product.metaDescription} />
<link rel="canonical" href={`https://example.com/products/${slug}`} />
<script type="application/ld+json" set:html={JSON.stringify(product.schema)} />
</head>
<body>
<h1>{product.name}</h1>
<p>{product.description}</p>
<!-- Only the button is interactive — ships JS for this island only -->
<AddToCartButton client:visible productId={product.id} />
</body>
</html>
Note that <title>, <meta>, <link rel="canonical">, and the JSON-LD script block are all authored directly in the .astro template and end up in the raw HTML. There is no metadata API to learn — you write HTML.
Astro defaults to SSG. Every page with getStaticPaths (or the equivalent) is pre-rendered at build time. To enable SSR (on-demand rendering per request), you add an adapter (Vercel, Netlify, Node, Cloudflare) and set output: 'server' in astro.config.mjs. You can also use output: 'hybrid' to opt individual routes into SSR while keeping others static.
// astro.config.mjs — hybrid mode
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
output: 'hybrid', // static by default, opt-in to SSR per route
adapter: vercel(),
});
<!-- A specific route opts into SSR by exporting prerender = false -->
---
export const prerender = false; // this route is now server-rendered on demand
const liveData = await fetchLiveInventory(Astro.params.id);
---
BaseHead.astro component that accepts title, description, canonical, and OG props — import it in every page layout. This enforces consistent metadata structure across the site.sitemap.xml using the @astrojs/sitemap integration — it crawls your static routes at build time automatically.<script type="application/ld+json"> block in the .astro template, fed from build-time data. No client-side injection — it is always in raw HTML.client:only for any component that contains content, links, or metadata. Use client:load, client:visible, or client:idle — these still server-render the initial HTML; client:only skips server rendering entirely.<Image> component from astro:assets — it enforces explicit width/height (preventing CLS) and outputs modern formats.
SvelteKit is the full-stack framework for Svelte. Its routing model, load functions, and adapter system are conceptually similar to Next.js and Nuxt, but Svelte's compile-step approach produces very small JS bundles with no virtual DOM — excellent for INP and overall performance.
SvelteKit supports SSR (default), SSG, and CSR, controlled via page-level options exported from +page.js or +page.server.js:
// src/routes/products/[slug]/+page.js
// Opt into static pre-rendering for this route
export const prerender = true;
// Opt out of SSR (CSR only) — use sparingly, only for private/auth pages
// export const ssr = false;
// Load function — runs on the server during SSR, runs in the browser during CSR
export async function load({ params, fetch }) {
const product = await fetch(`/api/products/${params.slug}`).then(r => r.json());
return { product };
}
Key distinction: If the load function lives in +page.server.js, it only ever runs on the server — it can use database credentials, private API keys, and so on. If it lives in +page.js, it runs on the server for SSR and in the browser for client-side navigation. For SEO-critical data fetching, prefer +page.server.js to guarantee server execution.
SvelteKit uses the <svelte:head> special element to inject tags into <head>. When this block contains reactive references to data returned by the load function, the tags are server-rendered and present in raw HTML during SSR.
<!-- src/routes/products/[slug]/+page.svelte -->
<script>
export let data; // populated by the load function
$: product = data.product;
$: canonical = `https://example.com/products/${product.slug}`;
</script>
<svelte:head>
<title>{product.seoTitle || product.name}</title>
<meta name="description" content={product.metaDescription} />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={product.name} />
<meta property="og:image" content={product.heroImage} />
{@html `<script type="application/ld+json">${JSON.stringify(product.schema)}</script>`}
</svelte:head>
<h1>{product.name}</h1>
<p>{product.description}</p>
onMount for data fetching that drives metadata — onMount is browser-only; metadata set reactively from it will be absent from raw SSR HTML.ssr: false globally in svelte.config.js instead of per-route — disables SSR site-wide, turning the whole site into a SPA.#hash fragments break URL-based indexability.@sveltejs/adapter-static, running vite build does not produce static HTML files — it produces a Node server. Check your adapter matches your deployment target.Use this as a quick reference when evaluating or auditing a site:
The right choice depends on the content type, update frequency, and the team's existing stack — not on which framework is most fashionable. Apply this decision process:
noindex as a belt-and-suspenders measure regardless.
Regardless of framework, run this QA process on every project before launch and after major changes:
<title>, <meta name="description">, <link rel="canonical">, and the application/ld+json block appear in the raw page source (Ctrl+U / Cmd+U)? If not, they are being injected client-side — fix this.<a href="..."> elements in the raw HTML? Use curl -A "Googlebot" [url] | grep -o 'href="[^"]*"' to verify from the server response.<head> match the URL you want indexed? Check for environment-bleed (staging URLs in production canonicals) and for trailing slash inconsistency.index/noindex signal present in the raw response? Check both <meta name="robots"> and the X-Robots-Tag HTTP header (use curl -I [url])./sitemap.xml and confirm it includes the expected URLs with accurate <lastmod> dates. Cross-reference a sample of sitemap URLs against their canonical tags — they must match.Every modern JavaScript framework gives you the tools to build an SEO-excellent site. The problems arise not from the frameworks themselves but from using the wrong rendering mode for a given content type, injecting metadata client-side rather than server-side, and shipping JS-only internal links that crawlers cannot follow reliably.
The mental model is simple and universal: put everything a crawler needs in the raw HTTP response body, verified by View Source — not in the rendered DOM after JavaScript runs. Rendering strategy, metadata APIs, sitemap generation, and structured data injection are the implementation details; that principle is the constant.
With Next.js you have the most feature-complete SEO toolkit. With Nuxt you have a close Vue equivalent. With Astro you have the cleanest HTML-first foundation. With SvelteKit you have performance-first ergonomics and flexibility. Learn the pattern in one; audit in all four.
Take a live production URL built with any of the four frameworks covered in this module. Without using any third-party tool, determine its rendering strategy by examining only the raw HTTP response and the page source. Then:
You are ready for the Capstone when you can complete this task in under 20 minutes on an unfamiliar site, without prompting.