Generating Sitemaps and RSS in Next.js Static Export

Next.js with output: 'export' produces a folder of static files that any host can serve, which makes it a reasonable choice for marketing sites and blogs built by React teams. The machine-readable files that such sites need — sitemap.xml, robots.txt, an RSS feed — are not generated automatically, and older guides describe post-build scripts or third-party packages that predate the App Router. With the App Router, all three can be produced by Next.js itself at build time: metadata routes for the sitemap and robots file, and a statically rendered route handler for the feed.

This guide adds all three to a Next.js 14 App Router site with 420 pages — blog posts from MDX, landing pages from a headless CMS — and checks them in CI. It is part of Next.js Static Export for Content Sites. The generic principles for both files are in Generating Sitemaps for Large Static Sites and RSS and Atom Feeds for Static Blogs.

Prerequisites

  • Next.js 14 or 15 with the App Router and output: 'export' in next.config.js.
  • A single module that lists content (posts and CMS pages) with URLs and dates, used by pages and by these files alike.
  • A SITE_URL constant for the production origin.

Step 1: One Content Index for Everything

Sitemaps and feeds go wrong when they compute URLs differently from the pages. Put the list of content in one module and use it everywhere:

// lib/content.ts
export const SITE_URL = 'https://www.example.com';

export type Entry = { path: string; title: string; updated: Date; published?: Date; summary?: string; noindex?: boolean };

export async function allEntries(): Promise<Entry[]> {
  const posts = (await getPosts()).filter((p) => !p.draft).map((p) => ({
    path: `/blog/${p.slug}/`, title: p.title, published: p.date, updated: p.updated ?? p.date, summary: p.summary
  }));
  const pages = (await getCmsPages()).map((p) => ({ path: p.path, title: p.title, updated: new Date(p.updatedAt), noindex: p.hideFromSearch }));
  return [...pages, ...posts];
}

getPosts reads MDX frontmatter; getCmsPages calls the CMS at build time. The same functions feed generateStaticParams in the page routes, so the sitemap cannot list a page that was not built.

Step 2: sitemap.ts

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { allEntries, SITE_URL } from '@/lib/content';

export const dynamic = 'force-static';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  return (await allEntries())
    .filter((e) => !e.noindex)
    .map((e) => ({ url: SITE_URL + e.path, lastModified: e.updated }));
}

At build time this produces out/sitemap.xml. force-static makes the intent explicit and fails the build if something dynamic sneaks in. The URLs include the trailing slash because the site uses trailingSlash: true; they must match the pages' canonical URLs exactly, as discussed in Trailing Slash and basePath in Next.js Static Export.

For sites beyond tens of thousands of URLs, export generateSitemaps to split the output into /sitemap/0.xml, /sitemap/1.xml and so on, and add a small route handler that writes a sitemap index listing them.

Content index feeding pages, sitemap and feed in Next.js A content module lists MDX posts and CMS pages with paths and dates. generateStaticParams uses it to build pages. sitemap.ts uses it to write sitemap.xml without noindex entries. The feed route handler uses the latest 30 posts to write feed.xml. robots.ts points at the sitemap. A CI check compares the sitemap with the built pages. One content index, three outputs lib/content.ts MDX + CMS, paths, dates Pages static params per route sitemap.ts indexable entries feed.xml route latest 30 posts CI check sitemap equals pages
Because all outputs read the same list, a draft or unpublished page cannot appear in one and not the others.

Step 3: robots.ts

// app/robots.ts
import type { MetadataRoute } from 'next';
import { SITE_URL } from '@/lib/content';
export const dynamic = 'force-static';
export default function robots(): MetadataRoute.Robots {
  return { rules: [{ userAgent: '*', allow: '/' }], sitemap: `${SITE_URL}/sitemap.xml` };
}

Keep preview deploys out of search with a response header on the host rather than a different robots.txt per environment; a static export cannot vary by host at runtime. The approach is in robots.txt and noindex for Preview and Staging.

Step 4: The RSS Feed as a Static Route Handler

Route handlers that use only build-time data are rendered once during next build and written to out/ when dynamic = 'force-static' is set:

// app/feed.xml/route.ts
import { allEntries, SITE_URL } from '@/lib/content';
import { renderPostHtml } from '@/lib/render-feed';

export const dynamic = 'force-static';

const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

export async function GET() {
  const posts = (await allEntries()).filter((e) => e.published)
    .sort((a, b) => +b.published! - +a.published!).slice(0, 30);
  const items = await Promise.all(posts.map(async (p) => `
    <item>
      <title>${esc(p.title)}</title>
      <link>${SITE_URL}${p.path}</link>
      <guid isPermaLink="true">${SITE_URL}${p.path}</guid>
      <pubDate>${p.published!.toUTCString()}</pubDate>
      <description>${esc(p.summary ?? '')}</description>
      <content:encoded><![CDATA[${await renderPostHtml(p.path, SITE_URL)}]]></content:encoded>
    </item>`));
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Example Blog</title><link>${SITE_URL}/</link><description>Product and engineering notes</description>
    <language>en</language>${items.join('')}
  </channel>
</rss>`;
  return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
}

renderPostHtml renders the post's MDX to an HTML string with absolute URLs for links and images — MDX components with interactivity are replaced by a link to the post. The Content-Type header in the Response is not used by static hosts, which infer the type from the .xml extension; set the header explicitly in the host's header rules if the feed should be served as application/rss+xml.

Add a discovery link in the root layout with the Metadata API, setting alternates.types to map application/rss+xml to the absolute feed URL.

How each machine-readable file is generated in Next.js static export A matrix of three files. sitemap.xml comes from app/sitemap.ts, a metadata route, and is written to out/sitemap.xml. robots.txt comes from app/robots.ts, a metadata route. feed.xml comes from app/feed.xml/route.ts, a route handler with dynamic force-static. All three run at build time with no server. Three files, three mechanisms File Source Mechanism sitemap.xml app/sitemap.ts metadata route robots.txt app/robots.ts metadata route feed.xml app/feed.xml/route.ts route handler, force-static
None of the three needs a post-build script or a third-party package.

Step 5: Check Them in CI

Three short checks run after next build:

  • the sitemap lists exactly the HTML pages in out/ that do not carry noindex, and every URL ends with a slash;
  • the feed parses as XML, has 30 items with unique GUIDs, and contains no href="/ or src="/ inside content:encoded;
  • robots.txt references the production sitemap URL.

The first run found two problems: CMS pages flagged "hide from search" still appeared in the sitemap because the flag was read from the wrong field, and 14 feed items had relative image paths from an MDX image component that bypassed the absolutizing step. Both were fixed in the content module and the renderer. The checks follow the pattern in Testing Structured Data and Meta Tags.

Replacing next-sitemap

Many Pages Router sites use the next-sitemap package, which runs after the build and scans the output. It still works with static export, but it duplicates logic the app already has (which pages exist, which are hidden), and its configuration drifted from the site's actual routing on this project: it listed the 404 page and two redirect stub pages. Moving to sitemap.ts removed the package and its config file, and made the sitemap reflect the same data as the pages by construction. The only reason to keep a post-build scanner is a site where pages come from sources the app cannot enumerate at build time, which is rare with static export.

Errors in sitemap and feed before and after moving to metadata routes Paired bars. URLs in the sitemap that should not be there fell from 23 to 0. Feed items with relative links or images fell from 14 to 0. Search Console sitemap errors fell from 9 to 0. Sitemap and feed problems before and after Before After Wrong URLs in sitemap 23 0 Feed items with relative URLs 14 0 Search Console sitemap errors 9 0
Before is next-sitemap plus a hand-written feed script; after is metadata routes plus the static route handler and CI checks.

Keeping CMS Pages in Step

Landing pages from the CMS change without a code commit, so the sitemap and feed only update when the site rebuilds. The site rebuilds on the CMS's publish webhook, which triggers the same GitHub Actions workflow as a code push. Because lastModified comes from the CMS's updatedAt field, a republished landing page moves its sitemap date forward in the same deploy that changes its content, and nothing else in the sitemap changes. An hourly scheduled rebuild covers scheduled publishing in the CMS, where no webhook fires at publish time; the approach is described in Scheduling Content Publication with Cron-Triggered Builds.

Measured Impact

Measurenext-sitemap + feed scriptMetadata routes + route handler
Extra packages and scripts2 packages, 1 scriptnone
Wrong URLs in sitemap230
Feed items with relative URLs140
Build time added6.2 s (post-build scan)1.1 s
Median recrawl after an edit (Search Console sample)8 days3 days (real lastModified)

Pitfalls & Rollback

  • Dynamic functions in metadata routes. Reading headers or cookies makes the route dynamic, which static export cannot build; keep them pure.
  • Build time as lastModified. Use real content dates, or every page looks changed on every deploy.
  • Relative URLs in feed content. Absolutize links and images when rendering MDX for the feed.
  • Content type on static hosts. The Response header is ignored for static files; configure it on the host if needed.
  • Rollback: the files are ordinary routes; deleting them and restoring the previous script returns the old output.

Conclusion

In a Next.js static export, the App Router can produce its own sitemap, robots file and RSS feed at build time from the same content index that builds the pages. That removes post-build packages, keeps the machine-readable files in step with the site by construction, and turns their correctness into three short CI checks.

FAQ

Do sitemap.ts and robots.ts work with output export?

Yes. Metadata route files in the App Router, such as app/sitemap.ts and app/robots.ts, are generated as static files at build time when they do not use dynamic functions. They appear in the out directory as sitemap.xml and robots.txt.

How do I generate an RSS feed in a static export?

Create a route handler such as app/feed.xml/route.ts that returns an XML Response, and export const dynamic = 'force-static'. Next.js calls it at build time and writes the result to out/feed.xml.

Can a sitemap in static export have more than 50,000 URLs?

Yes, with generateSitemaps in app/sitemap.ts, which produces several sitemap files with ids. In static export you also need a sitemap index, which can be written as another static route handler listing the generated files.

Feed readers resolve links without your site's base URL. Build every link and image URL in the feed from the configured production origin, and convert relative links inside post content to absolute ones before serializing.

Filed underHow-tosNext.js