# Prerendering an RSS Feed in a Static TanStack Start Site

I run an independent fan guide for **Lagos Life**, a Nigerian browser life-sim that went viral in October 2026. The game has no official changelog, so the guide keeps one: every few hours a script records the weekly law, the daily in-game headline and the daily gem-hunt clue into a JSON file, and the site is rebuilt.

A dated log page is useful, but people asked for a way to hear about changes without coming back to look. The answer is old and boring: **RSS**. The interesting part is that the site is fully prerendered — there's no server at request time — so the feed has to be a static file generated at build time from the same JSON the page uses.

Here's how that works in TanStack Start.

## A server route that only runs at build time

In TanStack Start, a file route can define `server.handlers` instead of a component. Normally that runs on a server. With prerendering, the build calls it once and writes the response body to disk.

The file name does the URL work. A literal dot is escaped with brackets, and the trailing underscore on `updates_` stops the route from nesting under the `/updates/` page layout:

```text
src/routes/updates_.feed[.]xml.ts   →   /updates/feed.xml
```

```ts
export const Route = createFileRoute('/updates_/feed.xml')({
  server: {
    handlers: {
      GET: () => {
        const xml = buildFeed(liveLog); // plain string building
        return new Response(xml, {
          headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
        });
      },
    },
  },
});
```

The JSON is a normal import (`import liveLog from '@/data/live-log.json'`), so it gets bundled at build time. No file reads, no fetch.

## Tell the prerenderer it exists

The prerenderer discovers pages by crawling links from `/`. Nothing links to an XML file with an `<a>` tag, so the crawler would never find it. It has to be listed explicitly, next to the other non-HTML outputs:

```ts
// vite.config.ts → tanstackStart({ prerender: { ... }, pages: [...] })
pages: [
  { path: '/robots.txt',  prerender: { enabled: true, outputPath: '/robots.txt' } },
  { path: '/sitemap.xml', prerender: { enabled: true, outputPath: '/sitemap.xml' } },
  { path: '/updates/feed.xml',
    prerender: { enabled: true, outputPath: '/updates/feed.xml' } },
],
```

I set `outputPath` explicitly so the file lands exactly where the feed URL says, with the `.xml` name intact.

## Three kinds of entries, one sorted list

The log has three arrays — law changes, daily headlines, gem-hunt clues. Each gets a small mapper to a common shape, then they're merged, filtered to the last 30 days and sorted newest first:

```ts
type Item = { guid: string; title: string; description: string; link: string; at: number };

const items = [
  ...log.laws.map(lawItem),
  ...log.daily.filter((d) => d.headline).map(dayItem),
  ...log.hunt.filter((h) => h.clue).map(clueItem),
]
  .filter((i) => Number.isFinite(i.at) && i.at >= since)
  .sort((a, b) => b.at - a.at);
```

A few details that took a second try:

*   **Stable GUIDs.** Feed readers decide "new item" by `guid`. I build it from the kind and the date (`day-2026-10-13`, `hunt-2026-10-13`) with `isPermaLink="false"`, so a rebuild every few hours never re-announces old items.
    
*   **The game's day, not mine.** The game runs on Lagos time (WAT, UTC+1). A headline for "13 October" should be dated midnight in Lagos, not midnight UTC: ``Date.parse(`${day}T00:00:00+01:00`)``. Get this wrong and items show up a day off for half the readers.
    
*   **Escape everything.** Headlines contain quotes and apostrophes ("Lagos says 'case closed'"). A five-line `esc()` for `& < > "` on every text node is enough for RSS 2.0; no XML library needed.
    
*   **Anchor links.** Every item links to a section of the one page (`/updates/#daily-headlines`, `#law-log`, `#daily-gem-hunt`) rather than to a page per item. There are no per-item pages, and I didn't want to create thin ones just for the feed.
    
*   `<ttl>180`**.** The data only changes every three hours, so the feed says so.
    

## Same red lines as the page

The feed is built from the same JSON as the page, so it can't leak anything the page doesn't show — but it could show *more* if a mapper got careless. The gem-hunt items carry the clue word for word and nothing else: no answers, no rewards, no leaderboard names. The governor's own announcements never reach the JSON in the first place.

## Make it discoverable

Two small things so readers can actually find the feed:

```ts
// root route head(): every page advertises the feed
links: [{ rel: 'alternate', type: 'application/rss+xml',
          title: 'Lagos Life updates (unofficial guide)',
          href: absoluteUrl('/updates/feed.xml') }],
```

…and a visible block on the page with the feed address and a copy button, because no major browser shows a feed button for `<link rel="alternate">` anymore.

## The result

The log page, with its feed linked at the bottom, is here: [**Lagos Life updates: this week's law, daily headlines and gem hunt clues**](https://lagoslifegames.com/updates/). Each rebuild regenerates the page and the feed together from one JSON file, so they can't drift apart.

The game itself is at [lagoslife.app](https://lagoslife.app/); my site is an independent fan guide, not affiliated with the developers.

## Takeaways

*   A prerendered "server route" is a clean way to emit **non-HTML files** (RSS, sitemap, robots) from the same data as your pages.
    
*   List those routes in the prerender `pages` config with an explicit `outputPath` — the crawler won't find them.
    
*   Date items in the **source's timezone**, and make **GUIDs deterministic** so frequent rebuilds don't spam readers.
