DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our blog's RSS feed is one string join, and all five posts in it share one pubDate

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
Enter fullscreen mode Exit fullscreen mode

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

// RSS wants RFC 822 dates; posts carry ISO days.
const rfc822 = (iso: string) => new Date(`${iso}T00:00:00Z`).toUTCString();
Enter fullscreen mode Exit fullscreen mode

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 &apos; 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
Enter fullscreen mode Exit fullscreen mode

The route sorts newest first:

const posts = [...entriesOf("post")].sort((a, b) =>
  b.page.published.localeCompare(a.page.published),
);
Enter fullscreen mode Exit fullscreen mode

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,
];
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Three decisions worth naming:

  • Two namespaces for two elements. atom: exists for the one atom:link rel="self", which is how a feed states its own canonical URL; dc: exists for dc: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.
  • lastBuildDate from 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 carries title, link, guid isPermaLink="true", pubDate, dc:creator and a description, and the description is the page's own summary run through plain(), 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"/>
Enter fullscreen mode Exit fullscreen mode

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)