Nakodo has a blog with five posts on it. The posts are TypeScript files: a title, a summary, and an array of typed blocks that one renderer turns into a page. There is no CMS and no markdown loader, so when I wanted a feed there was nothing to plug in.
The whole feed is a route handler that joins strings. You can read the output before the code: nakodo.app/blog/feed.xml.
$ curl -s -o /dev/null -w '%{http_code} %{size_download} %{content_type}\n' \
https://nakodo.app/blog/feed.xml
200 4774 application/rss+xml; charset=utf-8
The route
The file is src/app/(marketing)/blog/feed.xml/route.ts. The dot in the folder name is the whole trick for the URL: a route segment can contain a dot, so feed.xml/route.ts answers at /blog/feed.xml with no rewrite and no config.
import { entriesOf } from "@/content/registry";
import { plain } from "@/content/rich";
import { BRAND } from "@/lib/brand";
import { absoluteUrl } from "@/lib/seo/site";
// RSS for the blog, built once at build time from src/content/blog. Feed readers
// and aggregators pick new posts up from here without waiting for a crawl.
export const dynamic = "force-static";
const escape = (s: string) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
// RSS wants RFC 822 dates; posts carry ISO days.
const rfc822 = (iso: string) => new Date(`${iso}T00:00:00Z`).toUTCString();
force-static is honest here rather than clever. The posts are modules in the bundle, so a new post needs a deploy no matter what the route does; making the route static means the feed is a file on the CDN instead of a function invocation per feed reader.
Four replacements, not five. & has to go first or it would double-escape the entities the other three produce. ' is deliberately missing: an apostrophe is only special inside single-quoted attribute values, and nothing here is attributed with single quotes, so the live feed carries Don't buy one as typed. XML parsers are fine with that, and ' in a title is the kind of thing that ends up visible in somebody's reader.
The date conversion is three lines of surprise compressed into one. Posts carry published: "2026-10-03", because a day is all the page claims. RSS wants RFC 822, so the ISO day gets T00:00:00Z glued on before toUTCString(). The Z is load-bearing in the general case: a date-only string is parsed as UTC, but add a time with no zone and the same engine switches to local time, so new Date("2026-10-03T00:00:00") on a machine in Berlin is two hours earlier than the one in the feed. Writing the Z means the feed does not change when the build machine moves.
Every item has the same date
Here is the part I only noticed by reading my own output.
$ curl -s https://nakodo.app/blog/feed.xml | grep -o '<pubDate>[^<]*' | sort -u
<pubDate>Sat, 03 Oct 2026 00:00:00 GMT
$ curl -s https://nakodo.app/blog/feed.xml | grep -c '<item>'
5
The route sorts newest first:
const posts = [...entriesOf("post")].sort((a, b) =>
b.page.published.localeCompare(a.page.published),
);
All five posts were published on the same day, so every comparison returns 0 and the sort does nothing. Array.prototype.sort has been required to be stable since ES2019, which means the order readers get is the order of the hand-written array:
// Newest first: /blog lists posts in this order, and the first is its featured card.
export const POSTS: ContentPage[] = [
automatedInfluencerOutreach,
buyingInfluencerEmailLists,
youtubeVsInstagramVsTiktok,
influencerMarketingPlatformOrDiy,
ugcCreatorsVsInfluencers,
];
So the comment on the array is the real sort, and the sort in the feed is decoration. That is fine today and wrong the first time two posts go out a week apart and somebody appends to the array instead of unshifting. Worse, a feed reader cannot fix it either: with day granularity and five identical pubDate values, anything that orders by date is ordering by nothing, and I have no idea what each client falls back on.
Two ways out, and I have not picked yet. Either timestamps become real (published: "2026-10-03T09:00:00Z", which makes the sort work and gives readers something to tie-break on), or the sort goes away and a comment says the array is the order, which is already true. What I will not do is leave code that looks like it decides something it does not.
The rest of the channel
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">',
" <channel>",
` <title>${escape(`${BRAND.name} blog`)}</title>`,
` <link>${absoluteUrl("/blog")}</link>`,
` <atom:link href="${absoluteUrl("/blog/feed.xml")}" rel="self" type="application/rss+xml" />`,
` <description>${escape(BRAND.description)}</description>`,
" <language>en</language>",
...(posts.length ? [` <lastBuildDate>${rfc822(posts.map((e) => e.page.updated).sort().at(-1)!)}</lastBuildDate>`] : []),
...items,
" </channel>",
"</rss>",
].join("\n");
Three decisions worth naming:
-
Two namespaces for two elements.
atom:exists for the oneatom:link rel="self", which is how a feed states its own canonical URL;dc:exists fordc:creator, because RSS 2.0's own<author>is defined as an email address and the author is a company, not a mailbox. Declaring a namespace for one element each looks wasteful and is the correct amount. -
lastBuildDatefrom content, not from the clock.posts.map(updated).sort().at(-1)works because ISO days sort lexicographically, so the latest edit date wins with no date parsing. A build date would say every post changed on every deploy, which is the same lie a sitemap tells when it uses the build timestamp. -
No
content:encoded. Each item carriestitle,link,guid isPermaLink="true",pubDate,dc:creatorand adescription, and the description is the page's own summary run throughplain(), the function that strips our inline markup so[label](/path)never reaches a reader as literal text. The feed is an index, not a copy of the site.
The feed is also announced, which is the part people forget. The metadata helper takes a feeds record and turns it into alternates.types, so /blog and every post page carry the link in the head:
$ curl -s https://nakodo.app/blog | grep -o '<link[^>]*alternate[^>]*>'
<link rel="alternate" type="application/rss+xml" href="https://nakodo.app/blog/feed.xml"/>
That is the whole feature: 44 lines, no dependency, one route segment with a dot in it, and one sort that has never had anything to sort. The posts it serves are at nakodo.app/blog, and the raw XML is one curl away if you want to check the escaping yourself.
Top comments (0)