Adding an RSS Feed to a Next.js Blog

In my last post, I talked a bit about migrating this blog over from Gatsby to Next.js. One thing I had to sort out in that migration was moving from the relatively-straightforward implementation of gatsby-plugin-feed to building a custom-rolled RSS feed.

It actually turned out to be a relatively painless process, so I thought I'd share how it works.

A Bit of Background

First, it's probably helpful to cover how I've got things set up. I write everything in markdown (mdx) and have it sitting in a content directory, alongside the src and public directories in my codebase.

Here's roughly the file structure we'll be working with in this tutorial:

blog/
├─ src/
│  ├─ pages/
│  │  ├─ api/
│  │  │  ├─ rss/
│  │  │  │  ├─ index.ts
│  │  │  │  └─ feed.json
├─ scripts/
│  └─ rss.mjs
├─ content/
│  └─ blog-post.mdx
└─ package.json

The way that this will end up working is that we will use a prebuild hook in our package.json to run a node script that will grab all of our posts and format them into a feed.json file. From there we'll create an API route that will use that json file to serve up our RSS feed. Lastly, we'll use our next.config.js file to add a rewrite, so that we can serve the RSS feed from /rss.xml.

The Node Script

This node script will be run on a prebuild hook. When we build our site to deploy, the script will run through the content/ directory, grab all of our posts loop through them and format them into a feed.json file that we can deploy alongside our API endpoint.

The primary reason for generating this JSON file is so that we don't have to do a bunch of file system reads when someone requests the RSS feed.

Here's the script in full:

// scripts/rss.mjs
import fs from 'fs';
import matter from 'gray-matter';
import path from 'path';

const generateRss = async () => {
  const POSTS_DIR = path.join(process.cwd(), 'content');
  const posts = fs
    .readdirSync(POSTS_DIR)
    .filter((filePath) => filePath !== '.DS_Store') // only needed for macOS
    .map((filePath) => {
      const source = fs.readFileSync(path.join(POSTS_DIR, filePath));
      const { content, data } = matter(source);

      return {
        content,
        data,
        filePath,
      };
    })
    .sort(
      (a, b) =>
        new Date(b.data.date).getTime() - new Date(a.data.date).getTime(),
    );

  // Create sitemap file
  fs.writeFileSync('src/pages/api/rss/feed.json', JSON.stringify(posts));
};

generateRss();

The way this works is that we're calling readdirSync on the directory that contains all of our mdx files to get an array of file names, then filtering out the .DS_Store file (only necessary for macOS), and then mapping over that list of files.

When we map over the array of file names, we then call readFileSync to read the individual file and use the gray-matter package to extract the frontmatter into an object, and return all of that data to make our posts variable an array of objects that contain all of our posts and their frontmatter.

Finally, we sort the posts by publish date and then call fs.writeFileSync to write a feed.json file into the pages/api/rss directory; where it'll sit next to the API endpoint handler.

The Prebuild Hook

Not much to this one. When you run npm run build, the prebuild hook will run before the build process begins. We'll use this hook to run the node script we just built.

{
  "scripts": {
    "generate:rss": "node scripts/rss.mjs",
    "prebuild": "npm run generate:rss"
  }
}

The API Route

This is the meat and potatoes of serving up the RSS feed. We're using a custom API route to run a lambda function that loads up the JSON file that we generated in the previous step, formats it into an XML document, and returns it back to the requester.

// src/pages/api/rss/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import nc from 'next-connect';

import { BlogPost } from 'lib/types';

import feed from './feed.json';

const metadata = {
  title: 'JonBellah.com',
  description: 'About my site!',
  link: 'https://jonbellah.com'
}

const handler = nc();

/**
 * Respond with an rss.xml
 *
 * @param {object} req NextApiRequest
 * @param {object} res NextApiResponse
 */
handler.get(async (req: NextApiRequest, res: NextApiResponse) => {
  try {
    const postItems = feed
      .map((page: BlogPost) => {
        const url = `${
          process.env.NEXT_PUBLIC_ROOT_URL
        }/articles/${page.filePath.replace('.mdx', '')}`;

        return `<item>
          <title>${page.data.title}</title>
          <link>${url}</link>
          <guid>${url}</guid>
          <pubDate>${page.data.date}</pubDate>
          ${
            page.data.excerpt &&
            `<description>${page.data.excerpt}</description>`
          }
          ${/**
           * NOTE: page.content on the next line should be wrapped with
           * the CDATA tag, but it breaks my _actual_ RSS feed if I
           * include it. Check the link to the source at the bottom
           * of this post for the actual code.
           */}
          <content:encoded>${page.content}</content:encoded>
        </item>`;
      })
      .join('');

    // Add urlSet to entire sitemap string
    const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
    <rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
      <channel>
      <title>${metadata.title}</title>
      <description>${metadata.description}</description>
      <link>${metadata.link}</link>
      <lastBuildDate>${feed[0].data.date}</lastBuildDate>
      ${postItems}
      </channel>
      </rss>`;

    // set response content header to xml
    res.setHeader('Content-Type', 'text/xml');

    return res.status(200).send(sitemap);
  } catch (e: unknown) {
    if (!(e instanceof Error)) {
      throw e;
    }

    return res.status(500).json({ error: e.message || '' });
  }
});

export default handler;

It's worth noting, too, that if you're using a CMS that has a REST API — like WordPress — you can fetch the list of posts in this lambda function and build your RSS feed on-the-fly. If your content is stored in an external CMS, you can skip the prebuild script and just use the API route.

Another thing worth noting is that the url variable within the postItems map assumes that the path for posts will be articles/{post-slug}. Be sure to change that to whatever path you prefer for your own site.

Configure the Next Rewrite

The final step to get this up and running is to use our next.config.js file to add a rewrite, so that we can serve the RSS feed from /rss.xml instead of /api/rss.

Feel free to make the source route whatever you prefer, especially if you're trying to preserve the route to an existing RSS feed.

// next.config.js
module.exports = {
  rewrites: async () => [
    {
      source: '/rss.xml',
      destination: '/api/rss',
    },
  ],
};

Now once you deploy your site to Vercel, you can visit yoursite.com/rss.xml and see your RSS feed!

Source

You can find the source code for this tutorial here.