RSS is still one of the easiest ways to syndicate your writing.
Feed readers, aggregators, and automation tools all still rely on it in 2026. If you run a developer blog, RSS is low effort and high leverage.
The catch: static React Router sites don’t have a CMS or database to query. On hardcopy.dev, posts live in posts/*.mdx, so the feed has to be generated from files at build time.
This is exactly how I wired it up.
Why This Is Tricky on a Static Site
In a typical CMS setup, RSS is just another API response over your posts table.
On a static site, there is no posts table. The source of truth is frontmatter in Markdown/MDX files. That means your RSS feed has to:
- Read post files
- Parse frontmatter
- Filter drafts
- Sort by date
- Render valid XML
In this project, that starts with the existing getAllPosts() helper in app/lib/posts.ts, which already parses frontmatter and excludes published: false posts.
Generating RSS XML in a Route Loader
I created a dedicated route at app/routes/rss[.]xml.tsx. It pulls post metadata, escapes XML-sensitive characters, and returns an RSS 2.0 response.
import type { LoaderFunction } from "react-router";
import { getAllPosts } from "../lib/posts";
function escapeXml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function formatRssDate(value?: string): string {
if (!value) return new Date().toUTCString();
const date = new Date(value);
return Number.isNaN(date.getTime()) ? new Date().toUTCString() : date.toUTCString();
}
export const loader: LoaderFunction = async () => {
const baseUrl = process.env.SITE_URL || "https://hardcopy.dev";
const posts = await getAllPosts();
const latestDate = posts[0]?.frontmatter.date;
const items = posts
.map((post) => {
const title = escapeXml(post.frontmatter.title ?? post.slug);
const description = escapeXml(post.frontmatter.description ?? "");
const link = `${baseUrl}/post/${encodeURIComponent(post.slug)}`;
const pubDate = formatRssDate(post.frontmatter.date);
return `<item>
<title>${title}</title>
<link>${link}</link>
<guid>${link}</guid>
<description>${description}</description>
<pubDate>${pubDate}</pubDate>
</item>`;
})
.join("\n");
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Hardcopy</title>
<link>${baseUrl}</link>
<description>Professional blog and portfolio of Chris Child</description>
<language>en-us</language>
<lastBuildDate>${formatRssDate(latestDate)}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
};
This is dynamic at route level, but because the site is prerendered, react-router build emits a static rss.xml asset in build/client.
Wiring the Feed Route
In this repo, route wiring is explicit in app/routes.ts:
route("rss.xml", "routes/rss[.]xml.tsx")
I also link the feed in app/root.tsx so browsers/readers can autodiscover it:
{ rel: "alternate", type: "application/rss+xml", title: "Hardcopy RSS Feed", href: "/rss.xml" }
And I include /rss.xml in the sitemap route (app/routes/sitemap[.]xml.tsx) so crawlers can find it quickly.
Staying in Sync With Posts and Sitemap
The key is reusing the same post source everywhere.
- Blog index uses
getAllPosts() - Sitemap uses
getAllPosts() - RSS uses
getAllPosts()
That keeps sorting, draft filtering, and metadata handling consistent across all three outputs.
Edge Cases Worth Handling
- Draft posts: filtered out via
published: false - Ordering: sorted newest-first by date in
getAllPosts() - Encoding: escape
&,<,>, quotes, and apostrophes in XML fields - Missing/invalid dates: fallback to
new Date().toUTCString()
Validation and Real-World Testing
After generating the feed, validate it:
- Build the site:
npm run build - Open your feed URL (for me:
https://hardcopy.dev/rss.xml) - Run it through the W3C Feed Validator
Then test it in a real reader. Validation catches spec issues, but readers catch practical ones (truncated descriptions, odd date handling, weird clients).
What This Unlocks
Once RSS is in place, you can plug your blog into:
- Feed readers
- Aggregators
- IFTTT/Zapier workflows
- Newsletter pipelines that ingest RSS
No CMS required.
If your stack is “React Router + Markdown + static build,” RSS is mostly a metadata and XML formatting problem. Keep it simple, keep it deterministic, and validate it once before shipping.