Adding RSS to a Static Remix/React Router Site Without a CMS

Chris Child | 2026-04-12 | 4 min read

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:

  1. Read post files
  2. Parse frontmatter
  3. Filter drafts
  4. Sort by date
  5. 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("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&apos;");
}

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:

  1. Build the site: npm run build
  2. Open your feed URL (for me: https://hardcopy.dev/rss.xml)
  3. 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.

References

Comments

Related Posts

Setting Up Gatsby

Chris Child | 2020-11-12 | 1 min readComments

I am moving from a Wordpress site and converting all my old posts. Which is not that many! I chose Gatsby as I have done a lot of work with React over the past few years. The whole install process...