cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Serve the files crawlers readWrite sitemap.ts, generateSitemaps, robots.ts and manifest.ts, and read what Next.js serves at each address.
  • 02
    Describe a page safelyAdd JSON-LD in a script tag, and escape < so data can't end the script early.
  • 03
    Name 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:

The shop's sitemap, from app/sitemap.tsNext.js
app/about/teas/[id]/
app/sitemap.ts
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,    })),  ];}
Recorded from Next.js 16.4.3

GET /sitemap.xml200

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-nextjs-cache HIT
cache-control public, max-age=0, must-revalidate
content-type application/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.

A window for a search engine's crawler. Three arrows go from it, numbered 1 to 3: to slash robots dot txt, which holds the rules and the sitemap's address; to slash sitemap dot xml, which lists five pages with their dates; and to slash teas slash mint, a page, which has its JSON-LD and its canonical address.crawlersearch engine123/robots.txt: rules/sitemap.xml: 5 pages/teas/mint: a pagethe page has JSON-LD and a canonical link
A crawler is a search engine's program that downloads pages. It can read /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.
Real-life analogyA market for visitors who can't ask anyone

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, from robots.ts
In real life: The map of every stall, with the day each was restocked
In Next.js: /sitemap.xml, from sitemap.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, from manifest.ts
The main idea

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:

Playground: fixed dates or new Date()?
The tea shop, with this version of app/sitemap.tsNext.js
app/about/teas/[id]/
app/sitemap.ts
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,    })),  ];}
Recorded from Next.js 16.4.3
$ 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 content
Try it yourself
Dates from the data

Every 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.

Both versions were built with next build from Next.js 16.4.3. The Terminal shows the route table; the Network tab shows the sitemap next start sent. Only app/sitemap.ts changes. Reset goes back to the first version.

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:

The teas, split into numbered sitemaps by app/teas/sitemap.tsNext.js
app/about/teas/[id]/
app/teas/sitemap.ts
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,  }));}
Recorded from Next.js 16.4.3
$ 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 content

Line 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:

The shop's robots.txt, from app/robots.tsNext.js
app/about/cart/teas/[id]/thanks/
app/robots.ts
import type { MetadataRoute } from "next"; export default function robots(): MetadataRoute.Robots {  return {    rules: {      userAgent: "*",      allow: "/",      disallow: "/cart",    },    sitemap: "https://tea.example/sitemap.xml",  };}
Recorded from Next.js 16.4.3

GET /robots.txt200

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-nextjs-cache HIT
cache-control public, max-age=0, must-revalidate
content-type text/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:

The recorded /robots.txtText
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:

app/thanks/page.tsxTSX
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:

robots.txt or the robots field
Where you write itWhat it asksWhat it can't do
Disallow: /cart in robots.txtCrawlers 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 metadataSearch 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:

The shop's manifest, from app/manifest.tsNext.js
app/about/teas/[id]/
app/manifest.ts
import 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" }],  };}
Recorded from Next.js 16.4.3

GET /manifest.webmanifest200

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-nextjs-cache HIT
cache-control public, max-age=0, must-revalidate
content-type application/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:

A tea's page with JSON-LDNext.js
app/about/teas/[id]/
app/teas/[id]/page.tsx
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>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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:

The lemon tea's description in app/teas.tsTypeScript
{ 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:

Playground: what does the description do to the page?
The tea shop, with this version of app/teas/[id]/page.tsxNext.js
app/about/teas/[id]/
app/teas/[id]/page.tsx
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>  );}
Recorded from Next.js 16.4.3
localhost:3000/teas/lemonTea shop

Free tea for everyone!

","offers":{"@type":"Offer","price":3,"priceCurrency":"USD"}}
Lemon tea

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.

Try it yourself
JSON.stringify alone

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.

Both versions were built with next build from Next.js 16.4.3, with the same data: the lemon tea's description holds a closing script tag. The Browser tab draws the page from the HTML that arrived; the Network tab shows that HTML. Reset goes back to the first version.
Two windows, each the HTML that arrived for slash teas slash lemon. Left, JSON.stringify alone: the script holds the data only up to the closing script tag inside the description; after it, a paragraph Free tea for everyone! and the rest of the data sit on the page. Right, with less-than replaced: the script holds all the data, with backslash u 0 0 3 c where the less-than signs were, and the page shows the heading Lemon tea.JSON.stringify alone/teas/lemon<script>{…sour. </script>Free tea for everyone!","offers":{…}}Lemon teaWith < replaced/teas/lemon<script>{…\u003c/script…}</script>Lemon teadata spills onto the pageone script, all the data
From the two recordings of /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.

Line 22 without the replace, as the first version has itTSX
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:

Canonical addresses for the tea list and each teaNext.js
app/about/teas/[id]/
app/teas/[id]/page.tsx
import 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>  );}
Recorded from Next.js 16.4.3

GET /teas?sort=price200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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:

  • /teas and /teas?sort=price both have <link rel="canonical" href="https://tea.example/teas">.
  • /teas/mint has href="https://tea.example/teas/mint".
Two addresses, slash teas and slash teas question mark sort equals price, both have arrows to one card: canonical https://tea.example/teas. Below, slash teas slash mint has an arrow to canonical https://tea.example/teas/mint./teas/teas?sort=pricehttps://tea.example/teas/teas/mint…tea.example/teas/mintrel="canonical"
From the recording. /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:

app/layout.tsx with a canonical address for every pageTSX
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:

Special files and their addresses
The fileIts addressWhat arrives
app/sitemap.ts/sitemap.xmlapplication/xml: the list of pages, in the Sitemaps XML format.
app/teas/sitemap.ts with generateSitemaps/teas/sitemap/0.xml, /teas/sitemap/1.xmlapplication/xml: one numbered sitemap per id.
app/robots.ts/robots.txttext/plain: the rules for crawlers, and where the sitemaps are.
app/manifest.ts/manifest.webmanifestapplication/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:

Which file or tag?
  • 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=price count as /teas
  • Give each tea's page its own main address
Try it yourself
0 of 8 correct

Sort each job into sitemap.ts, robots.ts, a JSON-LD script, or alternates.canonical.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Disallow in robots.txt hides a page." No. It asks crawlers to skip it. The recorded /cart still answered 200. To keep a page out of results, use robots: { index: false }.
  • "A noindex page should also be disallowed." No. A crawler that skips the page never reads its noindex tag.
  • "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.
  • "generateSitemaps makes a list of the sitemaps." No. The recorded build has only the numbered ones. List them in robots.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 /about claimed 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.

Exercise 1 · Warm-upRead a sitemap's date

What's the <lastmod> of https://tea.example/teas/mint in /sitemap.xml?

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

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upTurn a rule into a line

    Which line does line 8 of app/robots.ts add to /robots.txt?

    Starter codeTypeScript
    import type { MetadataRoute } from "next";
    
    export default function robots(): MetadataRoute.Robots {
      return {
        rules: {
          userAgent: "*",
          allow: "/",
          disallow: "/cart",
        },
        sitemap: "https://tea.example/sitemap.xml",
      };
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhich teas are in the second sitemap?

      The shop has three teas. Which tea is in /teas/sitemap/1.xml?

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

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeTell crawlers about every sitemap

        Change line 10 so crawlers find the teas' two sitemaps too. How many Sitemap: lines does /robots.txt have then?

        Starter codeTypeScript
        import type { MetadataRoute } from "next";
        
        export default function robots(): MetadataRoute.Robots {
          return {
            rules: {
              userAgent: "*",
              allow: "/",
              disallow: "/cart",
            },
            sitemap: "https://tea.example/sitemap.xml",
          };
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind what leaks onto the page

          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.

          Starter codeTSX
          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>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeA canonical address for the whole shop

            The About page sets no alternates. What canonical address does /about get?

            Starter codeTSX
            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>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask who reads the file or tag: a crawler, a browser, or both.

              The sitemaps, robots and structured data quiz · 8 questionsScore: first tries count
              1. Question 1 of 8At which address does Next.js serve app/sitemap.ts?

                Choose an answer to see the explanation.

              2. Question 2 of 8A sitemap.ts gives every page lastModified: new Date(). What does the recorded route table show?

                Choose an answer to see the explanation.

              3. Question 3 of 8This is app/teas/sitemap.ts, with three teas. At which addresses are its sitemaps?

                Read the code, then predictTypeScript
                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,
                  }));
                }
                

                Choose an answer to see the explanation.

              4. Question 4 of 8This robots.ts disallows /cart. What happens when someone opens /cart in a browser?

                Read the code, then predictTypeScript
                import 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.

              5. Question 5 of 8Which tag keeps the order confirmation page, /thanks, out of search results?

                Choose an answer to see the explanation.

              6. Question 6 of 8Why do the docs replace every < with \u003c in JSON-LD?

                Choose an answer to see the explanation.

              7. Question 7 of 8The root layout sets alternates: { canonical: "/" }. What canonical address does /about get?

                Read the code, then predictTSX
                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>
                  );
                }
                

                Choose an answer to see the explanation.

              8. Question 8 of 8Which content type does the recorded /manifest.webmanifest come with?

                Choose an answer to see the explanation.

              Key takeaways

              • app/sitemap.ts returns a list of complete addresses with dates, and Next.js serves it as XML at /sitemap.xml. Use real dates: new Date() makes it ƒ.
              • generateSitemaps makes one sitemap per id, at /…/sitemap/[id].xml, with no list of them. Name each one in robots.txt.
              • app/robots.ts becomes /robots.txt: a request to crawlers, not a lock. Keep a page out of results with robots: { index: false }, and let crawlers read it.
              • app/manifest.ts becomes /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.canonical names a page's main address, made complete by metadataBase. 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.

              CompleteFrontend Clear concepts. Working examples.