Skip to main content
ASoc
Tutorial

An RSS Feed in the App Router, Without an RSS Library

One route file, force-static, and eight lines of helpers. RFC-822 dates, five escaped characters, and the absolute-URL bug that reached our production markup.

The ASoc Team10 min read

An RSS feed is a string. In the App Router it is one route file that returns XML and one line marking it static, so it prerenders alongside every other page and costs nothing per request. No library is involved, and the parts that need care are not the parts tutorials spend their words on: absolute URLs, RFC-822 dates, and five escaped characters.

Every result on this SERP installs the rss package first. That is a dependency, a transitive tree and an API to learn, in exchange for about twenty lines of template literal. Here is the whole feed this blog serves, and the three bugs the shape of it is designed to prevent — one of which reached our production markup before we caught it.

The whole route

The file lives at src/app/blog/feed.xml/route.ts, which makes its URL /blog/feed.xml. A route handler in a directory whose name ends in .xml is the App Router's way of serving a non-HTML document from a real path.

import { getAllPosts } from "@/lib/blog";
import { siteUrl } from "@/lib/siteUrl";

const BASE_URL = siteUrl();

// Prerendered at build time like the rest of the marketing site — the feed
// only changes when a post is added, which requires a deploy anyway.
export const dynamic = "force-static";

export function GET(): Response {
  const posts = getAllPosts();
  const updated = posts[0]?.date;

  const items = posts
    .map((post) => {
      const url = `${BASE_URL}/blog/${post.slug}`;
      return `    <item>
      <title>${xmlEscape(post.title)}</title>
      <link>${url}</link>
      <guid isPermaLink="true">${url}</guid>
      <description>${xmlEscape(post.description)}</description>
      <pubDate>${rfc822(post.updated ?? post.date)}</pubDate>
      ${post.tags.map((tag) => `<category>${xmlEscape(tag)}</category>`).join("\n      ")}
    </item>`;
    })
    .join("\n");

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>The ASoc Blog</title>
    <link>${BASE_URL}/blog</link>
    <description>Practical guides on building with Next.js and Tailwind CSS.</description>
    <language>en-us</language>
    <atom:link href="${BASE_URL}/blog/feed.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>${rfc822(updated)}</lastBuildDate>
${items}
  </channel>
</rss>
`;

  return new Response(xml, {
    headers: {
      "Content-Type": "application/rss+xml; charset=utf-8",
      "Cache-Control": "public, max-age=0, s-maxage=3600",
    },
  });
}

That is the entire feature. The two helpers are eight lines between them and are where the actual correctness lives.

force-static is the line that matters

Without it, a route handler is dynamic: every reader's poll runs a function. With it, the feed is generated once during next build and served as a file. In our build output it appears in the static column alongside robots.txt and sitemap.xml, which is the right company for it.

The condition for taking that trade is simple — the feed's content must change only at deploy time. That is true whenever posts are files in the repository, and false the moment posts come from a database an editor writes to. In the second case you want the default dynamic handler with a revalidation window, not force-static.

Note what the Cache-Control header is and is not doing here. The route is already a static file; the header tells a CDN how long it may serve that file without revalidating. It is belt and braces, not the thing that makes the feed cheap. s-maxage=3600 also sets reader expectations: an hour is polite for a blog that publishes in batches.

Dates: RSS 2.0 wants RFC-822, not ISO-8601

The single most common broken-feed cause, and it fails quietly — some readers parse an ISO date anyway, some show the epoch, some drop the item.

/** RFC-822 date, which is what RSS 2.0 requires (not ISO-8601). */
function rfc822(iso: string): string {
  return new Date(`${iso}T00:00:00Z`).toUTCString();
}

toUTCString() already produces the required format (Sat, 18 Aug 2026 00:00:00 GMT), so no formatting library is needed. Two details worth copying: build the Date from an explicit T00:00:00Z rather than the bare yyyy-mm-dd, so the value is not shifted by the build machine's timezone; and take post.updated ?? post.date, so a substantively revised article resurfaces in readers instead of sitting silently at its original position.

Escaping: five characters, no exceptions

function xmlEscape(value: string): string {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

Ampersand first — reverse the order and you double-escape everything that follows. Our post titles are developer-authored rather than user input, so this is a correctness guard rather than an injection defence, but the failure mode is total: one raw & in one title makes the entire feed unparseable in every reader. Not that item. The feed.

The alternative is <![CDATA[…]]> around each value, which handles everything except a literal ]]> in the content. Escaping is less exciting and has no edge case.

Absolute URLs, and the bug that taught us to centralise them

Every URL in a feed must be absolute. A reader has no page context to resolve /blog/post against, and some will show the raw path as broken.

That is easy. What is not easy is that the origin arrives from an environment variable, and a trailing slash in it produces https://host//blog/post. We shipped exactly that, and it did not show up in the feed first — it showed up in JSON-LD, where the doubled slash stopped matching the canonical URLs Google was crawling. The same value also feeds auth redirect allow-lists, which compare exactly.

So the origin is computed in one place, and every caller uses it:

export function siteUrl(fallback = "https://asoctemplates.com"): string {
  return (process.env.NEXT_PUBLIC_SITE_URL || fallback).replace(/\/+$/, "");
}

The || is deliberate and is not a style choice: with ??, an empty environment variable — the normal state of a misconfigured deploy — would be treated as a real value, and every absolute URL on the site would become root-relative. That distinction has its own post about which variables are decided at build time; the rule here is narrower. Build absolute URLs from one function, never by concatenating an env var at the call site.

Why metadata is separate from prose

The feed maps over a typed registry of post metadata — slug, title, description, date, tags — that lives apart from the MDX bodies. That split is what lets the feed, the index grid, the home teaser and the sitemap list fifty posts without compiling fifty articles, and it is why this route is a pure function with no await in it.

It also means the feed and the sitemap cannot disagree about what exists. They read the same array. When a post is added, three files must agree — registry entry, MDX file, loader entry — and the test suite fails if any one of them is missing, so the feed cannot advertise a post that 404s. The MDX-versus-CMS post covers that pipeline; the relevant consequence here is that a feed built from content files is a build artefact, and a feed built from a CMS is a query.

Full content or summaries?

We ship descriptions, not article bodies, and that is a defensible default rather than a laziness. Full-content feeds (<content:encoded> with the rendered HTML in CDATA) are genuinely better for readers and are what a reader-first publication should offer. They also require your rendered HTML to survive without your stylesheet, your components and your image pipeline — which, for articles built from MDX components with responsive images, means a second rendering path to maintain.

If you want full content, render the MDX to a string at build time and wrap it in CDATA. Expect to strip interactive components and rewrite relative image paths to absolute ones while you are there.

Announce it, or nobody finds it

Two lines, both easy to forget:

// In the blog index page's exported metadata:
alternates: {
  canonical: `${BASE_URL}/blog`,
  types: { "application/rss+xml": `${BASE_URL}/blog/feed.xml` },
}

That emits the <link rel="alternate"> tag reader extensions and some crawlers look for. Note the absolute URL again — the metadata API accepts a relative one, and a reader that resolves it against the wrong base gets nothing. Then put a visible link to the feed somewhere on the index, because the people most likely to subscribe are the least likely to be running an extension that guesses.

Mistakes and how they show up

MistakeSymptomFix
ISO-8601 in pubDateItems dated 1970, or dropped entirelytoUTCString() — RFC-822
Relative <link> URLsBroken links in every readerBuild from one absolute-origin helper
Trailing slash in the origin env varhttps://host//blog/x; canonicals stop matchingStrip it centrally, once
?? instead of `` on that env var
Escaping & lastDouble-escaped entities throughoutEscape & first
One unescaped characterThe whole feed fails to parse, not one itemEscape all five, always
No dynamic = "force-static"A function invocation per reader poll, foreverPrerender it; the content changes at deploy
Missing atom:link rel="self"Validators warn; some aggregators mis-handle the feedOne line in the channel
guid that changesEvery item reappears as new on each buildUse the permalink, and never rebuild it from a date
Feed and sitemap from different sourcesThey disagree; one advertises a 404Both read the same registry

Frequently asked questions

Should the file be /feed.xml or /blog/feed.xml? Whichever matches the scope of the feed. Ours covers the blog, so it lives under /blog. What matters more is that the URL never changes once published — subscribers hold it, and there is no redirect discovery in most readers.

Do I need the rss package? No. It saves you a template literal and costs you a dependency. It is worth considering only if you need multiple formats (RSS, Atom, JSON Feed) from one definition, which is the one thing it does that hand-writing does not.

Does an RSS feed help SEO? Not directly, and it is not a ranking signal. It helps distribution: aggregators, newsletter tools and some AI crawlers consume feeds, and a feed is the cheapest machine-readable index of your writing you can publish. Treat it as syndication, not optimisation.

How do I know it is valid? Run the built file through the W3C Feed Validation Service, then subscribe with two different readers. Validators catch structure; readers catch the things validators forgive, like dates that parse but sort wrongly.

What about a JSON Feed as well? Same route pattern, application/json, same registry. If you already have this file, the second format is twenty minutes. Whether anyone consumes it is a different question — publish RSS first.

Templates with a blog worth feeding

The three below are the content-shaped templates in the catalog — an editorial feed, a magazine layout and a copy-led marketing site. Each ships the article and index pages this route would syndicate, which is the part that takes longer than the feed.

Keep reading

Tutorial12 min read

Building a SaaS Landing Page in Next.js 16 That Loads Fast

Static rendering, LCP on the hero, and a small client bundle — plus the accessibility bugs our own Lighthouse audit caught that code review missed.

Read more
Tutorial11 min read

Shopping Cart in Next.js 16: Where Cart State Should Live

A cookie, a database row, or React Context? The three cart architectures compared, the add-to-cart Server Action, and the badge that quietly breaks static rendering.

Read more