Recently I upgraded my website from Nuxt 2 to Nuxt 3 and I've been crossing a few things off my list of features and content to add. Specifically, I’ve been wanting to set up a proper RSS feed for my blog articles, but hadn’t taken the time to figure out how to do it with Node or Nuxt. I finally got around to rectifying this and I got a chance to play around with another feature in Nuxt 3 in the process: Nuxt Server Routes.

My site is currently driven by Craft 4 as my CMS with Nuxt 3 on the front end. While this article is Nuxt 3-specific, a lot of the concepts around Craft CMS can be applied to any headless CMS or GraphQL data provider.

Article Structure

Articles on my site, like the one you are currently reading, are grouped by category in Craft via different entry sections. Each section determines the article’s URL and gives me the flexibility to create templates for SEO or field sets based on each section.

Right now I have a “code” section and a “maker” section. As you can see on this post, the URL contains the segment, /code/, that is used to match up my front-end routes with my article categories.

In Craft’s GraphQL implementation each section handle—in this case “codeArticle” and “makerArticle”—is used as part of the schema definition for all of the fields that make up my article content.

These handles and URL segments will be important later.

Nuxt Approach

In Nuxt 2, my typical approach around adding things like XML files to the project would normally be to use some sort of Node script that pulls in my articles, generates the file, then writes it out to disk. I wanted to see if Nuxt 3 had a built-in tool that would work and I came across this article, Create an RSS Feed With Nuxt 3 and Nuxt Content. For a general RSS feed, this article is all you need (especially if you’re using Nuxt Content).

This article pretty much lays out the approach I went with, including pulling in the NPM package, rss, as the main tool used to generate RSS XML content. I wanted to create a main RSS feed, as well as individual feeds for each category, so I abstracted this out a bit and made a few changes to work with my CMS fields.

Working in Nuxt

So in following Michael’s article, I added in my default RSS feed as a TypeScript file at ./server/routes/rss/index.ts. Unlike Michael’s setup I wanted my feed to appear at https://wbrowar.com/rss/, so I removed the .xml file extension from my filename.

./server/routes/rss/index.ts
import RSS from 'rss'
import { ofetch } from 'ofetch'
import { rssIndexGql } from '~/gql/rssGql'
import { articleBodyBlocks, feedOptions } from '~/utils/rss'

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()

  const feed = new RSS(feedOptions({ uri: '/' }))

  const entries = await ofetch(config.graphqlEndpoint, {
    body: { query: rssIndexGql },
    headers: { Authorization: `Bearer ${config.graphqlAccessToken}` },
    method: 'POST'
  })

  for (const entry of entries?.data?.entries) {
    feed.item({
      title: entry.title ?? '-',
      url: `https://wbrowar.com/${entry.uri}`,
      date: entry.dateCreated,
      description: articleBodyBlocks({ blocks: entry.articleBody ?? [] })
    })
  }

  const feedString = feed.xml({ indent: true })
  event.res.setHeader('content-type', 'text/xml')
  event.res.end(feedString)
})

In this file, I am creating a server route, using defineEventHandler, setting up the feed’s global settings, querying all of my articles, populating the feed, then writing out the XML file contents using event.res.end().

There are a few things in here that are abstracted out to other files:

  • rssIndexGql is the GraphQL query that pulls in all of my article content for all of my article sections.
  • feedOptions sets up globals for my RSS feed. Things like the site URL, copyright, etc...
  • articleBodyBlocks generates the content for each article in the feed, based on the Craft CMS matrix field that I use to build out each article.

Index GraphQL Query

As a habit, I keep all of my GraphQL queries stored as files in a gql directory at the root of my Nuxt project. I keep these as TypeScript files so I can manipulate the query based on the particular situation. These files export out my queries as plain strings that are passed along via my API request.

For my RSS feed, I copied the GraphQL query that I use for my Nuxt templates and stripped out a lot of the layout fields or other fields that aren’t needed in an RSS reader. From there I created a file, called rssGql.ts:

./gql/rssGql.ts
/*
 * Test using `codeArticle`, then swap the section handle out
 */

const elementGql = ({ handle = 'codeArticle' }) => {
  return `
... on codeArticle_codeArticle_Entry {
  articleBody {
    __typename
    ... on articleBody_code_BlockType {
      fileName
      code
      languageName
      caption
    }
    ... on articleBody_image_BlockType {
      file {
        title
        ... on codeArticleImages_Asset {
          caption
          ioArticleBodyUnconstrained {
            srcset
          }
        }
      }
    }
    ... on articleBody_markdown_BlockType {
      subheader
      text @markdown(flavor: "gfm-comment")
    }
    ... on articleBody_spacer_BlockType {
      hr
    }
    ... on articleBody_subheader_BlockType {
      subheader
    }
    ... on articleBody_video_BlockType {
      youtubeId
      vimeoId
      file {
        ... on codeArticleImages_Asset {
          url
        }
      }
    }
  }
}`.replace(/codeArticle/g, handle)
}

export const rssIndexGql = `{
  entries(section: ["codeArticle", "makerArticle"]) {
    dateCreated
    title
    uri
    ${elementGql({ handle: 'codeArticle' })}
    ${elementGql({ handle: 'makerArticle' })}
  }
}`

In this file, I’ve broken my query into two parts:

  • elementGql is a method that returns a portion of the query for all of the fields for each section. It uses a simple string replacement regex to swap out the section handle. If I didn’t do it this way, I’d repeat all of the same fields for each section, but that would require a little extra work any time I wanted to make adjustments. Part of what makes this work is to be consistent in my field handle naming.
  • rssIndexGql sets up an entries query, gets the title and other general entry data, and inserts the elementGql partial for each article section.

Utils

To help reduce duplication in my sever route files, I created a utils file at ./utils/rss.ts to house a couple of functions that help generate my feed markup.

Some global options I want to include in all of my feeds are set in the feedOptions method:

./utils/rss.ts
/*
 * Fill in feed options and adjust based on URL
 */
export function feedOptions ({ uri = '/' }) {
  const now = new Date()

  return {
    copyright: `©️${now.getFullYear()} William Browar`,
    feed_url: `https://wbrowar.com/rss${uri}`,
    language: 'en',
    managingEditor: 'Will Browar',
    webMaster: 'Will Browar',
    image_url: 'https://wbrowar.com/logo.png',
    site_url: 'https://wbrowar.com',
    title: 'Will Browar'
  }
}

Doing this here will make it easier if I decide to make a change to any of these options. The only thing that needs to be passed in is the URI of the feed. The result of this method is used when creating a new rss instance with new RSS(feedOptions({ uri: '/' })).

For each article in my feed, I need to pull out all of the matrix blocks that make up each article’s content. To do this, I created the articleBodyBlocks method.

./utils/rss.ts
import escape from 'lodash-es/escape.js'
import { ArticleBody_MatrixField, MakerArticleImages_Asset } from '~/types/automated/generated-craft'

/*
 * Generate markup for all articleBody blocks
 */
export function articleBodyBlocks ({ blocks = [] }: {blocks: ArticleBody_MatrixField[]}) {
  const articleBody = ['']

  blocks.forEach((block) => {
    switch (block.__typename) {
      case 'articleBody_code_BlockType':
        articleBody.push(`<code><pre>${escape(block.code ?? '')}</pre></code>`)
        break
      case 'articleBody_image_BlockType':
        (block.file as MakerArticleImages_Asset[]).forEach((image) => {
          if (image) {
            const caption = image.caption ? `<figcaption>${image.caption}</figcaption>` : ''
            articleBody.push(`<figure><img srcset="${image.ioArticleBodyUnconstrained?.srcset ?? ''}" alt="${image.title ?? ''}">${caption}</figure>`)
          }
        })
        break
      case 'articleBody_markdown_BlockType':
        if (block.subheader ?? false) {
          articleBody.push(`<h2>${block.subheader ?? ''}</h2>`)
        }
        articleBody.push(block.text ?? '')
        break
      case 'articleBody_spacer_BlockType':
        if (block.hr ?? false) {
          articleBody.push('<hr>')
        }
        break
      case 'articleBody_subheader_BlockType':
        articleBody.push(`<h2>${block.subheader ?? ''}</h2>`)
        break
      case 'articleBody_video_BlockType':
        if (block.youtubeId) {
          articleBody.push(`<iframe allowfullscreen frameborder="0" src="https://www.youtube.com/embed/${block.youtubeId}?rel=0&amp;controls=0&amp;showinfo=0">`)
        } else if (block.vimeoId) {
          articleBody.push(`<iframe allowfullscreen mozallowfullscreen webkitallowfullscreen frameborder="0" src="https://player.vimeo.com/video/${block.vimeoId}?title=0&byline=0&portrait=0">`)
        } else if (block.file?.[0]) {
          articleBody.push(`<video src="${block.file[0].url}"></video>`)
        }
        break
    }
  })

  return articleBody.join('')
}

First, an array is set as articleBody. All of my feed content will get added to this array, string by string, then the array will get joined together into one big string HTML markup.

This file iterates through each matrix block and it uses the __typename field to figure out what kind of matrix block is being rendered. Based on the type of block some other logic might be needed and the result is more string data getting pushed into the articleBody array.

A couple of things to note here:

  • I'm using a package, called @graphql-codegen/typescript, that generates TypeScript types from my GraphQL schema. It does a great job with Craft CMS fields and by using the __typename I am able to make sure each matrix field type is matched up with the generated types.
  • This is all set up to be additive, as opposed to using something like Array.map on my matrix blocks. This way I don’t necessarily need to render out each of my block types if there's not a good reason to in an RSS feed.

Making Multiple Feeds

So far, I’ve got my main RSS feed all set up and outputting all of the content for all of my articles. In case someone wanted to only subscribe to one of the article categories I'll need to make a few changes to make that happen.

Currently, I have my main feed in a file at ./server/routes/rss/index.ts. Because Nuxt Server Route files can use variables in their names, I only need to add one more file that takes care of both of my article categories. I added a file at ./server/routes/rss/[articleCategory].ts and the server directory now looks like this:

| server/
--| routes/
----| rss/
------| index.ts
------| [articleCategory].ts

This file is almost the same as index.ts:

./server/routes/rss/[articleCategory].ts
import RSS from 'rss'
import { ofetch } from 'ofetch'
import { rssArticleCategoryGql } from '~/gql/rssGql'
import { articleBodyBlocks, feedOptions } from '~/utils/rss'

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const articleCategory = event.context.params.articleCategory

  const category = {
    sectionHandle: ''
  }

  if (articleCategory === 'code') {
    category.sectionHandle = 'codeArticle'
  } else if (articleCategory === 'maker') {
    category.sectionHandle = 'makerArticle'
  }

  const feed = new RSS(feedOptions({ uri: `/${articleCategory}/` }))

  const entries = await ofetch(config.graphqlEndpoint, {
    body: { query: rssArticleCategoryGql(category.sectionHandle) },
    headers: { Authorization: `Bearer ${config.graphqlAccessToken}` },
    method: 'POST'
  })

  for (const entry of entries?.data?.entries) {
    feed.item({
      title: entry.title ?? '-',
      url: `https://wbrowar.com/${entry.uri}`,
      date: entry.dateCreated,
      description: articleBodyBlocks({ blocks: entry.articleBody ?? [] })
    })
  }

  const feedString = feed.xml({ indent: true })
  event.res.setHeader('content-type', 'text/xml')
  event.res.end(feedString)
})
  1. event.context.params.articleCategory gets the category from the URL.
  2. The category sets a variable that sets my section handles.
  3. The RSS feed is set up with my options and the correct URI.
  4. My articles are retrieved using a rssArticleCategoryGql method, instead of the previously used rssIndexGql.
  5. Just like the root RSS file, each article is generated and then the file XML is returned.

To query the entries for only one article category I added a new method, called rssArticleCategoryGql, to my ./gql/rssGql.ts file:

./gql/rssGql.ts
export function rssArticleCategoryGql (handle = 'codeArticle') {
  return `{
  entries(section: ["codeArticle"]) {
    dateCreated
    title
    uri
    ${elementGql({ handle })}
  }
}`.replace(/codeArticle/g, handle)
}

This is almost the same as rssIndexGql, but this also does the regex replacement for the section handle.

Fin

Just like Michael Hoffmann recommends, I added all of these RSS feed URLs to my list of pages to prerender.

I also added my feed URLs to my app.vue file so they can be linked across all of the pages in my site:

app.vue
<Head>
  <Link
    rel="alternate"
    type="application/rss+xml"
    title="RSS Feed for wbrowar.com"
    href="/rss/"
  />
  <Link
    rel="alternate"
    type="application/rss+xml"
    title="RSS Feed for wbrowar.com"
    href="/rss/code/"
  />
  <Link
    rel="alternate"
    type="application/rss+xml"
    title="RSS Feed for wbrowar.com"
    href="/rss/maker/"
  />
</Head>

Now I have the following RSS feeds:

https://wbrowar.com/rss/ https://wbrowar.com/rss/code/ https://wbrowar.com/rss/maker/

With this setup, adding more article categories in the future should be pretty painless. This setup also makes it an easy update when I decide to add a new matrix field to my Article Body field.

My TODO list: ☑️ RSS Feeds