Skip to content
FLAVIO COPES
flaviocopes.com
2026

Dynamic sitemap.xml in Astro SSR

By Flavio Copes

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 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. Tools, provider comparisons, case studies — each module 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

Don’t put XML string logic in the route file. Extract it.

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 &, <, quotes. Boring but necessary.

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:

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

Static Astro: pages exist at build time → sitemap plugin walks the build output.

SSR Astro: pages are rendered on demand → you maintain an explicit URL catalog.

Keep the catalog in typed registries, not scattered strings. Test the pure builder. Serve it from a one-file API route.

That’s it. No integration package, no stale sitemap from last week’s deploy.

Tagged: Astro · All topics
~~~

Related posts about astro: