Skip to content

RSS, Atom, and JSON feeds#

View Markdown

When feeds is enabled and ssg.siteUrl is set, the SSG build writes machine-readable feeds from a named collection:

  • feed.xml — RSS 2.0
  • atom.xml — Atom 1.0
  • feed.json — JSON Feed 1.1

The feature is off unless you turn it on. Existing sites stay unchanged.

import { oxContent } from "@ox-content/vite-plugin";

export default {
  plugins: [
    oxContent({
      feeds: true,
      ssg: {
        siteUrl: "https://example.com",
      },
    }),
  ],
};

false or omitted keeps the files off. true enables the defaults: all three formats, the content collection (or the first configured collection), and a 20-item limit. A single object is one default feed and overrides only the fields you set:

oxContent({
  feeds: {
    formats: ["rss", "json"],
    collection: "blog",
    limit: 10,
    path: "/feeds",
  },
  ssg: {
    siteUrl: "https://example.com",
  },
});

A named record or array writes multiple feeds. Each channel can set its own collection, path, formats, and metadata:

oxContent({
  feeds: {
    blog: {
      formats: ["rss"],
      collection: "blog",
      path: "/",
      title: "blog | example.com",
      description: "Technical articles",
      language: "en",
      image: "https://example.com/icon.png",
      favicon: "https://example.com/icon.png",
      copyright: "© 2026 example.com",
    },
    media: {
      formats: ["rss"],
      collection: "media",
      path: "/works/media",
      title: "Media | example.com",
      language: "ja",
    },
  },
  ssg: {
    siteUrl: "https://example.com",
  },
});

Programmatic items#

A channel can set items instead of collection when the feed source is a JSON file, database result, or another curated build-time source. The resolver runs during SSG and may return a promise:

import media from "./src/contents/external-rss/media.json";

oxContent({
  feeds: {
    media: {
      formats: ["rss"],
      path: "/works/media",
      title: "Media | ryoppippi.com",
      items: async () =>
        media
          .filter((item) => !item.playlist)
          .map((item) => ({
            title: item.title,
            url: item.link,
            id: `media:${item.link}`,
            date: item.pubDate,
            description: `${item.kind === "podcast" ? "Podcast" : "YouTube"} | ${item.title}`,
            author: { name: "ryoppippi", url: "https://ryoppippi.com" },
            language: item.lang,
          })),
    },
  },
  ssg: {
    siteUrl: "https://ryoppippi.com",
  },
});

Set either collection or items on a channel. Supplying both is rejected. Programmatic items support title, url or loc, optional id, date, description, content, author / authors, image, attachments, and language. The RSS, Atom, and JSON Feed renderers emit the fields that each format supports.

Custom hosts that already load feed data while planning routes can return that data from the normally build-only outputs(ctx) lifecycle instead of reloading it from feeds.items. Return default items, named collections, and optional collectionNames; Ox Content still uses the same writeFeedFiles() path and publish-state filtering. See Custom host lifecycle. Set dev.feedOutputs: true on oxContentCustomHost() to serve those coordinated feed files during Vite development without adding duplicate feed routes.

Custom dev servers#

Use renderFeedFiles() when a custom Vite middleware or dev server wants the same feed bytes without writing temporary files. It accepts the same resolved feed options, collection data, publish-state filtering, base, and SSG site metadata as writeFeedFiles(). Each result has a safe site-relative path, a contentType, and the serialized content.

import type { Plugin } from "vite";
import { renderFeedFiles, resolveFeedsOptions } from "@ox-content/vite-plugin";

const feeds = resolveFeedsOptions({
  media: {
    formats: ["rss", "atom", "json"],
    path: "/works/media",
    title: "Media | ryoppippi.com",
    items: async () => [
      {
        title: "Guest appearance",
        url: "https://media.example.com/episode",
        date: "2026-08-01",
        author: { name: "ryoppippi", url: "https://ryoppippi.com" },
      },
    ],
  },
});

export function feedMiddleware(): Plugin {
  return {
    name: "site-feeds",
    configureServer(server) {
      server.middlewares.use(async (req, res, next) => {
        const rendered = await renderFeedFiles({
          options: feeds,
          siteUrl: "https://ryoppippi.com",
          siteName: "ryoppippi.com",
          base: "/",
        });
        if (rendered.warning) {
          server.config.logger.warn(rendered.warning);
          return next();
        }

        const requestPath = new URL(req.url ?? "/", "http://localhost").pathname.replace(
          /^\/+/,
          "",
        );
        const file = rendered.files.find((candidate) => candidate.path === requestPath);
        if (!file) {
          return next();
        }

        res.statusCode = 200;
        res.setHeader("Content-Type", file.contentType);
        res.end(file.content);
      });
    },
  };
}

Unsafe paths, invalid site URLs, and duplicate output paths fail with the same warnings as writeFeedFiles().

Option Type Default
feeds boolean / one feed / named record / array false
formats ("rss" | "atom" | "json")[] ["rss", "atom", "json"]
collection string content, else the first collection
items FeedItemInput[] / async resolver omitted
limit number 20
path string / (site root)
title string SSG site name
description string SSG site description
language string omitted
image string omitted
favicon string omitted
copyright string omitted

path is the site-relative directory for the generated files. /feeds writes feeds/feed.xml, feeds/atom.xml, and feeds/feed.json. Channel title, description, language, image, favicon, and copyright override the site defaults where the format has a matching field (JSON Feed has no copyright).

Items are sorted newest first. The sort key is frontmatter date, then lastUpdated when date is missing. Entries with draft: true are omitted.

If feeds is enabled without ssg.siteUrl, no files are written. The build continues and emits a warning.

Titles and descriptions are escaped so they cannot break out of XML or JSON.

Blog index items#

External posts aggregated by Blog blog.feeds stay on the blog index only. They are omitted from these generated files. There is no include switch in this release.

Last updated: