Let Your Builder Build Your RSS Feed

I have a fundamental belief that you should ruthlessly automate drudgery. So when I decided to add an RSS feed to this blog, my first thought was, "How can I make this require zero ongoing effort?"

The web is full of tutorials that would have you spin up a full-blown server or subscribe to a SaaS platform to handle your RSS feed. This felt absurd for a site that is, by its very nature, static. The content only changes when I build and deploy. Why should the feed be any different?

It shouldn’t. The feed is an artifact of the build, just like a CSS bundle or a minified image. And if it's a build artifact, the builder should build it.


First, a Quick Refresher: What is RSS?

Before we dive in, let's freshen up what RSS even is. Standing for "Really Simple Syndication," RSS is a standardized XML file format that allows users to subscribe to content from a website. Think of it as a direct, chronological feed of your latest posts, free from algorithms or platform-specific whims. A user points an "aggregator" or "feed reader" app at your rss.xml file, and the app does the rest, pulling in new content as you publish it. It's a simple, robust, and open way to own your relationship with your readers.


The Right Tool for the Job: Vike's Build Hooks

This site is built with Vike.js, a framework that sits on top of Vite. While Vite handles asset bundling, Vike provides the structure for server-side rendering (SSR) and static-site generation (SSG). It's what lets me build a React app that ultimately compiles down to plain HTML files.

Fortunately, modern tools like Vike are designed for this kind of task. Vike provides an onPrerenderStart hook, which is just a fancy name for "a function I'll run for you once before I build all your pages." It's the perfect place to slot in our RSS generation logic.

The plan became clear and simple:

  1. At the start of the build, find every blog post.
  2. For each post, grab the metadata I've already written (title, description, date).
  3. Use a simple library like rss to format this data into an XML string.
  4. Save that string as rss.xml in the final output directory.

The result is a process that is fully automated, requires no runtime server, and adds practically nothing to my build time. It’s the kind of zero-bloat, "just works" solution I strive for.

Here’s a simplified version of the code that makes it happen, living in renderer/+onPrerenderStart.ts where Vike automatically picks it up:

// renderer/+onPrerenderStart.ts
import fs from 'node:fs';
import path from 'node:path';
import RSS from 'rss';
import type { Post } from '../pages/blog/types';

export const onPrerenderStart = async () => {
  const feed = new RSS({
    title: "Seva's blog",
    description: 'Blog posts about development, design, and other things.',
    site_url: 'https://seva.dev',
    feed_url: 'https://seva.dev/rss.xml',
    language: 'en',
    pubDate: new Date(),
    copyright: `${new Date().getFullYear()} Seva Maltsev`,
  });

  // Vite's import.meta.glob is the magic that finds all post configs statically.
  // https://vitejs.dev/guide/features.html#glob-import
  const postConfigs = import.meta.glob("../pages/blog/*/+config.ts", { eager: true });
  const posts = [];

  for (const configPath in postConfigs) {
    const module = postConfigs[configPath];
    if (!module || !module.postData || module.postData.hide) continue;

    const slug = configPath.replace("../pages/blog/", "").replace("/+config.ts", "");
    // Filter out special pages that aren't real posts
    if (['all', 'tags', 'template'].includes(slug)) continue;

    const postData = module.postData;

    posts.push({
      title: postData.title,
      description: postData.description,
      url: `https://seva.dev/blog/${slug}`,
      guid: `https://seva.dev/blog/${slug}`,
      date: new Date(postData.date),
      author: 'Seva Maltsev',
    });
  }

  // Sort by date and add each post to the feed
  posts.sort((a, b) => b.date.getTime() - a.date.getTime())
       .forEach(post => feed.item(post));

  // Write the final file. Done.
  const publicDir = path.resolve(process.cwd(), 'dist/client');
  fs.mkdirSync(publicDir, { recursive: true });
  fs.writeFileSync(path.join(publicDir, 'rss.xml'), feed.xml({ indent: true }));
};

And that's it. By letting the builder do the work, the problem stays solved. The feed is always up to date with the last deployed build, and I never have to think about it again. It's one less piece of drudgery, freeing up time for the work that actually matters.