Skip to content

Instantly share code, notes, and snippets.

What would you like to do?
sitemap.xml generator for
import childProcess from 'child_process';
import fs from 'fs';
import dotenv from 'dotenv';
import prettier from 'prettier';
const rootDir = process.cwd();
path: `${rootDir}/.env.production`,
interface Route {
id: string;
path?: string;
file: string;
children?: Route[];
const today = new Date().toISOString();
const domain = process.env.HOST;
console.log(`Updating sitemap on ${today} for domain ${domain}...`);
const consideredRoutes: string[] = [];
function addPathParts(path1 = '', path2 = ''): string {
return path1.endsWith('/') || path2.startsWith('/') ? `${path1}${path2}` : `${path1}/${path2}`;
function pathToEntry(path: string): string {
return `
<loc>${addPathParts(domain, path)}</loc>
async function depthFirstHelper(route: Route, currentPath = ''): Promise<string> {
let sitemapContent = '';
const isLayoutRoute = !route.path;
const pathIncludesParam = (route.path && route.path.includes(':')) || currentPath.includes(':');
if (!isLayoutRoute && !pathIncludesParam) {
const filePath = `${rootDir}/app/${route.file}`;
const routeContent = fs.readFileSync(filePath, 'utf8');
// no default export means API route
if (routeContent.includes('export default')) {
const nextPath = addPathParts(currentPath, route.path);
const isConsidered = consideredRoutes.includes(nextPath);
if (!isConsidered) {
sitemapContent += pathToEntry(nextPath);
if (route.children) {
for (const childRoute of route.children) {
const nextPath = addPathParts(currentPath, route.path);
sitemapContent += await depthFirstHelper(childRoute, nextPath);
return sitemapContent;
async function routesToSitemap(routes: Route[]): Promise<string> {
let sitemapContent = '';
for (const route of routes) {
sitemapContent += await depthFirstHelper(route, '');
return `
<?xml version="1.0" encoding="UTF-8"?>
const formatSitemap = (sitemap: string) => prettier.format(sitemap, { parser: 'html' });
async function main() {
const output = childProcess.execSync('npx remix routes --json');
const routes: Route[] = JSON.parse(output.toString());
const root = routes.find((r) => === 'root');
if (!root) {
throw new Error('Root not found');
const childRoutes = root.children;
if (!childRoutes) {
throw new Error('Root has no children routes');
console.log(`Found ${childRoutes.length} root children routes!`);
const sitemap = await routesToSitemap(childRoutes);
const formattedSitemap = formatSitemap(sitemap);
fs.writeFileSync('./public/sitemap.xml', formattedSitemap, 'utf8');
console.log('sitemap.xml updated 🎉');
return formattedSitemap;
Copy link

smith commented Dec 16, 2021

Instead of opening a child process, could you instead require('@remix-run/dev/config/format').formatRoutesAsJson(require('./server/build/routes'))? I don't think it's really part of the public API since you have to get it from config/format, but you would get the same result without having to shell out.

This would be great to have available as a module at runtime, so in my /app/routes/sitemap[.xml].tsx I can do

import { getSiteMapLoader } from 'remix-sitemap'
export const loader = getSiteMapLoader

Which could return a response with XML content type headers.

Thanks for making this. Great example!

Copy link

andrelandgraf commented Dec 16, 2021

Hey, glad this is helpful to you! Right now, the script only considers "static" routes (without route params) that don't change at runtime. That's why I use it during build time.

I like that your idea gets rid of the node code and makes it platform agnostic! I think your solution depends on the cli package though, right? You would need to install it as a dependency instead of a devDependency to use it during runtime. It would be awesome if Remix could provide an official API for that. Kent opened a ticket to access the route information at runtime through an official API: remix-run/remix#741 Maybe leave your suggestions there as well!

Copy link

CanRau commented Dec 25, 2021

Alternative could be using a resource route like I explain in RSS in Remix

Copy link

andrelandgraf commented Dec 28, 2021

@CanRau yes, you will need to do that when you have dynamic content as well. Kent is doing this on his page:

Copy link

CanRau commented Dec 28, 2021

Uh yea, also stumbled upon his approach though not (yet) sure what the reason is, on first look seems more complicated 😳

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment