~/TechPurAI
~/tutorials/nextjs-from-scratch/sitemap-and-robots
intermediate·part 21 of 22·3 min read

Generating a sitemap and robots.txt

Updated Aug 16, 2026JavaScript · Next.js

Part 9 and 10 covered how one page gets found and understood by a search engine. This part covers how every page gets discovered in the first place — a sitemap listing every URL, and a robots.txt telling crawlers what they're allowed to visit.

app/sitemap.ts

ts
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";

export default function sitemap(): MetadataRoute.Sitemap {
  const posts = getAllPosts().map((post) => ({
    url: `https://devnotes.example.com/posts/${post.slug}`,
    lastModified: post.date,
    changeFrequency: "monthly" as const,
    priority: 0.7,
  }));

  return [
    {
      url: "https://devnotes.example.com",
      changeFrequency: "weekly",
      priority: 1,
    },
    {
      url: "https://devnotes.example.com/posts",
      changeFrequency: "weekly",
      priority: 0.9,
    },
    ...posts,
  ];
}

sitemap.ts is a reserved filename, the same reserved-convention pattern as page.tsx and route.ts — Next.js automatically serves whatever this default export returns at /sitemap.xml, generating the actual XML for you. Building the post entries from getAllPosts() — the exact same function every page in this series already calls — means the sitemap can never drift out of sync with what posts actually exist; adding a post to the data source adds it here automatically, with no separate list to maintain.

app/robots.ts

ts
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: "/api/",
    },
    sitemap: "https://devnotes.example.com/sitemap.xml",
  };
}

Same reserved-filename pattern, serving /robots.txt. disallow: "/api/" keeps crawlers out of the Route Handlers from part 13 — JSON endpoints have no business showing up in search results — while allow: "/" permits everything else, including every post page. The sitemap field here is what points a crawler at the exact file generated above, so it doesn't have to guess or rely on it being submitted manually.

Why lastModified matters

lastModified: post.date on each sitemap entry is a real signal, not a formality — it's what tells a crawler whether it's worth re-fetching a specific page versus one it already has an up-to-date copy of. A blog that never updates this field (or hardcodes today's date on every post regardless of when it actually changed) gives crawlers no useful information to prioritize re-crawling with.

Splitting a very large sitemap

A single sitemap file has a practical size limit (50,000 URLs, per the sitemap protocol) — a site that grows well past what one post array can reasonably represent eventually needs a sitemap index instead: multiple smaller sitemaps, one per content type or date range, referenced from a top-level index file. That's a scale problem worth solving when it's actually reached, not before; app/sitemap.ts returning one array, as built here, covers every realistic blog for a long time before that split becomes necessary.

Common mistake

Hand-maintaining a static public/sitemap.xml file, added to once when the site launched and never touched since. Every post published after that point is invisible to it — generating the sitemap from the same data source every page already reads, the way app/sitemap.ts does here, is what keeps it permanently accurate with zero manual upkeep.

Last part: getting this from npm run dev on a laptop to a real, deployed site — plus the tests worth having before it ships.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous20. next/font: self-hosted, zero-layout-shift fontsnext →22. Testing and deploying to production