Posts

How to set up an RSS feed with Next.js in a Nrwl NX monorepo

Last updated Dec 03, 2022
written byZach

Important NOTE

This post describes a flow for a blog that sources posts from a static directory (e.g. Github repo of markdown files). If your blog content is hosted on a CMS, you will need to modify this solution so that your feed file is updated each time a new post is added to your CMS. The overall strategy should resemble the one below.

Why an RSS feed?

RSS feeds are rarely used anymore, but can be great for app-to-app communication and automations. For example, I use a Github Action on my Github profile's README to generate a list of the last 5 blog posts that I have written on my site:

name: Latest blog post workflow
on:
  schedule: # Run workflow automatically
    - cron: '0 * * * *' # Runs every hour, on the hour
  workflow_dispatch: # Run workflow manually (without waiting for the cron to be called), through the GitHub Actions Workflow page directly

jobs:
  update-readme-with-blog:
    name: Update this repo's README with latest blog posts
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Pull in site posts
        uses: gautamkrishnar/blog-post-workflow@v1
        with:
          # 👇 Here's where I'm reading the RSS feed from my site
          feed_list: 'https://www.zachgollwitzer.com/rss.xml'

High level solution overview

An RSS feed is simply an .xml file hosted somewhere on your site that follows this general format:

<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">

<channel>
  <title>Your feed title</title>
  <link>https://yoursite.com</link>
  <description>Feed description</description>
  <item>
    <title>Feed item 1</title>
    <link>Feed item 1 URL</link>
    <description>Feed item 1 excerpt</description>
  </item>
  <item>
    ... more feed items go here ...
  </item>
</channel>
</rss>

Our goal is to generate a new version of this every time posts are added to our Next.js blog at the following url (could be any url):

https://yoursite.com/rss.xml

To do this, we will...

  1. Create a postbuild step for our NX "app" that dynamically fetches the last 10 posts (or feed items you want to curate). This runs directly after Next.js runs a build (i.e. next build)
  2. In postbuild script, fetch latest 10 posts and use the rss NPM module to generate an XML file from it.
  3. Write that file to /public/rss.xml (where Next.js hosts static assets)

Step 1: Wire up your postbuild step with nx

With Nrwl NX, you can have multiple apps in a single repository. These are stored in the /apps folder. Each app has separate "targets" that tell NX how to serve your app locally, build it, test it, and pretty much anything else you can think of.

For example, to build an app called /apps/blog, you'd run:

npx nx build blog

We can also write custom targets. In your workspace.json file, add the following:

 "postbuild": {
  "executor": "nx:run-commands",
  "options": {
    "commands": [
      {
        "command": "npx ts-node tools/scripts/generateRSSFeed.ts"
      }
    ]
  }
}

With this configuration, our target, postbuild will run a script stored in /tools/scripts/generateRSSFeed.ts with the command:

# Assumes your app is called "blog"
npx nx postbuild blog

Go ahead and create a file at that path and add some testing code to make sure things work okay.

// File: /tools/scripts/generateRSSFeed.ts
import fs from 'fs';
import path from 'path';

fs.writeFileSync(
  // In production build, assets are written to the /dist folder, so check ENV to know where to write this file
  path.join(
    process.cwd(),
    `${
      process.env.NODE_ENV === 'production' ? './dist/' : './'
    }apps/blog/public/rss.xml`
  ),
  'TEST FILE CONTENTS'
);

Now, run npx nx postbuild blog. This should create the RSS file and you should be able to visit it at yoursite.com/rss.xml. Once you get this working, onto the next step!

Step 2: Use the RSS module to generate an RSS xml file

First, install the rss module:

yarn add rss
yarn add -D @types/rss

Now, update your postbuild script (/tools/scripts/generateRSSFeed.ts):

import fs from 'fs';
import path from 'path';
import RSS from 'rss';

const feed = new RSS({
  title: 'Zach Gollwitzer Blog RSS Feed',
  description: 'Latest 10 posts from my blog',
  feed_url: 'https://www.zachgollwitzer.com/rss.xml',
  site_url: 'https://www.zachgollwitzer.com',
});

// Just a little TypeScript magic to extract the item option types since they aren't exported from the rss module (not necessary, just helpful)
type RSSItemOptions = Parameters<typeof feed['item']>[0];
type RSSPost = Pick<
  RSSItemOptions,
  'title' | 'date' | 'author' | 'description' | 'url'
>;

// PLACEHOLDER implementation - will update in next step
async function fetchPosts(): Promise<RSSPost[]> {
  return Promise.resolve([
    {
      title: 'Test post',
      date: new Date(),
      author: 'Zach Gollwitzer',
      description: 'Test description',
      url: 'www.zachgollwitzer.com',
    },
  ]);
}

// Fetch your posts and write the file
fetchPosts().then((posts) => {
  // Add an RSS item for each post
  posts.forEach((post) => feed.item(post));

  fs.writeFileSync(
    path.join(process.cwd(), './apps/blog/public/rss.xml'),
    feed.xml() // Writes the XML file
  );
});

Now run npx nx postbuild blog to test that everything is working okay!

Step 3: Finish your fetchPosts implementation

The final step is to finish your implementation of fetchPosts, an async function that reaches out to some data source and curates the last X posts (i'm using 10) published to your blog.

Here's an example implementation I have used. My blog posts are stored as markdown files in the /apps/blog/posts folder, but yours might be different (so adjust accordingly).

I am using the gray-matter NPM package to extract the metadata about each post. My markdown frontmatter looks like this:

---
title: Title
slug: slug
excerpt: >-
  Excerpt
published_date: '2022-12-03'
tags: [sample-tag]
category: sample-category
---
import matter from 'gray-matter';

// In my case, I did this synchronously, but if you're dealing with an external CMS, it will be an async function
function fetchPosts(): RSSPost[] {
  return (
    fs
      // Get all valid post filenames
      .readdirSync(path.join(process.cwd(), './apps/blog/posts'))
      .filter((path) => /\.mdx?$/.test(path))

      // For each filename, get the file contents, read the frontmatter, map to the RSSPost type
      .map((filename) => {
        const filePath = path.join(
          process.cwd(),
          './apps/blog/posts',
          filename
        );

        const fileContents = fs.readFileSync(filePath);
        const postMeta = matter(fileContents).data;

        return {
          title: postMeta.title,
          date: new Date(postMeta.published_date),
          author: 'Zach Gollwitzer',
          description: postMeta.excerpt,
          url: `https://www.zachgollwitzer.com/posts/${postMeta.slug}`,
        };
      })
      .sort((a, b) => b.date.valueOf() - a.date.valueOf()) // Sort newest => oldest
      .slice(0, 10); // Only grab first 10 posts
  );
}