Back to blog
Aug 01, 2025
5 min read

Fixing Astro Sitemap Generation in SSR Mode

Learn how to create a custom sitemap endpoint for Astro SSR sites when the official sitemap integration can't discover your dynamic content. This solution generates sitemaps on-demand with all your blog posts, projects, and tags.

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:

  1. SSR routes are dynamic - They’re generated on-demand, not at build time
  2. The sitemap integration expects static routes - It needs to know all URLs upfront
  3. 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

  1. Always up-to-date - The sitemap is generated on each request, so new content appears immediately
  2. Works with SSR - No build-time limitations
  3. Fully customizable - Add any logic you need for including/excluding content
  4. 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!

Let's Build AI That Works

Ready to implement these ideas in your organization?