By SitemapFixer Team
Updated April 2026

Remix Sitemap: How to Generate an XML Sitemap

Check your Remix sitemap for errors before submitting to GoogleCheck My Sitemap Free

Remix Doesn't Include a Built-in Sitemap

Remix is a full-stack web framework built on web standards, but it does not ship with a sitemap generator. You need to add one yourself. There are two solid approaches: writing a custom resource route (recommended for full control), or using the remix-sitemap third-party package (faster to set up, convention-based).

A resource route in Remix is a route file that exports a loader (for GET) or action (for POST/PUT/DELETE) but no default component. When Remix matches the URL, it runs the loader and returns the response directly to the client. This makes resource routes ideal for generating XML, JSON feeds, RSS, or any non-HTML response.

Approach 1: Resource Route at sitemap[.]xml.tsx

Create the file app/routes/sitemap[.]xml.tsx. The square bracket notation in Remix escapes the dot so the route matches the literal URL /sitemap.xml. Without the brackets, Remix would treat the dot as a path separator.

// app/routes/sitemap[.]xml.tsx import type { LoaderFunctionArgs } from '@remix-run/node'; const SITE_URL = 'https://yourdomain.com'; interface Post { slug: string; updatedAt: string; } export async function loader({ request }: LoaderFunctionArgs) { // Fetch dynamic content from your data source const posts: Post[] = await getPosts(); // your own function const staticUrls = [ { loc: `${SITE_URL}/`, priority: '1.0', changefreq: 'daily' }, { loc: `${SITE_URL}/about`, priority: '0.7', changefreq: 'monthly' }, { loc: `${SITE_URL}/blog`, priority: '0.8', changefreq: 'weekly' }, { loc: `${SITE_URL}/contact`, priority: '0.5', changefreq: 'monthly' }, ]; const dynamicUrls = posts.map((post) => ({ loc: `${SITE_URL}/blog/${post.slug}`, lastmod: new Date(post.updatedAt).toISOString().split('T')[0], priority: '0.7', changefreq: 'weekly', })); const allUrls = [...staticUrls, ...dynamicUrls]; const xml = `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${allUrls .map( (entry) => ` <url> <loc>${entry.loc}</loc> ${entry.lastmod ? `<lastmod>${entry.lastmod}</lastmod>` : ''} <changefreq>${entry.changefreq}</changefreq> <priority>${entry.priority}</priority> </url>` ) .join('\n')} </urlset>`; return new Response(xml, { status: 200, headers: { 'Content-Type': 'application/xml', 'Cache-Control': 'public, max-age=0, s-maxage=3600', 'X-Content-Type-Options': 'nosniff', }, }); } // Placeholder — implement with your actual data source async function getPosts(): Promise<Post[]> { // Example: return await db.post.findMany({ select: { slug: true, updatedAt: true } }); return []; }

Approach 2: Using the remix-sitemap Package

The remix-sitemap package provides a convention-based approach. Install it with npm install remix-sitemap. You then export a handle object with sitemap configuration from each route that should be included, and set up a central config file.

// sitemap.server.ts (project root or app/ directory) import { createSitemap } from 'remix-sitemap'; export const { sitemap, isSitemapUrl } = createSitemap({ siteUrl: 'https://yourdomain.com', autoLastmod: true, }); // app/routes/sitemap[.]xml.tsx import { sitemap } from '~/sitemap.server'; export const loader = sitemap; // app/routes/blog.$slug.tsx — mark this route for the sitemap export const handle = { sitemap: { changefreq: 'weekly', priority: 0.7, }, };

The package reads all routes with a handle.sitemap export and generates the XML automatically. For dynamic routes with real slugs, you need to implement a getSitemapEntries function that returns the actual URLs. See the remix-sitemap documentation for the full API.

Edge vs Node Runtime Considerations

Remix supports both Node.js and edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge). The resource route approach works on both runtimes because it uses the standard Web Response API. Avoid using Node.js-specific APIs like fs inside your sitemap loader if you're targeting an edge runtime.

On Cloudflare Workers, database access typically goes through Cloudflare D1 (SQLite) or an external API. Fetch your dynamic URLs via HTTP or use bindings, not direct database connections. On Vercel, you can use Vercel KV or a standard database via an ORM like Prisma — but be mindful of cold start latency on serverless functions, which is why the CDN cache header (s-maxage=3600) matters.

Caching Your Sitemap for Performance

Generating a sitemap dynamically on every request is wasteful. Set aggressive CDN caching with s-maxage=3600 (cache at the CDN edge for 1 hour) combined with max-age=0 (no browser caching, so clients always get fresh CDN content). This means the first request per hour hits your server; subsequent requests are served from the CDN edge.

For content that changes infrequently, push s-maxage up to 86400 (24 hours) or even 604800 (1 week). For blogs with multiple daily posts, keep it at 3600. Google's crawler doesn't fetch your sitemap more than once every few hours anyway, so caching longer than that has no downside.

Submitting Your Remix Sitemap to Google Search Console

Once deployed, verify the sitemap returns valid XML at https://yourdomain.com/sitemap.xml. Then open Google Search Console, go to Indexing > Sitemaps, enter sitemap.xml, and click Submit. Google will queue it for processing. Add the sitemap URL to your robots.txt file as well: Sitemap: https://yourdomain.com/sitemap.xml — this ensures all crawlers can discover it even without GSC submission.

Validate Your Remix Sitemap Instantly
Free analysis in 60 seconds
Check My Sitemap Free

Related Guides