Resend to RSS, or when a friend wants RSS instead of an email

1 month ago 9

The Origin Story: My friend M asked to be removed from our newsletter. But instead of just hitting unsubscribe like a normal person, he said: "Just give me an RSS feed". So here we are. This is what friendship looks like in 2025. 😅

A Next.js API that converts Resend newsletter broadcasts into an RSS feed with full HTML content. Built for desplega.ai to give power users RSS access to our newsletter without maintaining a separate distribution system.

At desplega.ai, we send newsletters via Resend. Some folks (looking at you, M) prefer RSS feeds over email. Instead of maintaining two separate systems, this API:

  • ✅ Syncs broadcasts from Resend daily via cron
  • ✅ Caches everything in Vercel Blob for fast access
  • ✅ Serves a standard RSS feed with full HTML content
  • ✅ Provides individual broadcast URLs for web viewing

Perfect for teams using Resend who want to offer RSS without the hassle.

  • Daily Auto-Sync: Cron job fetches new broadcasts from Resend every day
  • Incremental Updates: Only fetches new emails after first run (~9s vs 4 minutes)
  • Full HTML Content: RSS feed includes complete email HTML (not just text)
  • Individual Broadcast URLs: Each email is viewable at /api/broadcast/{id}
  • Smart Caching: Uses Vercel Blob for fast RSS generation (no API calls)
  • Rate-Limited: Respects Resend's 2 calls/second limit
  • Type-Safe: Built with TypeScript
┌─────────────┐ │ Resend API │ │ Broadcasts │ │ Emails │ └──────┬──────┘ │ │ Daily Cron (midnight UTC) │ ▼ ┌──────────────────────────────┐ │ 1. Fetch audiences │ │ 2. Fetch broadcasts │ │ 3. Fetch emails (incremental)│ │ 4. Match emails → broadcasts│ │ 5. Convert HTML │ └──────┬───────────────────────┘ │ │ Store in Vercel Blob │ ▼ ┌──────────────────────┐ │ rss_audiences │ │ rss_broadcasts │ │ rss_emails │ │ rss_broadcast_{id} │ │ rss_last_email_id │ └──────┬───────────────┘ │ │ Read from cache │ ▼ ┌──────────────────────┐ │ /api/rss │ ← RSS Feed │ /api/broadcast/{id} │ ← Individual emails └──────────────────────┘

Since Resend's broadcast API doesn't return email content (see Problems), we:

  1. Fetch broadcast metadata (subject, from address)
  2. Fetch all sent emails separately
  3. Match emails to broadcasts by comparing subject and from fields
  4. Store matched HTML content in blob storage
  • Node.js 18+ and pnpm
  • Resend API key
  • Vercel account (for deployment)
git clone https://github.com/desplega-ai/rss.git cd rss pnpm install

Create a .env file:

# Required RESEND_API_KEY=re_xxxxx RESEND_AUDIENCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx BLOB_READ_WRITE_TOKEN=vercel_blob_rw_xxxxx # Auto-generated by Vercel CRON_SECRET=your-random-secret-here # Optional: RSS Feed Customization RSS_FEED_TITLE="Newsletter Feed" RSS_FEED_DESCRIPTION="RSS feed of newsletter broadcasts"

Environment Variable Details:

Variable Required Description Default
RESEND_API_KEY Your Resend API key -
RESEND_AUDIENCE_ID Target audience ID to fetch broadcasts for -
BLOB_READ_WRITE_TOKEN Vercel Blob storage token (auto-generated) -
CRON_SECRET Secret for authenticating cron requests -
RSS_FEED_TITLE Custom title for your RSS feed "Newsletter Feed"
RSS_FEED_DESCRIPTION Custom description for your RSS feed "RSS feed of newsletter broadcasts"
# Start dev server pnpm dev # Trigger cron job manually (in another terminal) curl -X GET http://localhost:3000/api/cron \ -H "Authorization: Bearer your-cron-secret" # View RSS feed curl http://localhost:3000/api/rss # or open in browser open http://localhost:3000/api/rss
  1. Push to GitHub

  2. Import to Vercel

    • Go to vercel.com
    • Click "New Project"
    • Import your repository
  3. Add Environment Variables

    In Vercel project settings → Environment Variables:

    Required:

    • RESEND_API_KEY - Your Resend API key
    • RESEND_AUDIENCE_ID - Target audience ID
    • CRON_SECRET - Random secret for cron authentication
    • BLOB_READ_WRITE_TOKEN - Auto-generated when you enable Vercel Blob

    Optional (RSS Customization):

    • RSS_FEED_TITLE - Custom feed title (default: "Newsletter Feed")
    • RSS_FEED_DESCRIPTION - Custom feed description (default: "RSS feed of newsletter broadcasts")
  4. Enable Vercel Blob

    • Go to Storage tab → Create Blob Store
    • BLOB_READ_WRITE_TOKEN will be automatically set
  5. Deploy

    • Vercel will auto-deploy on push
    • Cron runs daily at midnight UTC (configured in vercel.json)
  6. First Run (Manual Trigger)

    curl -X GET https://your-app.vercel.app/api/cron \ -H "Authorization: Bearer your-cron-secret"
  7. Access Your RSS Feed

    https://your-app.vercel.app/api/rss

Returns RSS XML feed with all broadcasts.

Response:

<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0"> <channel> <title>Newsletter Feed</title> <description>RSS feed of newsletter broadcasts</description> <item> <title>Newsletter Subject</title> <link>https://your-app.vercel.app/api/broadcast/{id}</link> <guid>https://your-app.vercel.app/api/broadcast/{id}</guid> <pubDate>Mon, 29 Sep 2025 18:54:10 GMT</pubDate> <description><![CDATA[Full HTML content here]]></description> </item> </channel> </rss>

Syncs data from Resend. Protected by Authorization header.

Headers:

Authorization: Bearer {CRON_SECRET}

Response:

{ "success": true, "audiences": 1, "broadcasts": 3, "newEmails": 0, "totalEmails": 569 }

Performance:

  • First run: ~4 minutes (fetches all emails)
  • With new emails: ~1-2 minutes
  • No new emails: ~9 seconds

Renders individual broadcast as HTML page.

Example:

https://your-app.vercel.app/api/broadcast/fa8f0216-3d02-4543-ad86-5e92e3c27124

⚠️ Resend API Limitation: The Resend broadcasts API (/broadcasts) does not return the actual email content (html or text fields). This is explicitly documented in their API.

Our Workaround:

  1. Fetch broadcast metadata (subject, from) via /broadcasts/{id}
  2. Fetch all sent emails via /emails
  3. Match emails to broadcasts by comparing subject and from
  4. Extract HTML content from matched emails

Trade-offs:

  • ✅ Works reliably with current Resend API
  • ✅ Incremental fetching keeps it fast
  • ⚠️ Rate limiting means slower first sync (~4 min for 370 emails)
  • ⚠️ Matching by subject+from is not 100% guaranteed (though works in practice)

Future: If Resend adds content to broadcast endpoints, we can simplify the matching logic significantly.

Currently using Vercel Blob for storage. If you outgrow it or want more control:

Benefits of S3:

  • Lower cost at scale
  • More control over data
  • Works outside Vercel ecosystem
  • Better for multi-region deployments

Migration Path:

  1. Install AWS SDK:

    pnpm add @aws-sdk/client-s3
  2. Replace @vercel/blob imports with S3 client

  3. Update storage functions:

    import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; const s3 = new S3Client({ region: 'us-east-1' }); // Instead of: put(key, data, options) await s3.send(new PutObjectCommand({ Bucket: 'your-bucket', Key: key, Body: data, }));
  4. Update environment variables to use S3 credentials

The code structure is designed to make this swap easy - all storage operations are isolated in the cron route.

Resend allows 2 API calls per second. The cron job adds 500ms delays between calls to respect this limit.

After the first run, the cron only fetches emails created after the last sync using the before query parameter. This dramatically reduces sync time from ~4 minutes to ~9 seconds when there are no new emails.

rss_audiences # All audiences rss_broadcasts # All broadcasts for target audience rss_emails # All emails (growing list) rss_last_email_id # Cursor for incremental sync rss_broadcast_{id} # Individual broadcast with HTML rss_audience_{id} # Individual audience (unused currently) rss_email_{id} # Individual email (unused currently)
  • Next.js 14 - API routes
  • TypeScript - Type safety
  • Vercel Blob - Storage
  • Vercel Cron - Scheduled jobs
  • Turndown - HTML to Markdown (stored but not used in RSS)
  • Resend API - Source data

This project was built by desplega.ai, a platform for building AI-powered browser automation and testing tools. We use this to provide RSS access to our newsletter while keeping everything automated through Resend.

If you're building AI agents that need to interact with web applications, check us out!

MIT License

Copyright (c) 2025 desplega.ai

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


Made with ☕ by desplega.ai | Thanks M for the inspiration 🎉

Read Entire Article