Generate a sitemap on every build, with CMS support
A sitemap written once goes stale the day you add a page. This prompt sets up a script that regenerates sitemap.xml on every build, so the file is correct as long as you keep deploying.
It also handles the case the simple prompt cannot: dynamic routes like /blog/:slug, where the actual URLs live in a CMS or database rather than in your router.
When you need this#
- You publish regularly, and hand-updating the sitemap has already been forgotten once.
- Your blog, docs, or product pages come from a CMS, so the router only has
:slugplaceholders. - Someone else on the team ships pages and will not remember a manual step.
If your site is a fixed set of pages that rarely changes, the simpler prompt is less machinery to maintain.
What it does before it writes anything#
The prompt tells the builder to read your codebase first and ask you only when something is genuinely ambiguous:
- Finds your routes file, checking
App.tsx,router.tsx,routes.tsx, andmain.tsx. - Detects your production domain from canonical links, meta tags, env vars, CNAME, or config, including whether you use
www. - Looks for an existing pattern that fetches content by slug (Supabase, Firebase, Sanity, Strapi, Contentful, Convex, Prisma, or raw fetch) and traces its client, endpoint, auth, and response shape.
- Identifies private routes behind auth guards or dashboard layouts.
Then it creates the script, adds a Vite plugin so it runs at build time, and runs a build to show you the output.
Before you paste it#
- The script uses
bunand a Vite build. On npm or a different bundler, tell the builder your setup and let it adapt the two commands. - It parses your router with Babel to find
<Route>elements. A config-object router (createBrowserRouterwith a plain array) needs a different parse, so say so up front. - If your CMS needs a key that is not already in env vars, the prompt will ask rather than guess.
The prompt#
I need you to set up automatic sitemap generation that runs on every build. Follow these steps exactly.
Step 0: Investigate my codebase silently before doing anything.
- Find my routes file (check App.tsx, router.tsx, routes.tsx, main.tsx)
- Detect my production domain from canonical links, meta tags, env vars, CNAME, or config files. Determine if it uses www or non-www.
- Search for any existing pattern that fetches dynamic content by slug from a CMS, database, or API (Supabase, Firebase, Sanity, Strapi, Contentful, Convex, Prisma, or raw fetch calls). Trace the client, endpoint, auth method, and response shape from the existing code.
- Identify private/protected routes (behind auth guards, inside dashboard or admin layouts)
- Only ask me if you cannot determine the domain or if a CMS/API key is not already in env vars.
Step 1: Run this command:
bun add -d @babel/parser @babel/traverse @types/babel__traverse
Step 2: Create the file src/lib/generate-sitemap.ts with exactly this code:
```ts
// Sitemap generation script
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import * as parser from "@babel/parser";
import traverse, { NodePath } from "@babel/traverse";
import { JSXAttribute, JSXIdentifier, JSXOpeningElement } from "@babel/types";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ———————————————
// CONFIGURATION
// ———————————————
const BASE_URL = "https://YOURDOMAIN.com";
const ROUTER_FILE_PATH = path.resolve(__dirname, "../App.tsx");
const OUTPUT_DIR = path.resolve(__dirname, "../../public");
const IGNORE_PATHS: string[] = ["/dashboard/*"];
// ———————————————
// SITEMAP SCRIPT
// ———————————————
const SITEMAP_PATH = path.join(OUTPUT_DIR, "sitemap.xml");
function getAttributeValue(
astPath: NodePath<JSXOpeningElement>,
attributeName: string
): string | null {
const attribute = astPath.node.attributes.find(
(attr): attr is JSXAttribute =>
attr.type === "JSXAttribute" && attr.name.name === attributeName
);
if (!attribute) return null;
const value = attribute.value;
if (value?.type === "StringLiteral") return value.value;
return null;
}
function joinPaths(paths: string[]): string {
if (paths.length === 0) return "/";
const joined = paths.join("/");
const cleaned = ("/" + joined).replace(/\/+/g, "/");
if (cleaned.length > 1 && cleaned.endsWith("/")) return cleaned.slice(0, -1);
return cleaned;
}
function shouldIgnoreRoute(route: string): boolean {
for (const ignorePattern of IGNORE_PATHS) {
if (ignorePattern === route) return true;
if (ignorePattern.endsWith("/*")) {
const prefix = ignorePattern.slice(0, -2);
if (route.startsWith(prefix + "/") || route === prefix) return true;
}
}
return false;
}
function createSitemapXml(routes: string[]): string {
const today = new Date().toISOString().split("T")[0];
const urls = routes
.map((route) => {
const fullUrl = new URL(route, BASE_URL).href;
return `
<url>
<loc>${fullUrl}</loc>
<lastmod>${today}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`;
})
.join("");
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>
`;
}
async function generateSitemap() {
console.log("Generating sitemap...");
if (!BASE_URL.startsWith("http")) {
console.error('Error: BASE_URL must be a full URL (e.g., "https://example.com")');
process.exit(1);
}
const content = fs.readFileSync(ROUTER_FILE_PATH, "utf-8");
const ast = parser.parse(content, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
const pathStack: string[] = [];
const foundRoutes: string[] = [];
traverse(ast, {
JSXOpeningElement: {
enter(astPath) {
const nodeName = astPath.node.name as JSXIdentifier;
if (nodeName.name !== "Route") return;
const pathProp = getAttributeValue(astPath, "path");
const hasElement = astPath.node.attributes.some(
(attr) => attr.type === "JSXAttribute" && attr.name.name === "element"
);
if (pathProp) pathStack.push(pathProp);
if (hasElement && pathProp) {
const fullRoute = joinPaths(pathStack);
foundRoutes.push(fullRoute);
}
},
exit(astPath) {
const nodeName = astPath.node.name as JSXIdentifier;
if (nodeName.name !== "Route") return;
const pathProp = getAttributeValue(astPath, "path");
if (pathProp) pathStack.pop();
},
},
});
const staticRoutes = foundRoutes.filter(
(route) => !route.includes(":") && !route.includes("*")
);
const filteredRoutes = staticRoutes.filter(
(route) => !shouldIgnoreRoute(route)
);
console.log(`Found ${foundRoutes.length} total routes.`);
console.log(`Filtered ${staticRoutes.length - filteredRoutes.length} ignored routes.`);
console.log(`Final ${filteredRoutes.length} routes in sitemap.`);
if (filteredRoutes.length > 0) console.log("Routes:", filteredRoutes.join(", "));
const sitemapXml = createSitemapXml(filteredRoutes);
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(SITEMAP_PATH, sitemapXml);
console.log(`Sitemap successfully generated at ${SITEMAP_PATH}`);
}
generateSitemap().catch(console.error);
```
Now adapt ONLY the CONFIGURATION section at the top of this file:
- Set BASE_URL to the production domain you detected (with www or non-www based on what the codebase already uses). If you could not determine it, ask me.
- Set ROUTER_FILE_PATH to point to the routes file you found (e.g. "../router.tsx" or "../App.tsx").
- Set IGNORE_PATHS to include all private/protected routes you identified (e.g. "/dashboard/*", "/admin/*", "/settings/*", "/auth/*").
- If you found a CMS, database, or API integration that fetches dynamic content by slug, add a function called fetchDynamicRoutes() that reuses the same client/endpoint/auth pattern from the codebase to fetch all published slugs at build time. Call it in generateSitemap() between the "Filter out ignored paths" step and the "Generate the XML" step, prepend the correct URL prefix to each slug, and concat them into filteredRoutes.
Step 3: Add the following Vite plugin to vite.config.ts. Add the import at the top and sitemapPlugin() to the plugins array. Do not remove or change any existing plugins or config:
```ts
import { execSync } from "child_process";
function sitemapPlugin() {
return {
name: "sitemap-generator",
buildEnd: () => {
console.log("Running sitemap generator...");
try {
execSync("bun run src/lib/generate-sitemap.ts", { stdio: "inherit" });
} catch (error) {
console.error("Failed to generate sitemap:", error);
}
},
};
}
```
Add sitemapPlugin() to the plugins array in defineConfig.
Step 4: Run bun run build and confirm the sitemap was generated at public/sitemap.xml. Show me its contents.
After it runs#
Run a build and read the console output. The script prints how many routes it found, how many it filtered, and the final list, which is the fastest way to spot a private route that slipped through or a CMS fetch that returned nothing.
Then confirm the file is actually served at yourdomain.com/sitemap.xml rather than only sitting in public/, and point your robots.txt at it.
Alternatives#
- A framework plugin.
next-sitemap,@nuxtjs/sitemap, and similar do this with less custom code and are maintained for you. Use one if your stack has it. - The simple prompt. For a stable set of pages, the one-off sitemap is easier to reason about.
- Your CMS. If almost every URL comes from the CMS, generate the sitemap there and skip the router parsing.
