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:
// 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:
// 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.
// 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.
// 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:
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.
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
noindexresponse whenprocess.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.