Our blog has no CMS. Every post is a TypeScript object in one array, and the article bodies are React components. lib/blog/registry.ts currently holds 66 entries.
Four things read that array, and the interesting part is that only one of them is the blog.
1. The index page
/blogs renders the first entry as a featured card and the rest as a grid:
{BLOG_POSTS.length > 0 && (
<Link href={`/blogs/${BLOG_POSTS[0].slug}`}>...</Link>
)}
{BLOG_POSTS.slice(1).map((post) => ( ... ))}
Note what that implies about the array. Position 0 is a decision, not a date. The registry says so:
/**
* Listing order. The first entry is rendered as the featured card on /blogs, so
* this array is curated rather than sorted by date.
*/
2. The structured data on the same page
The index emits a Blog node whose blogPost array is the registry mapped into BlogPosting nodes:
blogPost: BLOG_POSTS.map((post) => ({
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt ?? post.description,
url: `${baseUrl}/blogs/${post.slug}`,
datePublished: post.date,
...
}))
The excerpt ?? description fallback is the small detail that keeps this honest: the markup quotes the same sentence the card shows, so the structured data cannot describe a post differently from the page it is on.
3. The sitemap, through a deliberately thin module
app/sitemap.ts needs slugs and dates and nothing else, so it does not import the registry at all:
/**
* Slug and date metadata for every blog post, derived from the registry.
*
* Kept as its own module because app/sitemap.ts and app/api/indexnow only need
* the URLs and dates, and should not pull in the rest of the registry's copy.
*/
export const blogPostMeta = BLOG_POSTS.map(({ slug, date, lastModified }) => ({ slug, date, lastModified }));
Two lines of projection buy a real thing: the sitemap route and the IndexNow submitter do not drag every excerpt, tag list and word count into their own module graph, while still being derived rather than typed out. A hand maintained URL list in a sitemap is one of those files that is wrong within a month and silently.
4. The RSS feed
app/feed.xml/route.ts is a template string. No library:
export async function GET() {
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" ...>
<channel>
...
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${baseUrl}/feed.xml" rel="self" type="application/rss+xml"/>
${BLOG_POSTS.map((post) => `
<item>
<title><![CDATA[${post.title}]]></title>
<link>${baseUrl}/blogs/${post.slug}</link>
<guid isPermaLink="true">${baseUrl}/blogs/${post.slug}</guid>
<description><![CDATA[${post.excerpt ?? post.description}]]></description>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
${post.tags.map((tag) => `<category><![CDATA[${tag}]]></category>`).join('\n ')}
</item>`).join('')}
</channel>
</rss>`;
return new Response(rss, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
});
}
A feed is a small enough format that a library is mostly a way to not learn it. The things that actually matter are CDATA around anything an author typed, a guid that never changes for a given post, pubDate in RFC 822 rather than ISO 8601, and an atom:self link. The toUTCString() calls are doing the date format conversion, which is the one place a hand written feed usually goes wrong.
It is live: cogniprep.app/feed.xml. 66 items, about 54 KB. Autodiscovery is in the root layout, so a reader pointed at the domain finds it without the path:
<link rel="alternate" type="application/rss+xml" title="CogniPrep Blog RSS Feed" href="/feed.xml" />
The bug that writing this post found
Run this against the feed, in a console on any page of the site:
const xml = await (await fetch('/feed.xml')).text();
const doc = new DOMParser().parseFromString(xml, 'application/xml');
const dates = [...doc.querySelectorAll('item > pubDate')].map(d => new Date(d.textContent));
console.log(dates.slice(0, 4).map(d => d.toDateString()));
console.log('descending?', dates.every((d, i) => i === 0 || dates[i - 1] >= d));
You get false. Today the first item is dated 7 September, the next nine are 24 September, and the tail runs back to June.
Which is exactly what the code says it does, and I still did not see it until I looked at the output. BLOG_POSTS is a curated order because position 0 is the featured card on a web page. The feed reuses the array verbatim, and RSS has no concept of "featured": the order of <item> elements is the order a reader is entitled to present.
Most readers sort by pubDate and hide the mistake. Some do not, and the ones that do not are showing a September post above a more recent one for no reason a subscriber can see.
The fix is one line in the route, sorting a copy by date, and the reason it is one line is the thing worth taking away: the derived consumer must not inherit a decision that only makes sense for the surface it was made on. The registry's order encodes an editorial judgment about a grid layout. The sitemap projection dropped everything it did not need and was fine. The feed took the whole array including its opinions.
There is a second, smaller version of the same mismatch in there. lastBuildDate is new Date() on every request, so the body changes every hour even when no post has changed, which means every conditional request from every reader transfers all 54 KB again. The registry already carries a lastModified per post for the sitemap. The feed should be using max() of those instead of the clock, and for the same reason: a timestamp should describe the content, not the moment you asked for it.
Neither of these is shipped yet as I write this, so the feed linked above is still doing both. That is deliberate: it seemed more useful to publish the finding with the evidence still reproducible than to fix it quietly and describe it afterwards. Run the snippet and you will see a false.
Top comments (0)