Sitemaps, robots and structured data
Help search engines find and understand every Next.js page: sitemap.ts and generateSitemaps, robots.ts, the web app manifest, JSON-LD and canonical URLs.
- 01Serve the files crawlers readWrite sitemap.ts, generateSitemaps, robots.ts and manifest.ts, and read what Next.js serves at each address.
- 02Describe a page safelyAdd JSON-LD in a script tag, and escape < so data can't end the script early.
- 03Name one address per pageSet metadataBase and alternates.canonical, and keep a page out of results with the robots field instead of robots.txt.
How search engines find the tea shop's pages
A search engine finds pages with a crawler: a program that downloads a page, follows its links, and downloads the pages they point to. Links alone can miss pages, and they don't say when a page changed. So a site can hand the crawler a list: a sitemap, an XML file of every address you want found, each with the day it last changed. In Next.js, you write the list as code, in app/sitemap.ts, and Next.js serves it as XML at /sitemap.xml:
app/sitemap.tsimport type { MetadataRoute } from "next";import { getTeas } from "./teas"; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const teas = await getTeas(); return [ { url: "https://tea.example", lastModified: "2026-10-20" }, { url: "https://tea.example/about", lastModified: "2026-06-01" }, ...teas.map((tea) => ({ url: `https://tea.example/teas/${tea.id}`, lastModified: tea.updated, })), ];}GET /sitemap.xml200
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchx-nextjs-cacheHITcache-controlpublic, max-age=0, must-revalidatecontent-typeapplication/xml
The body that arrived, as it arrived
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://tea.example</loc>
<lastmod>2026-10-20</lastmod>
</url>
<url>
<loc>https://tea.example/about</loc>
<lastmod>2026-06-01</lastmod>
</url>
<url>
<loc>https://tea.example/teas/green</loc>
<lastmod>2026-09-02</lastmod>
</url>
<url>
<loc>https://tea.example/teas/mint</loc>
<lastmod>2026-10-12</lastmod>
</url>
<url>
<loc>https://tea.example/teas/lemon</loc>
<lastmod>2026-10-20</lastmod>
</url>
</urlset>
This is a real Next.js 16.4.3 project, built with next build and run with next start. The default export of app/sitemap.ts, on line 4, returns a list: one object per page, with its complete url and its lastModified date. The Network tab shows what /sitemap.xml answered: Content-Type: application/xml, and a <urlset> with five <url> entries, each with its <loc> (the address) and <lastmod> (the date). You wrote no XML.
Two more files work the same way: robots.ts becomes /robots.txt, the crawlers' rules, and manifest.ts becomes /manifest.webmanifest, for installing the site as an app. Inside each page, two more things help: structured data (JSON-LD), which says what the page is about, and a canonical address, which says which address is the page's main one.
/robots.txt for the rules and the sitemap's address, then /sitemap.xml for every page and the day it changed, then the pages. Next.js serves the first two from robots.ts and sitemap.ts.Picture a market where visitors walk in alone, with nobody to ask. A sign at the gate says which doors are for staff, and where the map is. The map lists every stall and the day it was restocked. Each box has a card in the same layout: what it is, and its price. A side door says where the main entrance is, so nobody counts the stall twice.
- In real life: The sign at the gate: "Staff only past this door", and where the map is
- In Next.js:
/robots.txt, fromrobots.ts - In real life: The map of every stall, with the day each was restocked
- In Next.js:
/sitemap.xml, fromsitemap.ts - In real life: A card on each box: product, price, currency, in a form every shopper reads
- In Next.js: A JSON-LD script in the page
- In real life: "Our main entrance is on the high street", on the side door
- In Next.js:
<link rel="canonical"> - In real life: The stall's badge, with its name and colors, for the loyalty app
- In Next.js:
/manifest.webmanifest, frommanifest.ts
sitemap.ts, robots.ts and manifest.ts in app are special Route Handlers: each returns plain data, and Next.js serves it at a fixed address, in the format crawlers and browsers expect. What a single page is about, and its main address, go in the page itself: a JSON-LD script, and alternates.canonical.
sitemap.ts: every page, from your data
Read app/sitemap.ts again, line by line. Line 1 imports the MetadataRoute type, so MetadataRoute.Sitemap on line 4 checks every entry. Line 5 loads the teas from app/teas.ts, a stand-in for the shop's database. Lines 7 and 8 list the home page and the About page by hand. Lines 9 to 12 add one entry per tea, built from its id on line 10 and its updated date on line 11.
Every url is complete, with https://tea.example in front: a sitemap is read far from your site, so a path alone means nothing there. Next.js writes each url into <loc> as it is, and metadataBase doesn't apply to it. An entry can also have changeFrequency and priority. The docs list both; Google's own docs say it ignores them, and uses lastmod when the dates are accurate.
Open the Terminal tab: ○ /sitemap.xml. The sitemap reads nothing from the request, so the build made it once. The docs' first example fills lastModified with new Date(). Before you pick the second version, predict two things: the symbol the route table gives /sitemap.xml, and the dates in its <lastmod> tags. Then compare with the recording:
app/sitemap.tsNext.jsapp/sitemap.tsimport type { MetadataRoute } from "next";import { getTeas } from "./teas"; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const teas = await getTeas(); return [ { url: "https://tea.example", lastModified: "2026-10-20" }, { url: "https://tea.example/about", lastModified: "2026-06-01" }, ...teas.map((tea) => ({ url: `https://tea.example/teas/${tea.id}`, lastModified: tea.updated, })), ];}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (9/9) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /about├ ○ /sitemap.xml└ /teas/[id] ├ ◐ /teas/[id] ├ ○ /teas/green ├ ○ /teas/mint └ ○ /teas/lemon ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentEvery lastModified is a date the data knows. The build made the sitemap once: ○ /sitemap.xml, and each <lastmod> is the day that page last changed.
new Date() is the moment the code runs, and the build can't know that moment for a request it hasn't seen, so the sitemap became ƒ: made again for every request, with every page "changed" just now. Dates that are always now tell a crawler nothing. Use the dates your data already has.
More pages than one sitemap should hold
The docs give Google's limit as 50,000 addresses per sitemap. A big shop splits its list with generateSitemaps. It returns a list of ids, and Next.js makes one sitemap per id. Here the shop puts two teas in each. Before you open the Terminal tab, predict the addresses of the two sitemaps it makes, then compare with the recording:
app/teas/sitemap.tsimport type { MetadataRoute } from "next";import { getTeas } from "../teas"; const perSitemap = 2; export async function generateSitemaps() { const teas = await getTeas(); const count = Math.ceil(teas.length / perSitemap); return Array.from({ length: count }, (_, index) => ({ id: index }));} export default async function sitemap({ id }: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> { const start = Number(await id) * perSitemap; const teas = await getTeas(); return teas.slice(start, start + perSitemap).map((tea) => ({ url: `https://tea.example/teas/${tea.id}`, lastModified: tea.updated, }));}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (11/11) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /about├ ○ /sitemap.xml├ /teas/[id]│ ├ ◐ /teas/[id]│ ├ ○ /teas/green│ ├ ○ /teas/mint│ └ ○ /teas/lemon└ /teas/sitemap/[__metadata_id__] ├ ● /teas/sitemap/0.xml └ ● /teas/sitemap/1.xml ○ (Static) prerendered as static content● (SSG) prerendered as static HTML (uses generateStaticParams)◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentLine 4 sets two teas per sitemap. Lines 6 to 10 are generateSitemaps: three teas need two sitemaps, so line 9 returns [{ id: 0 }, { id: 1 }]. Line 12 is the default export. Since Next.js 16, it gets the id as a promise of a string, so line 13 waits for it and turns it into a number before working out where its slice starts.
The Terminal tab lists ● /teas/sitemap/0.xml and ● /teas/sitemap/1.xml: the folder's path, then sitemap, then the id with .xml. In the Network tab, the first holds green and mint, and the second holds lemon. Next.js makes no file that lists the numbered sitemaps: the route table has no /teas/sitemap.xml. You tell crawlers about each one yourself, in robots.txt.
robots.ts: what crawlers may visit
A well-behaved crawler reads /robots.txt before it downloads a site's pages. The file holds rules about which addresses to skip, and where the sitemaps are. app/robots.ts returns them as an object:
app/robots.tsimport type { MetadataRoute } from "next"; export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: "*", allow: "/", disallow: "/cart", }, sitemap: "https://tea.example/sitemap.xml", };}GET /robots.txt200
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchx-nextjs-cacheHITcache-controlpublic, max-age=0, must-revalidatecontent-typetext/plain
The body that arrived, as it arrived
User-Agent: *
Allow: /
Disallow: /cart
Sitemap: https://tea.example/sitemap.xml
Lines 5 to 9 are one rule. Line 6 says who it's for: *, every crawler. Line 7 allows every address, and line 8 asks them to skip /cart, which only ever shows an empty cart to a crawler. Line 10 says where the sitemap is. The Network tab shows the file Next.js wrote, text/plain:
User-Agent: *
Allow: /
Disallow: /cart
Sitemap: https://tea.example/sitemap.xml
Search engines' crawlers read this and follow it. But it's a request, not a lock. Pick /cart above the Network tab: the server answered 200, as for any page. Next.js doesn't read robots.txt when it serves pages, and anyone can still open /cart.
Keeping a page out of search results
To keep a page out of search results, the page itself says so, in its metadata. The shop's order confirmation page, /thanks, has no reason to show up in a search:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Thanks for your order",
robots: { index: false },
};
export default function ThanksPage() {
return (
<main>
<h1>Thanks for your order</h1>
<p>Your tea is on its way.</p>
</main>
);
}
Line 5 sets the robots field to { index: false }. Pick /thanks in the example above: its <head> has <meta name="robots" content="noindex">. Don't also disallow /thanks in robots.txt: a crawler that skips the page never reads the tag. Each row compares the two:
| Where you write it | What it asks | What it can't do |
|---|---|---|
Disallow: /cart in robots.txt | Crawlers that follow the rules don't download /cart. | It's a request. Anyone can still open /cart, and the recorded /cart answered 200. |
robots: { index: false } in a page's metadata | Search engines that read the page leave it out of their results. | A crawler has to download the page to read the tag, so robots.txt must not block it. |
manifest.ts: the shop as an installable app
A web app manifest is a small JSON file about the site as an app: its name, its colors, its icons. Browsers read it when someone installs the site, for example on a phone's home screen. app/manifest.ts returns it:
app/manifest.tsimport type { MetadataRoute } from "next"; export default function manifest(): MetadataRoute.Manifest { return { name: "Tea shop", short_name: "Tea", start_url: "/", display: "standalone", background_color: "#ffffff", theme_color: "#2f855a", icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }], };}GET /manifest.webmanifest200
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchx-nextjs-cacheHITcache-controlpublic, max-age=0, must-revalidatecontent-typeapplication/manifest+json
The body that arrived, as it arrived
{"name":"Tea shop","short_name":"Tea","start_url":"/","display":"standalone","background_color":"#ffffff","theme_color":"#2f855a","icons":[{"src":"/icon.svg","sizes":"any","type":"image/svg+xml"}]}Line 5 is the full name, and line 6 the short one, for the space under an icon. Line 7 is the page the app opens on. Line 8, standalone, asks for a window without the browser's address bar. Lines 9 and 10 are colors, and line 11 points at the shop's icon, app/icon.svg from Static files.
The Network tab shows /manifest.webmanifest: the same object as JSON, with Content-Type: application/manifest+json. Pick /: Next.js also added <link rel="manifest" href="/manifest.webmanifest"> to the home page's <head>, so browsers can find it. The file is manifest.ts, but the address ends in .webmanifest.
JSON-LD: a page that describes itself
A person reading /teas/mint sees that it sells a tea for $3. A search engine sees words. Structured data tells it in a fixed format: the page sells a product, with this name and this price. The format the docs' guide uses is JSON-LD: JSON with words from a shared list at schema.org, in a <script type="application/ld+json"> tag in the page:
app/teas/[id]/page.tsximport { getTea, getTeas } from "../../teas"; export async function generateStaticParams() { const teas = await getTeas(); return teas.map((tea) => ({ id: tea.id }));} export default async function TeaPage({ params }: PageProps<"/teas/[id]">) { const { id } = await params; const tea = await getTea(id); const jsonLd = { "@context": "https://schema.org", "@type": "Product", name: tea?.name, description: tea?.description, offers: { "@type": "Offer", price: tea?.price, priceCurrency: "USD" }, }; return ( <main> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }} /> <h1>{tea?.name}</h1> <p>{tea?.description}</p> </main> );}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 17 scripts left out (a JSON-LD script is data, not code, so its text stays)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<meta name="description" content="Three teas from one small shop.">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<main>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"Mint tea","description":"Cool mint leaves.","offers":{"@type":"Offer","price":3,"priceCurrency":"USD"}}</script>
<h1>Mint tea</h1>
<p>Cool mint leaves.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Lines 11 to 17 build the data as a plain object. Line 12 names the list of words, https://schema.org, and line 13 says what the page is about: a Product. Lines 14 and 15 are its name and description, and line 16 is an Offer with its price and currency. Lines 20 to 23 render the script tag, with type on line 21, and on line 22, the data as text.
In the Network tab, the script is in the <main> of the HTML that arrived, holding the JSON. A browser doesn't run it: the type says it's data. It's a plain <script> tag, not next/script, which is for code that runs. The docs suggest Google's Rich Results Test or the Schema Markup Validator to check it.
Why line 22 replaces every <
dangerouslySetInnerHTML puts a string into the HTML exactly as it is, with nothing escaped. Usually React escapes text for you: line 25, {tea?.description}, would show any < in the description as a character. Now say someone typed this into the lemon tea's description, through the shop's admin form:
{ id: "lemon", name: "Lemon tea", price: 3, updated: "2026-10-20", description: "Bright and sour. </script><p>Free tea for everyone!</p>" },Try the page with and without the replace on line 22, and guess first what a visitor sees:
app/teas/[id]/page.tsxNext.jsapp/teas/[id]/page.tsximport { getTea, getTeas } from "../../teas"; export async function generateStaticParams() { const teas = await getTeas(); return teas.map((tea) => ({ id: tea.id }));} export default async function TeaPage({ params }: PageProps<"/teas/[id]">) { const { id } = await params; const tea = await getTea(id); const jsonLd = { "@context": "https://schema.org", "@type": "Product", name: tea?.name, description: tea?.description, offers: { "@type": "Offer", price: tea?.price, priceCurrency: "USD" }, }; return ( <main> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <h1>{tea?.name}</h1> <p>{tea?.description}</p> </main> );}Free tea for everyone!
","offers":{"@type":"Offer","price":3,"priceCurrency":"USD"}}Bright and sour. </script><p>Free tea for everyone!</p>
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
The description holds </script>. The HTML parser ends the script there, so Free tea for everyone! and the rest of the data land on the page as visible content.
/teas/lemon. The browser ends a script at the first </script> it finds, even inside a string. With every < written as \u003c, the only one left is the script's own.Without the replace, the browser's HTML parser met </script> inside the description and ended the script there. Everything after it became part of the page: the Browser tab shows Free tea for everyone! as a paragraph, and the rest of the JSON as text. Here it's a paragraph; from someone who means harm, it could be a script of their own. With the replace, every < in the data is written \u003c, which JSON reads as the same <, so the data is unchanged, and the only </script> is the tag's own.
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}Canonical URLs: one address for each page
/teas and /teas?sort=price show the same list of teas. To a search engine, they're two addresses, and it has to guess which one to show. A canonical address answers: a <link rel="canonical"> tag that names a page's main address. In Next.js, it's the alternates.canonical field of the metadata, and it uses metadataBase from Open Graph images:
app/teas/[id]/page.tsximport type { Metadata } from "next";import { getTea, getTeas } from "../../teas"; export async function generateStaticParams() { const teas = await getTeas(); return teas.map((tea) => ({ id: tea.id }));} export async function generateMetadata({ params }: PageProps<"/teas/[id]">): Promise<Metadata> { const { id } = await params; const tea = await getTea(id); return { title: tea?.name, alternates: { canonical: `/teas/${id}` }, };} export default async function TeaPage({ params }: PageProps<"/teas/[id]">) { const { id } = await params; const tea = await getTea(id); return ( <main> <h1>{tea?.name}</h1> <p>{tea?.description}</p> </main> );}GET /teas?sort=price200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Our teas</title>
<meta name="description" content="Three teas from one small shop.">
<link rel="canonical" href="https://tea.example/teas">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<main>
<h1>Our teas</h1>
<p>Green, mint and lemon.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>In app/teas/[id]/page.tsx, line 14 returns alternates: { canonical: `/teas/${id}` } from generateMetadata. Line 4 of app/layout.tsx sets metadataBase, and app/teas/page.tsx sets canonical: "/teas" on its line 5. In the Network tab, pick each response:
/teasand/teas?sort=priceboth have<link rel="canonical" href="https://tea.example/teas">./teas/minthashref="https://tea.example/teas/mint".
/teas and /teas?sort=price show the same list, and both say their canonical address is https://tea.example/teas. Each tea's page names its own.A canonical address is a field like any other, so it merges down the tree. Put it in the root layout, and every page below inherits it:
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://tea.example"),
title: "Tea shop",
description: "Three teas from one small shop.",
alternates: { canonical: "/" },
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Line 7 sets canonical: "/" in the root layout. In the recording, /about has <link rel="canonical" href="https://tea.example">: the About page says its main address is the home page. Set alternates.canonical in each page instead.
Without metadataBase, the recorded build didn't stop or warn, and /teas/mint got href="/teas/mint", a path with no site in it. The docs say a relative address without metadataBase causes a build error; the recorded 16.4.3 build passed. Google's docs ask for complete addresses here, so set metadataBase either way.
Which file or tag does each job?
Each row is one of the special files, the address Next.js serves it at, and what arrives there:
| The file | Its address | What arrives |
|---|---|---|
app/sitemap.ts | /sitemap.xml | application/xml: the list of pages, in the Sitemaps XML format. |
app/teas/sitemap.ts with generateSitemaps | /teas/sitemap/0.xml, /teas/sitemap/1.xml | application/xml: one numbered sitemap per id. |
app/robots.ts | /robots.txt | text/plain: the rules for crawlers, and where the sitemaps are. |
app/manifest.ts | /manifest.webmanifest | application/manifest+json: the shop's name, colors and icons, for installing it. |
Sort each job the tea shop has into the file or tag that does it:
- List every tea's page, with the day it last changed
- Split 120,000 product pages into several lists
- Ask crawlers not to download the cart
- Tell crawlers where the sitemaps are
- Tell search engines a tea's price and currency
- Say that a page is about a product, not an article
- Make
/teas?sort=pricecount as/teas - Give each tea's page its own main address
Sort each job into sitemap.ts, robots.ts, a JSON-LD script, or alternates.canonical.
Common mix-ups
- "
Disallowin robots.txt hides a page." No. It asks crawlers to skip it. The recorded/cartstill answered 200. To keep a page out of results, userobots: { index: false }. - "A noindex page should also be disallowed." No. A crawler that skips the page never reads its
noindextag. - "
new Date()keeps the sitemap fresh." It makes the sitemapƒ, and every page's date the time of each request. Use the dates your data has. - "
generateSitemapsmakes a list of the sitemaps." No. The recorded build has only the numbered ones. List them inrobots.txt. - "React escapes the JSON-LD." Not inside
dangerouslySetInnerHTML. Replace every<with\u003c, or a</script>in your data ends the script. - "A canonical address in the root layout is a good default." No. Every page inherits it, and the recorded
/aboutclaimed to be the home page.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
What's the <lastmod> of https://tea.example/teas/mint in /sitemap.xml?
import type { MetadataRoute } from "next";
import { getTeas } from "./teas";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const teas = await getTeas();
return [
{ url: "https://tea.example", lastModified: "2026-10-20" },
{ url: "https://tea.example/about", lastModified: "2026-06-01" },
...teas.map((tea) => ({
url: `https://tea.example/teas/${tea.id}`,
lastModified: tea.updated,
})),
];
}
2026-10-12. The recorded /sitemap.xml has <loc>https://tea.example/teas/mint</loc> with <lastmod>2026-10-12</lastmod>.
Which line does line 8 of app/robots.ts add to /robots.txt?
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/cart",
},
sitemap: "https://tea.example/sitemap.xml",
};
}
Disallow: /cart. The recorded /robots.txt has it right after User-Agent: * and Allow: /.
The shop has three teas. Which tea is in /teas/sitemap/1.xml?
import type { MetadataRoute } from "next";
import { getTeas } from "../teas";
const perSitemap = 2;
export async function generateSitemaps() {
const teas = await getTeas();
const count = Math.ceil(teas.length / perSitemap);
return Array.from({ length: count }, (_, index) => ({ id: index }));
}
export default async function sitemap({ id }: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> {
const start = Number(await id) * perSitemap;
const teas = await getTeas();
return teas.slice(start, start + perSitemap).map((tea) => ({
url: `https://tea.example/teas/${tea.id}`,
lastModified: tea.updated,
}));
}
Lemon tea only. Line 13 starts at 2, and slice(2, 4) of three teas is the last one. The recorded /teas/sitemap/1.xml has one <url>: https://tea.example/teas/lemon.
Change line 10 so crawlers find the teas' two sitemaps too. How many Sitemap: lines does /robots.txt have then?
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/cart",
},
sitemap: "https://tea.example/sitemap.xml",
};
}
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/cart",
},
sitemap: [
"https://tea.example/sitemap.xml",
"https://tea.example/teas/sitemap/0.xml",
"https://tea.example/teas/sitemap/1.xml",
],
};
}
Three. Lines 10 to 14 list every sitemap, and the recorded /robots.txt ends with three Sitemap: lines, one per address.
The lemon tea's description holds </script><p>Free tea for everyone!</p>. What does its page show that nobody meant to show? Then fix line 22.
import { getTea, getTeas } from "../../teas";
export async function generateStaticParams() {
const teas = await getTeas();
return teas.map((tea) => ({ id: tea.id }));
}
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
const tea = await getTea(id);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: tea?.name,
description: tea?.description,
offers: { "@type": "Offer", price: tea?.price, priceCurrency: "USD" },
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<h1>{tea?.name}</h1>
<p>{tea?.description}</p>
</main>
);
}
import { getTea, getTeas } from "../../teas";
export async function generateStaticParams() {
const teas = await getTeas();
return teas.map((tea) => ({ id: tea.id }));
}
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
const tea = await getTea(id);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: tea?.name,
description: tea?.description,
offers: { "@type": "Offer", price: tea?.price, priceCurrency: "USD" },
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<h1>{tea?.name}</h1>
<p>{tea?.description}</p>
</main>
);
}
Free tea for everyone!, as a paragraph, then the rest of the JSON as text. Replacing every < with \u003c on line 22 keeps the whole description inside the script, so the page shows only the tea's name and its description.
The About page sets no alternates. What canonical address does /about get?
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://tea.example"),
title: "Tea shop",
description: "Three teas from one small shop.",
alternates: { canonical: "/" },
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
https://tea.example, the home page. The recorded /about has exactly that canonical address. Remove line 7 and set alternates.canonical in each page.
Check your understanding
For each question, ask who reads the file or tag: a crawler, a browser, or both.
Question 1 of 8At which address does Next.js serve
app/sitemap.ts?Choose an answer to see the explanation.
Question 2 of 8A
sitemap.tsgives every pagelastModified: new Date(). What does the recorded route table show?Choose an answer to see the explanation.
Question 3 of 8This is
app/teas/sitemap.ts, with three teas. At which addresses are its sitemaps?Read the code, then predictTypeScriptimport type { MetadataRoute } from "next"; import { getTeas } from "../teas"; const perSitemap = 2; export async function generateSitemaps() { const teas = await getTeas(); const count = Math.ceil(teas.length / perSitemap); return Array.from({ length: count }, (_, index) => ({ id: index })); } export default async function sitemap({ id }: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> { const start = Number(await id) * perSitemap; const teas = await getTeas(); return teas.slice(start, start + perSitemap).map((tea) => ({ url: `https://tea.example/teas/${tea.id}`, lastModified: tea.updated, })); }Choose an answer to see the explanation.
Question 4 of 8This
robots.tsdisallows/cart. What happens when someone opens/cartin a browser?Read the code, then predictTypeScriptimport type { MetadataRoute } from "next"; export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: "*", allow: "/", disallow: "/cart", }, sitemap: "https://tea.example/sitemap.xml", }; }Choose an answer to see the explanation.
Question 5 of 8Which tag keeps the order confirmation page,
/thanks, out of search results?Choose an answer to see the explanation.
Question 6 of 8Why do the docs replace every
<with\u003cin JSON-LD?Choose an answer to see the explanation.
Question 7 of 8The root layout sets
alternates: { canonical: "/" }. What canonical address does/aboutget?Read the code, then predictTSXimport type { Metadata } from "next"; export const metadata: Metadata = { metadataBase: new URL("https://tea.example"), title: "Tea shop", description: "Three teas from one small shop.", alternates: { canonical: "/" }, }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en"> <body>{children}</body> </html> ); }Choose an answer to see the explanation.
Question 8 of 8Which content type does the recorded
/manifest.webmanifestcome with?Choose an answer to see the explanation.
Key takeaways
app/sitemap.tsreturns a list of complete addresses with dates, and Next.js serves it as XML at/sitemap.xml. Use real dates:new Date()makes itƒ.generateSitemapsmakes one sitemap perid, at/…/sitemap/[id].xml, with no list of them. Name each one inrobots.txt.app/robots.tsbecomes/robots.txt: a request to crawlers, not a lock. Keep a page out of results withrobots: { index: false }, and let crawlers read it.app/manifest.tsbecomes/manifest.webmanifest, and every page links it.- JSON-LD is a plain
<script type="application/ld+json">in the page. Replace every<with\u003c. alternates.canonicalnames a page's main address, made complete bymetadataBase. Set it per page, not in the root layout.
Remember the one-liner.
Special files tell crawlers where to go; JSON-LD and the canonical link tell them what each page is.
Coming next: Fetching data in Server Components. It loads the shop's teas from a real data source, straight from a Server Component.