Serve published blog excerpts as RSS XML with stable item IDs, safe escaping, feed discovery, and an explicit cache policy.

An RSS feed gives readers a way to follow your blog from their existing reader. In a Next.js application using BTST, a small Route Handler can turn published posts into RSS XML without rendering a React page.
This example targets the Next.js App Router and the Blog getter in @btst/stack@3.0.2. It assumes a public, single-site blog whose published posts are visible to everyone. If publication also depends on tenant membership or other policy, use an appropriately authorized query before building the feed.
getAllPosts(adapter, { published: true, limit: 20 }) returns an object with an items array. Its released implementation performs a direct database read and bypasses operation authorization and lifecycle hooks. The caller owns the public filtering decision.
The getter orders by createdAt descending. Therefore this feed selects the 20 newest-created published posts; it is not a query for the 20 most recently published posts. That distinction matters for backdated imports or drafts created long before publication. If you need publication-date ordering, implement that ordering in a bounded database query before applying the limit. Sorting an already limited page cannot recover missing posts.
Put this in app/feed.xml/route.ts. Replace the example origin, blog title, description, and /p/blog/ mount with your own values. adapter is your existing server database adapter, such as the exported adapter in a BTST stack module. Keep its credentials on the server.
import { getAllPosts } from "@btst/stack/plugins/blog/api";
import { adapter } from "@/lib/stack";
const origin = "https://example.com";
const blogURL = `${origin}/p/blog`;
function xml(value: string): string {
return value
.replace(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
type FeedPost = Awaited<ReturnType<typeof getAllPosts>>["items"][number];
function item(post: FeedPost): string {
const url = `${origin}/p/blog/${encodeURIComponent(post.slug)}`;
const date = new Date(post.publishedAt ?? post.createdAt);
const pubDate = Number.isNaN(date.getTime())
? ""
: `<pubDate>${date.toUTCString()}</pubDate>`;
return `<item>
<title>${xml(post.title)}</title>
<link>${xml(url)}</link>
<guid isPermaLink="false">${xml(`btst:post:${post.id}`)}</guid>
<description>${xml(xml(post.excerpt ?? ""))}</description>
${pubDate}
</item>`;
}
export async function GET() {
const { items } = await getAllPosts(adapter, {
published: true,
limit: 20,
});
const body = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Example Engineering Blog</title>
<link>${xml(blogURL)}</link>
<description>Engineering notes from Example.</description>
${items.map(item).join("\n")}
</channel>
</rss>`;
return new Response(body, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
The channel has a title, link, and description. Items use absolute article URLs and stable IDs so an ordinary title edit does not create a new feed identity. Dates use the RSS-compatible UTC string form. These fields follow the RSS 2.0 specification.
The XML helper escapes markup characters and removes characters XML 1.0 cannot represent. Descriptions are escaped twice deliberately: RSS descriptions can contain HTML, so the first pass treats the excerpt as plain text and the second encodes that text for XML. This keeps an excerpt containing <strong> or <script> from becoming active description markup in a reader. The feed uses excerpts, not raw Markdown or unsanitized full article HTML.
published: true is the visibility filter. A future publishedAt value alone does not make the Blog getter a scheduling system. Keep future articles unpublished until your publication process makes them public.
Add a visible RSS link near your blog navigation. In the existing Next.js metadata for the blog layout, merge an alternate feed entry while preserving other metadata:
import type { Metadata } from "next";
export const metadata: Metadata = {
alternates: {
types: {
"application/rss+xml": "https://example.com/feed.xml",
},
},
};
Next.js Route Handlers return a Response, so the feed route does not need a page component. Keep it in its own folder rather than placing a page.tsx at the same path.
Start with the uncached response above while validating the feed. If you later cache it, include /feed.xml in the publication, update, unpublish, and deletion invalidation workflow. A cached feed can retain an article after the ordinary blog page changes. Existing feed readers may also retain copies they already downloaded; unpublishing cannot recall those copies.
Check the response status and content type, then parse the XML and open it in a feed reader. Use fixtures containing ampersands, angle brackets, quotes, emoji, a missing excerpt, and a draft. Confirm the draft never enters the query result, article links resolve to canonical pages, and unpublishing removes the item from the next feed response.
The example's TypeScript and XML output were checked with focused fixtures. No feed endpoint is installed by reading this article; deploy and test the route in your own application. For related data selection and publishing behavior, see Blog search and pagination, drafts and caching, and the Blog documentation.