Back to blog
Aug 01, 2025
5 min read

Astro Sitemap in SSR Mode: When @astrojs/sitemap Misses Pages

@astrojs/sitemap can't see dynamic routes in an SSR Astro site. Build a sitemap.xml endpoint that lists every post, project and page, and avoid the trailing-slash mistake I made.

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.

Update, October 2026: the endpoint below originally wrote every URL with a trailing slash (/blog/${post.slug}/). My site redirects /x/ to /x (Vercel trailingSlash: false), so every URL in my sitemap was a redirect, and Search Console reported 925 URLs submitted and 0 indexed. The code now writes URLs without the slash. Whichever you choose, make your sitemap, your canonical tags and your host’s trailing-slash setting agree.

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?