No More Manual Sitemap Builds: How I Synced Next.js with a Headless WordPress CMS

April 6, 2025 by Missie Dawes

When we launched the CodeKaizen site, we were feeling pretty good. It’s built with Next.js on the frontend and powered by a headless WordPress backend — everything felt fast, clean, and modern. But pretty quickly, we ran into a hiccup: our sitemap wasn’t keeping up.

Let me explain.

The Problem

We initially used the next-sitemap package to handle sitemap generation during the build process. And to be fair, it mostly worked. It generated a proper sitemap.xml file and even a robots.txt — nice!

But here’s where it fell short: it only updated during builds. That meant every time we added, edited, or deleted a blog post in WordPress, the sitemap stayed frozen in time. Not ideal when SEO and discoverability are important.

We needed the sitemap to stay fresh, updating automatically as our WordPress content changed without having to rebuild the entire Next.js app every time.

So I ditched next-sitemap and started from scratch.

The Goal

  • Dynamically generate a sitemap that includes both static and dynamic routes.
  • Regenerate the sitemap automatically when blog posts are created, edited, or deleted in WordPress.
  • Avoid full rebuilds just to keep the sitemap current.

Step 1: Built-In Sitemap Support in Next.js

I started by exploring Next.js’s built-in metadata support for sitemaps. It’s a nice feature, but it comes with a few caveats:

  • It doesn’t natively support dynamic routes like blog post slugs (which is what had led me to next-sitemap in the first place).
  • It’s designed to generate a static sitemap at build time, not on-demand.
  • There’s no built-in way to regenerate it automatically when content changes.

After researching options and seeing there isn’t much out there (yet), I realized I’d need to get a little creative.

Step 2: Creating a Dynamic Sitemap Generator

I created a sitemap.ts file to build the sitemap programmatically.

This script:

  • Gathers both static and dynamic routes (like /blog/my-post-slug).
  • Constructs the full URLs for each route.
  • Writes or overwrites a sitemap.xml file.

It even handles differences between local dev and production environments (which turned out to be more annoying than expected).

import fs from "fs";
import path from "path";

import type { MetadataRoute } from "next";

const APP_URL = process.env.APP_URL;

if (APP_URL === undefined) {
	throw new Error();
}

const siteConfig = {
	url: APP_URL,
};

// Recursively collect all pages with `page.tsx`
async function getStaticRoutes(
	filePathRoot: string,
	urlRoot = ""
): Promise<string[]> {
	const currentDir = filePathRoot;
	const entries = fs.readdirSync(currentDir, { withFileTypes: true });

	let routes: string[] = [];

	for (const entry of entries) {
		const fullPath = path.join(currentDir, entry.name);

		if (entry.isDirectory()) {
			const routePath = path.join(urlRoot, entry.name);

			// Check if this directory has a page.tsx
			const hasPage = ["page.tsx"].some((file) =>
				fs.existsSync(path.join(fullPath, file))
			);

			if (hasPage) {
				routes.push(`/${routePath}`);
			}

			// Continue scanning nested folders recursively
			const nestedRoutes = await getStaticRoutes(
				path.join(filePathRoot, entry.name),
				routePath
			);

			// Exclude routes with dynamic segments (e.g., [slug])
			const nestedStaticRoutes = nestedRoutes.filter(
				(route) => !route.match(/\[.+\]/)
			);

			routes = routes.concat(nestedStaticRoutes);
		}
	}

	return urlRoot === "" ? ["/", ...routes] : routes;
}

// Get dynamic routes by calling `generateStaticParams` from dynamic pages
async function getDynamicRoutes(
	subpath: string,
	dynamicSegment: string
): Promise<string[]> {
	try {
		const { generateStaticParams } = await import(
			`./${subpath}/[${dynamicSegment}]/page`
		);
		const params = await generateStaticParams();
		return params.map(
			(route: { [segment: string]: string }) =>
				`/${subpath}/${route[dynamicSegment]}`
		);
	} catch (error) {
		console.error("Error loading dynamic routes:", error);
		return [];
	}
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
	const filePathRoot =
		process.env.NODE_ENV === "production"
			? path.resolve(process.cwd(), ".next", "server", "app")
			: path.resolve(process.cwd(), "src", "app");
	const allRoutes = (
		await Promise.all([
			getStaticRoutes(
				filePathRoot
			),
			getDynamicRoutes("blog", "slug"),
			getDynamicRoutes("projects", "slug"),
			getDynamicRoutes("team", "slug"),
		])
	).flat();

	return allRoutes.map((route) => ({
		url: encodeURI(`${siteConfig.url}${route}`),
		lastModified: new Date().toISOString(),
		priority: route === "/" ? 1 : 0.8,
	}));
}

At this point, I was back to having a sitemap file generated at build time. So the last piece was to get the sitemap to automatically update based on blog post changes in WordPress.

Step 3: Hooking into WordPress Changes

I already had an API route in my Next.js app called /revalidateBlogPosts, which WordPress hits whenever a blog post is created, updated, or deleted. That route used to just revalidate blog-related paths using Next.js’s revalidatePath() cache revalidation feature.

Now it also uses revalidatePath() to refresh the sitemap. For example:

export async function POST(req: Request) {
	await getAllBlogPostSlugs();
	revalidatePath("/blog");
	revalidatePath("/sitemap.xml"); // Boom. Instant sitemap update.
}

Huzzah!

Final Thoughts

Building this out was a great learning experience, though I’m surprised that Next.js doesn’t (yet) offer a more abstracted solution for this seemingly common use case. Sitemaps are kind of a big deal.

But it works, it’s fast, and — best of all — I don’t have to think about it anymore.