Turning any RSS feed into a fast JSON API on Vercel

Tobechi DuruTobechi Duru
3 min read

Modern apps often need content from blogs or news feeds. Many sites expose RSS, but your frontend likely wants JSON. This post shows how to ship a small serverless endpoint on Vercel that converts RSS to JSON and applies real caching with ETag and conditional requests. It stays simple, avoids heavy infra, and is production minded.

What you will build

  • A serverless API: GET /api/rss?url=<rss_url>

  • XML parsed to a small JSON schema

  • ETag passthrough and conditional requests

  • Cache headers that let browsers and CDNs do the work

Why this approach

  • Works on the free tier

  • No database needed

  • Plays well with any frontend

  • Honors the origin site caching policy

JSON shape

{
  "title": "Feed title",
  "link": "https://example.com",
  "lastBuildDate": "2025-08-24T10:00:00.000Z",
  "items": [
    {
      "title": "Post title",
      "link": "https://example.com/post",
      "pubDate": "2025-08-23T08:00:00.000Z",
      "description": "Summary..."
    }
  ]
}

Core API logic (Vercel serverless function, JavaScript)

// api/rss.js
import { XMLParser } from "fast-xml-parser";

/**
 * Minimal RSS to JSON with HTTP caching.
 * Usage: /api/rss?url=https://your-feed.xml
 */
export default async function handler(req, res) {
  try {
    const feedUrl = req.query.url;
    if (!feedUrl) {
      res.status(400).json({ error: "Missing url query parameter" });
      return;
    }

    // Forward If-None-Match to support conditional GET at the origin
    const clientIfNoneMatch = req.headers["if-none-match"] || "";

    const originResp = await fetch(feedUrl, {
      headers: clientIfNoneMatch ? { "If-None-Match": clientIfNoneMatch } : {},
    });

    // If origin returns 304, pass it through
    if (originResp.status === 304) {
      res.status(304).end();
      return;
    }

    if (!originResp.ok) {
      res.status(originResp.status).json({ error: `Upstream error ${originResp.status}` });
      return;
    }

    const etag = originResp.headers.get("etag") || "";
    const cacheControl =
      originResp.headers.get("cache-control") || "public, max-age=300, stale-while-revalidate=60";

    const xml = await originResp.text();

    const parser = new XMLParser({
      ignoreAttributes: true,
      attributeNamePrefix: "",
      trimValues: true,
    });
    const rss = parser.parse(xml);

    // Support both RSS 2.0 and Atom shapes in a small, defensive way
    const channel = rss?.rss?.channel || rss?.feed;
    if (!channel) {
      res.status(422).json({ error: "Unsupported feed format" });
      return;
    }

    const items =
      (channel.item || channel.entry || []).map((it) => {
        const link =
          typeof it.link === "string"
            ? it.link
            : Array.isArray(it.link)
            ? it.link[0]?.href || it.link[0]
            : it.link?.href || it.link?.["@_href"] || "";

        return {
          title: it.title?.["#text"] || it.title || "",
          link,
          pubDate: it.pubDate || it.updated || it.published || "",
          description: it.description || it.summary || "",
        };
      }) || [];

    const payload = {
      title: channel.title?.["#text"] || channel.title || "",
      link:
        typeof channel.link === "string"
          ? channel.link
          : channel.link?.href || channel.link?.["@_href"] || "",
      lastBuildDate: channel.lastBuildDate || channel.updated || "",
      items,
    };

    // Set caching headers so clients and Vercel CDN can cache
    if (etag) res.setHeader("ETag", etag);
    res.setHeader("Cache-Control", cacheControl);

    res.status(200).json(payload);
  } catch (err) {
    res.status(500).json({ error: "Server error", detail: String(err?.message || err) });
  }
}

Quick client fetch example

<script>
  async function loadFeed() {
    const url = encodeURIComponent("https://hnrss.org/frontpage");
    const resp = await fetch(`/api/rss?url=${url}`);
    if (resp.status === 304) return; // not changed
    const data = await resp.json();
    console.log("Feed title:", data.title);
    console.log("First item:", data.items[0]);
  }
  loadFeed();
</script>

Testing tips

  • Use a real feed such as https://hnrss.org/frontpage

  • Call once, then again with If-None-Match from the first response to see a 304 from the origin

  • Inspect Cache-Control to confirm caching behavior

Limits and next steps

  • No persistence. For global rate limits, add Vercel KV or Upstash later.

  • Add JSON schema validation for consumers.

  • Add a list of allowed origins if you want to restrict usage.

0
Subscribe to my newsletter

Read articles from Tobechi Duru directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tobechi Duru
Tobechi Duru

Software Engineer, MERN-Stack Developer