# Dynamic sitemap.xml in Astro SSR

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

Author: Flavio Copes | Published: 2026-07-18 | Canonical: https://flaviocopes.com/dynamic-sitemap-astro-ssr/

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](https://stackplan.dev) 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`:

```ts
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:

```ts
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`:

```ts
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.

```ts
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:

```ts
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`:

```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.
