If you’re running an Astro site in SSR (Server-Side Rendering) mode and noticed your sitemap is empty or missing dynamic content, you’re not alone. The official @astrojs/sitemap
integration has a fundamental limitation: it can’t discover dynamic routes during the build process when running in SSR mode.
The Problem
When you build an Astro site with output: "server"
, the sitemap integration can’t automatically discover your blog posts, projects, or any other content from collections. This is because:
- SSR routes are dynamic - They’re generated on-demand, not at build time
- The sitemap integration expects static routes - It needs to know all URLs upfront
- Content collections aren’t exposed to the integration - There’s no way to tell it about your dynamic content
Here’s what happens when you try to use customPages
with an async function:
// This doesn't work!
sitemap({
customPages: async () => {
const posts = await getCollection('blog');
return posts.map(post => `https://example.com/blog/${post.slug}/`);
}
})
You’ll get an error: [@astrojs/sitemap] customPages Expected array, received function
The Solution: Custom Sitemap Endpoint
The solution is to bypass the sitemap integration entirely and create a custom endpoint that generates the sitemap on-demand. Here’s how:
1. Remove the Sitemap Integration
First, remove @astrojs/sitemap
from your Astro config:
// astro.config.mjs
export default defineConfig({
site: "https://yourdomain.com",
integrations: [
mdx(),
solidJs(),
tailwind(),
// Remove: sitemap()
],
output: "server",
adapter: vercel(),
})
2. Create a Custom Sitemap Endpoint
Create a new file at src/pages/sitemap.xml.ts
:
import type { APIContext } from 'astro';
import { getCollection } from 'astro:content';
export async function GET(context: APIContext) {
const site = context.site?.toString() || 'https://yourdomain.com';
// Get all collections
const posts = await getCollection('blog');
const projects = await getCollection('projects');
// Generate URLs for all content
const urls: Array<{ loc: string; changefreq?: string; priority?: number }> = [];
// Static pages
urls.push(
{ loc: `${site}/`, changefreq: 'weekly', priority: 1.0 },
{ loc: `${site}/blog/`, changefreq: 'weekly', priority: 0.9 },
{ loc: `${site}/projects/`, changefreq: 'monthly', priority: 0.8 },
{ loc: `${site}/about/`, changefreq: 'monthly', priority: 0.7 },
);
// Blog posts
posts
.filter(post => !post.data.draft)
.forEach(post => {
urls.push({
loc: `${site}/blog/${post.slug}/`,
changefreq: 'monthly',
priority: 0.8
});
});
// Projects
projects
.filter(project => !project.data.draft)
.forEach(project => {
urls.push({
loc: `${site}/projects/${project.slug}/`,
changefreq: 'monthly',
priority: 0.7
});
});
// Get all unique tags
const allTags = new Set<string>();
posts.forEach(post => {
post.data.tags?.forEach(tag => allTags.add(tag));
});
// Add tag pages
allTags.forEach(tag => {
urls.push({
loc: `${site}/tags/${encodeURIComponent(tag)}/`,
changefreq: 'weekly',
priority: 0.5
});
});
// Generate sitemap XML
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(url => ` <url>
<loc>${url.loc}</loc>${url.changefreq ? `
<changefreq>${url.changefreq}</changefreq>` : ''}${url.priority !== undefined ? `
<priority>${url.priority}</priority>` : ''}
</url>`).join('\n')}
</urlset>`;
return new Response(sitemap, {
status: 200,
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
}
});
}
3. Update robots.txt
Update your robots.txt
to point to the new sitemap location:
# Sitemap location
Sitemap: https://yourdomain.com/sitemap.xml
Benefits of This Approach
- Always up-to-date - The sitemap is generated on each request, so new content appears immediately
- Works with SSR - No build-time limitations
- Fully customizable - Add any logic you need for including/excluding content
- Better for dynamic sites - Perfect for sites with frequently changing content
Caching Considerations
The example above caches the sitemap for 1 hour (max-age=3600
). Adjust this based on how often your content changes:
- Frequently updated sites: Use a shorter cache time (e.g., 15 minutes)
- Rarely updated sites: Use a longer cache time (e.g., 24 hours)
- Real-time requirements: Remove caching entirely
Including Additional Content
This approach makes it easy to include any type of dynamic content in your sitemap:
// Include author pages
const authors = await getCollection('authors');
authors.forEach(author => {
urls.push({
loc: `${site}/authors/${author.slug}/`,
changefreq: 'monthly',
priority: 0.6
});
});
// Include API endpoints (for A2A protocol, etc.)
urls.push({
loc: `${site}/.well-known/agent.json`,
changefreq: 'yearly',
priority: 0.9
});
Conclusion
While the official Astro sitemap integration is great for static sites, SSR mode requires a different approach. By creating a custom sitemap endpoint, you get full control over your sitemap generation and ensure all your dynamic content is properly indexed by search engines.
This solution has been working perfectly on my site, and the sitemap updates instantly whenever I publish new content. No more wondering why Google can’t find your latest blog posts!