← Back to Course Index

Module 5.6 — JavaScript Framework SEO Patterns

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?


1. The Core Question Every Framework Must Answer

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.


2. Rendering Mode Vocabulary (Shared Across Frameworks)

All four frameworks implement variations of the same set of rendering strategies. Nail the definitions once here, then apply them everywhere.


3. Next.js — SEO Patterns In Depth

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.

3.1 App Router vs Pages Router

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.

3.2 Rendering Modes in Next.js

// 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} />;
}

3.3 Metadata API — The SEO Control Surface

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:

3.4 Dynamic Sitemap and robots.txt in Next.js

// 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',
  };
}

3.5 Next.js SEO Failure Modes to Audit For


4. Nuxt — SEO Patterns

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.

4.1 Rendering Modes

Nuxt offers three primary rendering modes configured in nuxt.config.ts:

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
  },
});

4.2 Metadata in Nuxt — useHead and useSeoMeta

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.

4.3 Nuxt SEO Failure Modes


5. Astro — SEO Patterns

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.

5.1 Islands Architecture and SEO

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.

5.2 SSG vs SSR in Astro

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);
---

5.3 Astro SEO Patterns — Checklist


6. SvelteKit — SEO Patterns

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.

6.1 Rendering in SvelteKit

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.

6.2 Metadata in SvelteKit — svelte:head

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>

6.3 SvelteKit SEO Failure Modes


7. Cross-Framework Comparison Matrix

Use this as a quick reference when evaluating or auditing a site:


8. Choosing the Right Framework and Rendering Strategy

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:


9. Universal Audit Checklist for Any JS Framework

Regardless of framework, run this QA process on every project before launch and after major changes:


10. Module Summary

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.

Milestone Task

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.