5 min read

What this chapter does to you: This is the chapter you can act on today. Everything here maps directly onto the stack the Full-Stack Playbook already committed you to — Next.js App Router — so there's no new infrastructure to learn, just conventions to use correctly. Layer 1 from the last chapter, made concrete.


Metadata: static by default, dynamic when the data demands it

Next.js's Metadata API replaces hand-written <head> tags with a typed export. Set the shared defaults once in the root layout:

tsx
// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://yourapp.com'),
  title: { default: 'Your App', template: '%s | Your App' },
  description: 'One sentence, written for a human, not stuffed with keywords.',
}

metadataBase matters more than it looks — without it, relative Open Graph image URLs resolve incorrectly when a page is shared, and you get broken link previews. The template gives every page a consistent "Page Title | Your App" pattern without repeating the suffix everywhere.

For anything data-driven — a blog post, a product page, this reader's own chapter pages — use generateMetadata, and pull from the same data source that renders the page, never a hardcoded string:

tsx
// app/[section]/[chapter]/page.tsx (this reader's own pattern)
export async function generateMetadata({ params }): Promise<Metadata> {
  const chapter = getChapter(params.section, params.chapter)
  return {
    title: chapter.title,
    description: chapter.description ?? `${chapter.title} — read in the Engineering Curriculum.`,
  }
}

If the metadata can drift out of sync with the actual page content because it's maintained by hand in a second place, it will drift.

Sitemaps and robots.txt: generated, not hand-maintained

Next.js serves sitemap.ts and robots.ts at /sitemap.xml and /robots.txt automatically via file convention — no static XML file to forget to update.

ts
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  return getSections().flatMap(section =>
    section.chapters.map(chapter => ({
      url: `https://yourapp.com/${section.id}/${chapter.slug}`,
      lastModified: chapter.updatedAt,
    }))
  )
}

The same getSections() this reader already uses to build its sidebar is exactly what should drive its sitemap — one source of truth, so a new chapter added to content/ is automatically both navigable in the UI and discoverable by every crawler, with zero extra step to remember. A sitemap that's a static file someone edits by hand is a sitemap that's stale within a month.

ts
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/', disallow: '/api/' },
    sitemap: 'https://yourapp.com/sitemap.xml',
  }
}

Canonical URLs — pick the one true version of every page

Recall from chapter 1: www vs bare domain, trailing slash vs no trailing slash, and tracking query params (?utm_source=...) all produce different URLs serving identical content unless you tell search engines which one is canonical. This reader's own next.config.js sets trailingSlash: true — a real, live decision that fixes half the duplication problem by forcing one consistent URL shape before canonical tags even need to argue about it. The other half — canonicalizing away tracking params — needs an explicit alternates.canonical in metadata:

tsx
export const metadata: Metadata = {
  alternates: { canonical: `https://yourapp.com/${section}/${chapter}` },
}

Always point to the clean URL, computed from route params, never read from request.url — a canonical tag that echoes back whatever query params the visitor happened to arrive with defeats the entire point.

JSON-LD: the structured data that both search and AI systems parse

JSON-LD is a <script> tag containing structured, machine-readable facts about the page, additional to (not a replacement for) the visible content. It's what unlocks rich results in Google and gives GEO-relevant crawlers an unambiguous, structured fact set to cite instead of having to parse prose.

tsx
export default function ChapterPage({ chapter }) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: chapter.title,
    author: { '@type': 'Organization', name: 'Your App' },
    datePublished: chapter.publishedAt,
  }
  return (
    <>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
      {/* page content */}
    </>
  )
}

The five schema types that account for most of the real-world payoff: Organization (once, site-wide — establishes who you are as an entity), Article/BlogPosting, Product, BreadcrumbList (cheap, and it's what produces the breadcrumb trail in search results), and FAQPage — with the caveat from the last chapter that its rich-result eligibility has narrowed, so don't treat it as a guaranteed win. Non-negotiable rule: schema must describe what's actually visible on the page. Structured data that claims something the rendered page doesn't show is treated as manipulation, not optimization, the moment it's caught.

Core Web Vitals — a tiebreaker, not the main event

Three metrics: LCP (Largest Contentful Paint — how fast the main content appears), INP (Interaction to Next Paint — responsiveness to user input), CLS (Cumulative Layout Shift — how much content jumps around while loading). They're confirmed ranking signals, but content quality and E-E-A-T dominate the ranking decision first — Core Web Vitals mainly decide ties between pages that are otherwise similarly relevant and trustworthy. Worth doing well, not worth obsessing over at the expense of the content itself.

Next.js gives you most of this for free if you use its primitives instead of working around them: next/image handles responsive sizing and lazy loading (directly targets CLS and LCP), next/font — already in use in this reader's own app/layout.tsx — eliminates font-loading layout shift and render-blocking font requests, and the App Router's automatic code splitting keeps JS payload (and therefore INP) down without manual effort.

Two edge cases worth naming explicitly

  • Preview deployments must never get indexed. Every Vercel preview URL is a live, publicly reachable page unless you actively block it — set a noindex response when process.env.VERCEL_ENV !== 'production', or every PR preview becomes a duplicate-content problem competing with your real production pages.
  • Redirects: 301 vs 302 matters, and it's not just an SEO nuance — same lesson as app 3's ADR-4 in the Full-Stack Playbook: a 301 tells search engines (and browsers) to transfer ranking signal permanently to the new URL and to stop re-checking the old one; a 302 says "temporary, keep checking." Use 301 only when a URL has actually, permanently moved — using it prematurely on something that might change back is a slow, hard-to-reverse mistake.