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'innext.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_URLconstant 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.
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, '&').replace(/</g, '<').replace(/>/g, '>');
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.
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 carrynoindex, and every URL ends with a slash; - the feed parses as XML, has 30 items with unique GUIDs, and contains no
href="/orsrc="/insidecontent:encoded; robots.txtreferences 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.
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
| Measure | next-sitemap + feed script | Metadata routes + route handler |
|---|---|---|
| Extra packages and scripts | 2 packages, 1 script | none |
| Wrong URLs in sitemap | 23 | 0 |
| Feed items with relative URLs | 14 | 0 |
| Build time added | 6.2 s (post-build scan) | 1.1 s |
| Median recrawl after an edit (Search Console sample) | 8 days | 3 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.
Why does my feed show relative links?
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.
Related
- Parent: Next.js Static Export for Content Sites — the static export model.
- Using MDX in Next.js Static Export — the MDX source of posts.
- Handling Dynamic Routes in Next.js Static Export — generateStaticParams in depth.
- Technical SEO for Static Sites — the wider build-time SEO picture.
- Next.js App Router Static Export Limitations — what static export cannot do.