Dynamic sitemap.xml in Astro SSR

By

Generate sitemap.xml and robots.txt dynamically in Astro SSR without @astrojs/sitemap. API routes, typed registries, and testable pure functions.

~~~

Most Astro SEO guides assume a static build. You add @astrojs/sitemap, run astro build, and get a sitemap.xml in dist/.

That breaks when your app is SSR-only. StackPlan runs on Cloudflare Workers with output: 'server'. There is no build-time sitemap.

You generate sitemap.xml at request time instead.

An API route that returns XML

Astro pages ending in .ts export HTTP handlers. sitemap.xml.ts serves /sitemap.xml:

import type { APIRoute } from 'astro'
import { getCollection } from 'astro:content'
import { buildSitemapXml } from '../lib/sitemap'

export const prerender = false

export const GET: APIRoute = async () => {
  const guides = await getCollection('guides')
  const guideSlugs = guides.map((entry) => entry.id)
  const xml = buildSitemapXml(guideSlugs)

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml; charset=utf-8',
      'Cache-Control': 'public, max-age=3600',
    },
  })
}

The route stays thin. It loads dynamic slugs (content collection entries) and delegates XML building to a library function.

Cache for an hour. Sitemap URLs don’t change every second.

Build the URL list from registries

A sitemap is just a list of absolute URLs. The hard part is knowing every public page.

Static marketing pages are a constant array:

const STATIC_PATHS = ['/', '/new', '/pricing', '/tools', '/privacy'] as const

Data-driven pages come from typed registries. Each module for tools, provider comparisons, and case studies exports a list with a path or slug:

export function buildSitemapUrls(guideSlugs: string[]): string[] {
  const urls = STATIC_PATHS.map((path) => `${SITE_URL}${path}`)

  for (const tool of TOOLS) {
    urls.push(`${SITE_URL}${tool.path}`)
  }

  for (const pair of SENSIBLE_COMPARISON_PAIRS) {
    urls.push(`${SITE_URL}/compare/${pair.slug}`)
  }

  for (const slug of guideSlugs) {
    urls.push(`${SITE_URL}/guides/${slug}`)
  }

  return urls
}

When you add a new tool to the registry, the sitemap picks it up automatically. No manual sitemap edits.

Pure functions you can test

Keep the XML string logic in a separate function:

export function buildSitemapXml(guideSlugs: string[]): string {
  const entries = buildSitemapUrls(guideSlugs)
    .map((loc) => `  <url>\n    <loc>${escapeXml(loc)}</loc>\n  </url>`)
    .join('\n')

  return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries}
</urlset>`
}

escapeXml() handles &, <, and quotes.

Unit tests run without Astro or Workers:

it('includes static routes and registry-driven pages', () => {
  const urls = buildSitemapUrls(['cloudflare-workers'])

  expect(urls).toContain('https://stackplan.dev/privacy')
  expect(urls).toContain('https://stackplan.dev/guides/cloudflare-workers')
})

I also assert the total URL count. That catches a forgotten registry loop.

robots.txt as a route too

Same pattern for robots.txt.ts:

export const prerender = false

const BODY = `User-agent: *
Allow: /

Disallow: /admin
Disallow: /api/
Disallow: /dashboard

Sitemap: ${SITE_URL}/sitemap.xml
`

export const GET: APIRoute = () =>
  new Response(BODY, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  })

Block private areas. Point crawlers at your dynamic sitemap.

What you give up

@astrojs/sitemap adds lastmod, changefreq, and image tags automatically. A hand-rolled sitemap is simpler: just <loc> entries.

For most apps that’s enough. Google cares that the URLs exist and match real pages.

If you need lastmod per guide, pull updated from the content collection entry and extend the XML builder.

The mental model

With static Astro, pages exist at build time, so the sitemap plugin walks the build output.

With SSR Astro, pages are rendered on demand, so you maintain an explicit URL catalog.

Typed registries keep the catalog in one place. You can test the pure builder and serve it from a one-file API route.

You don’t need an integration package, and the sitemap cannot stay stale after a deploy.

Tagged: Astro · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about astro: