Skip to main content

Command Palette

Search for a command to run...

Prerendering an RSS Feed in a Static TanStack Start Site

A game with no changelog, a JSON log, and one server route that never runs on a server

Updated
•5 min read•View as Markdown

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:

src/routes/updates_.feed[.]xml.ts   →   /updates/feed.xml
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:

// 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:

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:

// 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. 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; 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.