Lachlan's avatar@lachlanjc/notebook

Set up RSS with Contentlayer & MDX

I recently switched this site off Gatsby (finally!) to Next.js & Contentlayer. Afterwards, I wanted to get an RSS feed set up—a long-overdue feature, considering I’m a heavy RSS reader personally.

For my first implementation, I referred to Lee Robinson’s site codebase to get the general structure of using a serverless function with the rss package as a Next.js page. Contentlayer made pulling in feed items super easy via their generated indices.

Converting MDX source to plain text

Since this site has no concept of a post summary/description, I wanted to extract the beginning of the post content. One of Contentlayer’s excellent features is built-in MDX support (without setting up a bunch of packages & Next.js plugins), which works great for the site itself, but since RSS feeds need machine-renderable HTML text or human-readable plain text, neither the MDX source or rendered JS are desirable.

I decided to distribute exclusively the first paragraph of text as simple plain text, then readers can open the site to view the rest. (I’m used to this workflow with many other feeds. The full MDX source could be rendered as HTML for a superior RSS reading experience, but that’s a larger project.)

Contentlayer provided easy access to the MDX source text, then with a quick & dirty integration with this (untyped/unmaintained) Remark plugin from Jared Sumner, & got my function generating post previews immediately:

import { remark } from 'remark'
import stripMdx from 'remark-mdx-to-plain-text'
const lineStartingWithCapitalLetter = /^[A-Z]/
await remark()
.filter(text => text.match(lineStartingWithCapitalLetter))?.[0],
.then(file => String(file).trim()),

The feed looked fine as XML, so I shipped it after a quick test with an RSS client1.

Do you need a serverless function?

(Contentlayer cannot work with Next.js Edge Functions since it relies on fs under the hood, which the Edge Runtime doesn’t support.)

I quickly realized any type of dynamic route was unnecessary: since pages are Git-tracked MDX files, the feed can be regenerated merely at deploy time, not per-request. I referred to Lee Robinson’s (somewhat-outdated) post on building Next.js sitemaps for the build script setup, moved my RSS API Route to a .mjs file, & it worked seamlessly with much less code. Contentlayer is fast: the script took a mere 0.4s to run locally & didn’t change my Vercel build time at all. Serving the RSS file from Vercel’s CDN is instantaneous as well, with no added traffic costs.

My setup added this postbuild script in package.json:

node --experimental-json-modules ./lib/rss.mjs

(The JSON modules flag I found necessary for Contentlayer’s index to work with ES modules.)

Then here’s the full build script:


import RSS from 'rss'
import { allSheets } from '../.contentlayer/generated/Sheet/_index.mjs'
import { remark } from 'remark'
import strip from 'remark-mdx-to-plain-text'
import { writeFileSync } from 'fs'
async function generateFeed() {
const feed = new RSS({
title: '@lachlanjc/notebook',
site_url: '',
feed_url: '',
image_url: '',
language: 'en_US',
const capitalLetter = /^[A-Z]/
await Promise.all(
.filter(sheet => != null)
.map(async sheet => {
url: `${sheet.slug}`,
guid: sheet.slug,
description: await remark()
.filter(text => text.match(capitalLetter))?.[0],
.then(file => String(file).trim()),
const body = feed.xml({ indent: true })
writeFileSync('./.next/static/feed.xml', body)

Finally, I added a rewrite to next.config.(m)js for the prettier URL:


async rewrites() {
return [
{ source: '/feed.xml', destination: '/_next/static/feed.xml' },

If you want to subscribe to this site, the feed is right here, as promised. Happy RSSing!


  1. I’d recommend NetNewsWire as an RSS client if you’re looking for a fantastic free one; I personally use Reeder 5 for its excellent typography & gestural interactions.