Building RSS feeds from Sanity.io data

Broad directions & tips on how to fetch data and render the feeds

I recently built a couple of RSS feeds - one for my own site (thanks to Ollie for the request) and another for my partner's. This is how I approached it.

Getting the right data

Each feed is going to vary according to their site's content structure. Start by identifying which document types you'll display and how their data translates into feed items' schema:

// Data format for items in the "feed" npm package (Typescript)
export interface Item {
    title: string;
    description?: string;
    id?: string;
    link: string;
    date: Date;
    content?: string;
    category?: Category[];
    guid?: string;
    image?: string | Enclosure;
    audio?: string | Enclosure;
    video?: string | Enclosure;
    enclosure?: Enclosure;
    author?: Author[];
    contributor?: Author[];
    published?: Date;
    copyright?: string;
    extensions?: Extension[];
}
Note

I'm using the npm package feed to generate my RSS to avoid having to get into the nitty gritty of XML and web feed specifications. There are plenty of alternatives if you're using other languages and runtimes, but I'll use its abstractions to explain my process.

At the very minimal, you'll need to add a title, date, and link to your entries. description is a nice addition, if possible.

Data-related tips:

  • Important: links must be absolute, including the HTTP protocol -> "https://hdoro.dev/my-post" instead of "/my-post"
  • id can be the same as link
  • If you have multiple document types, add a category to differentiate them
  • Have editor-defined publishedAt and/or updatedAt fields to allow controlling when readers get updates about each piece

From the decisions above, build a single GROQ query to get all the data you'll need for items and make sure it runs properly (if you aren't comfortable with it, my interactive article on GROQ may help!). Here's an example from Mari's site, which exposes case studies and blog posts in a single feed:

{		
  // You could also have separate sub-queries for each document type.
  // I have them in a single one for simplicity given they're very similar.
  "entries": *[
    _type in ["post", "caseStudy"] &&
    defined(slug.current) &&
    !(_id in path("drafts.**"))
  ]{
    _type,
    // Notice how I'm already massaging all the data in GROQ,
    // getting the most desirable value for each property.
    "publishedAt": coalesce(publishedAt, _createdAt),
    "updatedAt": coalesce(updatedAt, _updatedAt),
    "slug": slug.current,
    "image": coalesce(seo.ogImage, image, images[0]),
    "title": coalesce(seo.title, title),
    "description": coalesce(seo.description, subtitle),

    // type-specific values
    _type == "post" => {
      "category": "Articles",
    },
    _type == "caseStudy" => {
      "category": "Case Studies",
      services,
    },
  }[]|order(updatedAt desc), // finish by ordering them

  // Global settings for the feed configuration (see below)
  "settings": *[_id == "settings"][0]{
    ${SETTINGS_FRAGMENT}
  },  
}

Data for the feed configuration

Aside from specific items, you'll also need to add general information to your feed, such as its title, description, language, copyright, etc.

Some of this information will be inherently static (such as favicon, the root link, and copyright), but others should probably come from Sanity itself to allow more control to editors (title, description could be updated, for example).

For this, I highly recommend creating a global settings document and querying it in your GROQ query (as I'm doing in the code above).

Decision: full text or not?

You could add the full content of each entry to your feed, which many readers prefer as it allows them to do all their reading in one app without having to visit each source website.

Personally, I'd rather have people read on my own website for various reasons - design & vibe, branding, owning the medium, opening up for dynamic elements in the middle of written content, etc.

Whatever you choose, keep in mind that providing the full content will require you to convert PortableText data, with all of your custom block types, annotations and styles, to plain text or HTML.

This can be a time-intensive if you use front-end frameworks such as React & Svelte, as you won't be able to use your component code without first using the framework's rendering API directly to get the HTML. Then we start getting into bundling issues and other complications that make the process sour very quickly.

Once you're comfortable with the data format you're getting, let's generate the actual feed!

Generating the feed

As I mentioned above, the npm package feed takes care of generating the XML for me. I've used it both in SvelteKit and in NextJS, and the approach is pretty much the same:

  1. Create an API endpoint
  2. Fetch Sanity.io data according to what you require in the step above
  3. Configure the feed
  4. Add each entry to it
  5. Export to XML
  6. Finish the request with the proper headers

Here's the simplified code for a SvelteKit GET route, properly commented for the most important bits:

export const get = async () => {
  // 1. ===== FETCHING DATA =====
  // See the query in the section above
  const { settings, entries } = await sanityServerClient.fetch(QUERY);

  // 2. ===== CONFIGURING FEED =====
  const author = {
    name: "Mari Moraes",
    email: settings.email,
    link: BASE_URL,
  };
  const feed = new Feed({
    title: "Marifulness",
    description: settings.feedDescription,
    id: slugToAbsUrl("/"),
    link: slugToAbsUrl("/"),
    language: "en",
    // Will get to safe image in a bit!
    image: getSafeImage(settings.ogImage),
    favicon: BASE_URL + "/favicons/apple-icon-72x72.png",
    copyright: `All rights reserved ${new Date().getFullYear()}, Mari Moraes`,
    updated: entries?.[0] ? new Date(entries[0]?.updatedAt) : undefined,
    author,
  });

  // 3. ===== ADDING ENTRIES =====
  entries.forEach((entry) => {
    const url = slugToAbsUrl(getDocumentPath(entry));
    feed.addItem({
      title: entry.title,
      description:
        entry.description ||
        // alternative description for case studies
        new (Intl as any).ListFormat("en").format(entry.services || []),
      published: new Date(entry.publishedAt),
      id: url,
      link: url,
      date: new Date(entry.publishedAt),
      image: getSafeImage(entry.image),
      author: [author],
      category: [
        {
          name: entry.category,
        },
      ],
    });
  });

  // 4. ===== GENERATING THE XML =====
  const xml = feed.rss2();
  return {
    status: 200,
    body: xml,
    headers: {
      "Content-Type": "application/xml",
    },
  };
};

Beware of images

For some reason, XML is picky about its URLs and apparently can't handle query params (not 100% of this!). As Sanity images depend on query parameters to define their width, height, cropping, etc., we need to sanitize them before adding to the feed. Here's how I'm doing it:

/**
 * XML is picky about its URLs and can't handle query params.
 * As our images require those, we need to encode them first
 */
function getSafeImage(image?: SanityImageRef) {
	try {
		const imageUrl = image
			? imageBuilder.image(image).fit('max').auto('format').maxWidth(1200).url()
			: undefined;
		return `${imageUrl.split('?')[0]}?${encodeURIComponent(imageUrl.split('?')[1])}`;
	} catch (error) {
		return undefined;
	}
}

The route code above has a few examples of how I use this getSafeImage function 😉

Style your feed

By default, RSS feeds are only readable to machines. If you want to make it also readable by humans, you could follow Matt Webb's AboutFeeds template and style it. Here's how I'm doing it from my adapted XSL stylesheet:

// Add the stylesheet attribute right below the first line of the XML
const FIRST_LINE = `<?xml version="1.0" encoding="utf-8"?>`
const xml = feed
  .rss2()
  .replace(
    FIRST_LINE,
    FIRST_LINE +
      '<?xml-stylesheet href="/rss-styles.xsl" type="text/xsl"?>',
  )
res.status(200).setHeader('Content-Type', 'application/xml').send(xml)

Publishing your feed

Now that you have the proper feed being generated, make sure to add it to your website and other mediums you want to advertise it on. Suggestions:

  1. Add a link to it from the website's footer
  2. Add a link tag to your feed from the HTML's head
    1. Example: <link rel="alternate" type="application/rss+xml" href="https://hdoro.dev/feed.xml">

That's it! Hope you find this useful :)