Generating a Dynamic Sitemap in Remix
Search engines need to know what pages exist on your website to index them properly. A sitemap is an XML file that tells search engines about all your pages, when they were last updated, and how important they are. In this guide, I’ll show you how to build a dynamic sitemap in Remix that automatically includes your static pages and database-driven content.
Why You Need a Sitemap
A sitemap is essential for SEO. It helps search engines like Google discover and crawl your pages more efficiently, especially when:
- Your site has dynamic content that changes frequently
- Pages aren’t well-connected through internal links
- Your site is new and has few external links
The sitemap protocol is defined at sitemaps.org and supported by all major search engines.
Creating a Resource Route
Remix’s resource routes are perfect for sitemaps because they return data instead of UI. Create a new file at app/routes/[sitemap.xml].tsx:
The square bracket syntax [] escapes the special character . in Remix’s file-based routing. Alternatively, you could use app/routes/sitemap[.]xml.tsx. Both work identically. Learn more in the Remix documentation.
Building the Loader Function
Resource routes use a loader function to handle requests. Let’s start with a basic structure that returns an empty sitemap:
export async function loader() {
const sitemap = "";
try {
return new Response(sitemap, {
headers: {
"Content-Type": "application/xml",
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
},
});
} catch (error) {
console.error("Error generating Sitemap:", error);
throw new Response("Internal server error", { status: 500 });
}
}
Key points:
- Returns an XML string with
Content-Type: application/xml - Caches the sitemap for 24 hours with
Cache-Control - Handles errors gracefully with a 500 response
Adding Static Pages
Let’s understand the XML format before we generate it. A sitemap consists of URL entries with metadata about each page:
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2025-01-15</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://example.com/about/</loc>
<lastmod>2025-01-15</lastmod>
<changefreq>yearly</changefreq>
<priority>0.1</priority>
</url>
<url>
<loc>https://example.com/blog/</loc>
<lastmod>2025-01-15</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
</urlset>
Understanding the fields:
loc- The full URL of the pagelastmod- When the page was last modified (YYYY-MM-DD format)changefreq- How often the page changes (daily, weekly, monthly, yearly)priority- Relative importance (0.0 to 1.0)
Now let’s generate this dynamically using JavaScript:
export async function loader() {
// Define your static pages
const staticPages = [
{ loc: "/", lastmod: "2025-01-15", changefreq: "weekly", priority: 0.7 },
{
loc: "/about/",
lastmod: "2025-01-15",
changefreq: "yearly",
priority: 0.1,
},
{
loc: "/blog/",
lastmod: "2025-01-15",
changefreq: "weekly",
priority: 0.9,
},
];
// Set base URL based on environment
const baseUrl =
process.env.NODE_ENV === "production"
? process.env.BASE_URL
: "http://localhost:3000";
// Generate XML from the array
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${staticPages
.map(
(page) => `
<url>
<loc>${baseUrl}${page.loc}</loc>
<lastmod>${page.lastmod}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>`
)
.join("")}
</urlset>`;
try {
return new Response(sitemap, {
headers: {
"Content-Type": "application/xml",
"Cache-Control": "public, max-age=86400",
},
status: 200,
});
} catch (error) {
console.error("Error generating sitemap:", error);
throw new Response("Internal server error", { status: 500 });
}
}
By storing pages in an array, you can easily add new pages without touching the XML generation logic. If your site only has static pages, you’re done!
Including Dynamic Content
Most sites have dynamic content from a database. Let’s add blog posts to the sitemap by fetching them from your database:
import { prisma } from "~/.server/db";
export async function loader() {
const baseUrl =
process.env.NODE_ENV === "production"
? process.env.BASE_URL
: "http://localhost:3000";
// Static pages
const staticPages = [
{ loc: "/", lastmod: "2025-01-15", changefreq: "weekly", priority: 0.7 },
{
loc: "/about/",
lastmod: "2025-01-15",
changefreq: "yearly",
priority: 0.1,
},
{
loc: "/blog/",
lastmod: "2025-01-15",
changefreq: "weekly",
priority: 0.9,
},
];
// Fetch blog posts from database
const blogPosts = await prisma.blogPost.findMany({
select: { slug: true, updatedAt: true },
});
// Transform posts into sitemap format
const dynamicPages = blogPosts.map((post) => ({
loc: `/blog/${post.slug}/`,
lastmod: post.updatedAt.toISOString().split("T")[0], // Format as YYYY-MM-DD
changefreq: "monthly",
priority: 0.8,
}));
// Combine all pages
const allPages = [...staticPages, ...dynamicPages];
// Generate XML
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allPages
.map(
(page) => `
<url>
<loc>${baseUrl}${page.loc}</loc>
<lastmod>${page.lastmod}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>`
)
.join("")}
</urlset>`;
try {
return new Response(sitemap, {
headers: {
"Content-Type": "application/xml",
"Cache-Control": "public, max-age=86400",
},
status: 200,
});
} catch (error) {
console.error("Error generating sitemap:", error);
throw new Response("Internal server error", { status: 500 });
}
}
The magic here:
- Database query runs on each sitemap request
updatedAtis converted to the required YYYY-MM-DD format- Arrays are merged using spread syntax
- The same XML generation handles both static and dynamic pages
Deployment and Environment Variables
Your sitemap needs different base URLs for development and production. Here’s how to handle that properly.
Setting Up Environment Variables
const baseUrl =
process.env.NODE_ENV === "production"
? process.env.BASE_URL
: "http://localhost:3000";
This pattern ensures the correct domain is used in each environment.
Local Development:
Create a .env file in your project root:
BASE_URL="http://localhost:3000"
Remember to add .env to your .gitignore to avoid committing secrets.
Production Deployment:
Set environment variables through your hosting platform. For example, on Fly.io:
flyctl secrets set BASE_URL="https://your-domain.com"
On platforms like Vercel or Netlify, use their dashboard or CLI to set environment variables.
Optimizing Cache Strategy
The default max-age=86400 (24 hours) works for most sites. Adjust based on your needs:
// Update every hour for frequently changing content
"Cache-Control": "public, max-age=3600"
// Update every week for stable content
"Cache-Control": "public, max-age=604800"
Remember: search engines don’t check sitemaps constantly, so aggressive caching is fine.
Testing Your Sitemap
After deploying, verify your sitemap works:
- Visit
https://yourdomain.com/sitemap.xmlin your browser - Use the XML Sitemap Validator
- Submit it to Google Search Console
- Check that all URLs are correctly formatted and accessible
Key Takeaways
Building a dynamic sitemap in Remix is straightforward:
- Use resource routes to return XML responses
- Store static pages in an array for easy maintenance
- Fetch dynamic content from your database
- Combine arrays and generate XML with template literals
- Configure environment variables for different deployments
- Cache appropriately to reduce server load
With this setup, your sitemap automatically stays in sync with your content, helping search engines discover and index your pages efficiently.