If you'd rather own your data, you can build a reader that runs on your own infrastructure. This tutorial walks through building a self-hosted RSS reader with Strapi and Next.js, where Strapi 5 handles feed storage and parsing while Next.js 16 renders a fast reading interface.
In brief:
- Model feeds and articles as Strapi Collection Types with a one-to-many relation.
- Parse RSS 2.0 and Atom feeds with a custom Strapi service built on
rss-parser. - Schedule background feed fetches with Strapi cron tasks, no external job runner required.
- Render the reading UI with Next.js 16 Server Components and toggle read status with Server Actions.
What We're Building
The end product is a two-part application. On the backend, Strapi 5 stores your feed subscriptions and the articles pulled from them. A custom service fetches each feed URL, parses the XML into structured article objects, and saves them through the Document Service API. A cron task runs that fetch every 30 minutes so new articles appear automatically. Custom routes let you subscribe to a feed, import an OPML file, or trigger a manual refresh.
On the frontend, Next.js 16 reads from Strapi's REST API and renders the interface server-side. A sidebar lists your feeds, the main panel shows articles, and a dedicated page renders each article's content. Marking an article as read happens through a Server Action that calls Strapi's update endpoint.
Both pieces run on hardware you control. Your subscriptions and reading data live in a PostgreSQL database you own.
What you'll learn:
- Modeling Feed and Article Content Types with relations
- Building a custom Strapi service with
rss-parser - Scheduling background fetches in
config/cron-tasks.ts - Creating custom routes and controllers for feed management
- Rendering the reading UI with Next.js 16 Server Components
Prerequisites
Before starting, set up the following:
- Node.js v22 LTS (Strapi 5 supports Active LTS or Maintenance LTS versions of Node.js, including v20, v22, and v24.)
- Strapi 5.x (this tutorial uses v5.48.0)
- Next.js 16.2.x (this tutorial uses 16.2.9, which ships with React 19.2)
- rss-parser v3.13.0
- PostgreSQL 16.x for the database
- Basic familiarity with TypeScript, React, and REST APIs
- A code editor and terminal
Setting Up the Strapi Backend
The backend setup covers five steps: installing Strapi 5, defining the Content Types, building a custom feed parser service, scheduling background fetches, and creating custom routes for feed management.
Step 1: Install Strapi 5
Create a new Strapi project with the official CLI. Run this in the directory where you keep your projects:
npx create-strapi@latest rss-backendThe installer runs an interactive flow that prompts you to log in or skip, then asks configuration questions. Choose TypeScript when prompted (it's the default).
For a production setup with PostgreSQL, install the pg client inside the project folder:
cd rss-backend
npm install pgConfigure the database connection. Strapi supports PostgreSQL 14.0 and up, so version 16 sits comfortably in range:
// ./config/database.ts
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', '127.0.0.1'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', ''),
ssl: env.bool('DATABASE_SSL', false)
? { rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true) }
: false,
schema: env('DATABASE_SCHEMA', 'public'),
},
},
});One thing that trips people up: the database user needs SCHEMA permissions. A user without them produces a 500 error when the Admin Panel loads.
Step 2: Define the Feed and Article Content Types
You need two Collection Types. A Feed stores a subscription, and an Article stores a single entry pulled from that feed. The relation between them is one-to-many: one feed has many articles.
Content-Type schemas live at ./src/api/[api-name]/content-types/[content-type-name]/schema.json. You can generate these through the Content-Type Builder UI, but defining the JSON directly is faster here.
Start with the Feed schema. It holds the title, the feed XML URL, the site link, a last-fetched timestamp, and a favicon URL:
// ./src/api/feed/content-types/feed/schema.json
{
"kind": "collectionType",
"collectionName": "feeds",
"info": {
"singularName": "feed",
"pluralName": "feeds",
"displayName": "Feed"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"xmlUrl": {
"type": "string",
"required": true
},
"siteLink": {
"type": "string"
},
"lastFetchedAt": {
"type": "datetime"
},
"favicon": {
"type": "string"
},
"articles": {
"type": "relation",
"relation": "oneToMany",
"target": "api::article.article",
"mappedBy": "feed"
}
}
}The info.singularName and info.pluralName drive the REST endpoint paths, so this feed becomes available at /api/feeds.
Now the Article schema. It carries the title, link, published date (pubDate), a read/unread boolean, a content snippet, the full content (rich text), and the back-reference to its feed:
// ./src/api/article/content-types/article/schema.json
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"link": {
"type": "string",
"required": true
},
"pubDate": {
"type": "datetime"
},
"isRead": {
"type": "boolean"
},
"contentSnippet": {
"type": "text"
},
"content": {
"type": "richtext"
},
"feed": {
"type": "relation",
"relation": "manyToOne",
"target": "api::feed.feed",
"inversedBy": "articles"
}
}
}The owning side (the many-to-one Article) uses inversedBy. The inversed side (the one-to-many Feed) uses mappedBy. Get these backwards and the relation won't resolve.
Strapi auto-generates REST routes for Content Types; generated Content Types include core controller and router files, while custom controllers, routes, and services are only needed to extend the default behavior. If you defined the schemas by hand, create the matching factory files. Here are the minimal versions for both:
// ./src/api/feed/controllers/feed.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::feed.feed');// ./src/api/feed/routes/feed.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::feed.feed');// ./src/api/feed/services/feed.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::feed.feed');// ./src/api/article/controllers/article.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article');// ./src/api/article/routes/article.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::article.article');// ./src/api/article/services/article.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::article.article');Step 3: Build the Feed Parser Custom Service
This is where the work happens. The feed parser custom service fetches a feed URL, parses it with rss-parser, normalizes the result across RSS 2.0 and Atom, and saves new articles through the Document Service API.
Install the parsing library first:
npm install --save rss-parserThe rss-parser package handles most of the differences between RSS 2.0 and Atom for you. It strips HTML from contentSnippet, removes dc: prefixes, and maps both dc:date and pubDate to a single isoDate field in ISO 8601 format. That saves you from writing branching logic for each feed dialect.
Create the service. The factory's second argument gives you access to strapi, which exposes the Document Service:
// ./src/api/feed/services/feed.ts
import { factories } from '@strapi/strapi';
import Parser from 'rss-parser';
const parser = new Parser({
timeout: 60000,
headers: { 'User-Agent': 'Self-Hosted RSS Reader/1.0' },
});
export default factories.createCoreService('api::feed.feed', ({ strapi }) => ({
async fetchAndSaveArticles(feedDocumentId: string) {
const feed = await strapi.documents('api::feed.feed').findOne({
documentId: feedDocumentId,
});
if (!feed) {
throw new Error('Feed not found');
}
let parsed;
try {
parsed = await parser.parseURL(feed.xmlUrl);
} catch (err) {
strapi.log.warn(`Failed to fetch feed ${feed.xmlUrl}: ${err.message}`);
return { count: 0, skipped: true };
}
let created = 0;
for (const item of parsed.items) {
if (!item.link) {
continue;
}
const existing = await strapi.documents('api::article.article').findMany({
filters: {
link: { $eq: item.link },
},
});
if (existing.length > 0) {
continue;
}
await strapi.documents('api::article.article').create({
data: {
title: item.title ?? 'Untitled',
link: item.link,
pubDate: item.isoDate ?? null,
contentSnippet: item.contentSnippet ?? null,
content: item.content ?? null,
isRead: false,
feed: feedDocumentId,
},
});
created += 1;
}
await strapi.documents('api::feed.feed').update({
documentId: feedDocumentId,
data: { lastFetchedAt: new Date() },
});
return { count: created, skipped: false };
},
async fetchAllFeeds() {
const feeds = await strapi.documents('api::feed.feed').findMany({
fields: ['xmlUrl'],
});
let totalCreated = 0;
for (const feed of feeds) {
const result = await this.fetchAndSaveArticles(feed.documentId);
totalCreated += result.count;
}
return { feedsProcessed: feeds.length, articlesAdded: totalCreated };
},
}));A few details worth calling out. The deduplication check uses findMany with a $eq filter on the link field before any insert, so the same article never gets stored twice even when a feed republishes its entire history.
The feed-to-article relation uses the many-to-one shorthand: passing feed: feedDocumentId as a string connects the article to its feed without the longhand connect syntax.
Error handling matters here because feeds go down. The try/catch around parseURL catches unreachable feeds and logs a warning instead of crashing the whole run. Strapi 5 core service methods take documentId (not the old entityId), so every call references the canonical string identifier.
Many feeds emit elements beyond the standard set, like media enclosures or author tags. You can capture those by passing customFields to the parser constructor:
// ./src/api/feed/services/feed.ts
const parser = new Parser({
customFields: {
item: [['media:content', 'media'], ['author', 'author']],
},
});Each entry in customFields maps a feed element name to a property on the parsed item. The first array element is the source element name as it appears in the XML, and the second is the property name rss-parser writes to on each item. With the configuration above, item.media and item.author become available alongside the standard title, link, and content fields.
This lets you store extra metadata without parsing the XML by hand. If you wanted to display a thumbnail next to each article, you would add a media attribute to the Article schema, then read item.media inside the create call. The parser handles the XML extraction, and your service decides which of those fields to persist.
You invoke this service from anywhere in the backend with strapi.service('api::feed.feed'):
// Usage from any controller or service
const result = await strapi
.service('api::feed.feed')
.fetchAndSaveArticles(documentId);Step 4: Schedule Background Fetches with Cron Tasks
Strapi 5 runs cron tasks through node-schedule, so you don't need a separate job runner. Define your tasks in config/cron-tasks.ts and enable them in config/server.ts.
First, enable cron in the server config and point it at your tasks file:
// ./config/server.ts
import cronTasks from './cron-tasks';
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
cron: {
enabled: true,
tasks: cronTasks,
},
});Now define the task. Each task function receives { strapi }, which is how it reaches the feed parser service. The cron rule has six fields, with the leftmost being seconds:
// ./config/cron-tasks.ts
export default {
fetchRssFeeds: {
task: async ({ strapi }) => {
strapi.log.info('Starting scheduled RSS feed fetch');
try {
const result = await strapi
.service('api::feed.feed')
.fetchAllFeeds();
strapi.log.info(
`Feed fetch complete: ${result.articlesAdded} new articles across ${result.feedsProcessed} feeds`
);
} catch (err) {
strapi.log.error(`Scheduled feed fetch failed: ${err.message}`);
}
},
options: {
rule: '0 */30 * * * *',
},
},
};The rule 0 */30 * * * * reads as: at second 0, every 30th minute, every hour. The fetchAllFeeds service wraps each individual feed fetch in its own error handling, so one unreachable feed won't stop the others from updating. The outer try/catch in the task is a second safety net for anything unexpected.
If you ever need timezone-specific scheduling, add a tz property to options (for example, tz: 'Asia/Dhaka'). For one-off runs, you can pass a Date object instead of a rule.
Beyond the static config/cron-tasks.ts file, you can register and remove jobs at runtime. From a bootstrap function in src/index.ts or from inside a service, call strapi.cron.add({ ... }) to schedule a job after the application has started, and strapi.cron.remove() to tear one down.
This becomes useful if you want each feed to fetch on its own interval rather than running every feed on one shared schedule. You might give high-traffic news feeds a five-minute rule while a slow personal blog refreshes once a day. Dynamic registration also lets you add a job the moment a new feed is subscribed, instead of waiting for the next pass of the shared task.
Step 5: Create Custom Routes for Feed Management
The core REST routes handle basic CRUD (Create, Read, Update, Delete), but feed management needs custom endpoints: subscribing to a feed with URL validation, importing from OPML, and triggering a manual refresh.
Routes files load in alphabetical order, so prefix the custom file to control when it loads:
// ./src/api/feed/routes/01-custom-feed.ts
export default {
routes: [
{
method: 'POST',
path: '/feeds/subscribe',
handler: 'api::feed.feed.subscribe',
config: {
middlewares: [],
},
},
{
method: 'POST',
path: '/feeds/:id/refresh',
handler: 'api::feed.feed.refresh',
config: {
middlewares: [],
},
},
{
method: 'POST',
path: '/feeds/import-opml',
handler: 'api::feed.feed.importOpml',
config: {
middlewares: [],
},
},
],
};The handler string follows the format api::<api-name>.<controllerName>.<actionName>. Each handler maps to a method you add to the feed controller.
Now extend the custom controllers with those three actions. The subscribe action validates the URL with the native URL constructor before fetching, parses the feed to grab its title, creates the feed record, then runs the first fetch:
// ./src/api/feed/controllers/feed.ts
import { factories } from '@strapi/strapi';
import Parser from 'rss-parser';
const parser = new Parser();
export default factories.createCoreController('api::feed.feed', ({ strapi }) => ({
async subscribe(ctx) {
const { xmlUrl } = ctx.request.body;
if (!xmlUrl) {
return ctx.badRequest('xmlUrl is required');
}
try {
new URL(xmlUrl);
} catch {
return ctx.badRequest('Invalid feed URL');
}
const existing = await strapi.documents('api::feed.feed').findMany({
filters: { xmlUrl: { $eq: xmlUrl } },
});
if (existing.length > 0) {
return ctx.badRequest('Already subscribed to this feed');
}
let parsed;
try {
parsed = await parser.parseURL(xmlUrl);
} catch {
return ctx.badRequest('Could not fetch or parse the feed');
}
const feed = await strapi.documents('api::feed.feed').create({
data: {
title: parsed.title ?? xmlUrl,
xmlUrl,
siteLink: parsed.link ?? null,
},
});
const result = await strapi
.service('api::feed.feed')
.fetchAndSaveArticles(feed.documentId);
return ctx.send({
feed,
articlesAdded: result.count,
});
},
async refresh(ctx) {
const { id } = ctx.params;
if (!id) {
return ctx.badRequest('Feed ID is required');
}
const result = await strapi
.service('api::feed.feed')
.fetchAndSaveArticles(id);
return ctx.send({ refreshed: true, articlesAdded: result.count });
},
async importOpml(ctx) {
const { xmlContent } = ctx.request.body;
if (!xmlContent) {
return ctx.badRequest('xmlContent is missing');
}
const urlMatches = [...xmlContent.matchAll(/xmlUrl="([^"]+)"/g)];
const feedUrls = urlMatches.map((match) => match[1]);
if (feedUrls.length === 0) {
return ctx.badRequest('No feed URLs found in OPML');
}
const imported: string[] = [];
for (const url of feedUrls) {
const existing = await strapi.documents('api::feed.feed').findMany({
filters: { xmlUrl: { $eq: url } },
});
if (existing.length > 0) {
continue;
}
try {
const parsed = await parser.parseURL(url);
const feed = await strapi.documents('api::feed.feed').create({
data: {
title: parsed.title ?? url,
xmlUrl: url,
siteLink: parsed.link ?? null,
},
});
await strapi.service('api::feed.feed').fetchAndSaveArticles(feed.documentId);
imported.push(url);
} catch {
strapi.log.warn(`Skipped unreachable OPML feed: ${url}`);
}
}
return ctx.send({ importedCount: imported.length, imported });
},
}));The OPML import parses each outline element's xmlUrl attribute. In OPML 2.0, feed subscriptions are <outline> elements with type="rss" and an xmlUrl attribute that points to the feed itself. Each URL gets the same dedupe check before import, and unreachable feeds are skipped rather than failing the whole batch.
Real-world OPML files often nest feeds inside folder <outline> elements that represent categories, and those folder outlines contain child <outline> elements for the actual feeds. The flat regex used here captures double-quoted xmlUrl attributes written in that exact form wherever they appear, regardless of nesting depth, so folder structure gets flattened on import: every feed lands in the same flat list. That works fine for a reader that does not track categories. If you wanted to preserve them, you would parse the XML into a tree and read each parent outline's text or title attribute as a category name, then attach that category to the feeds nested beneath it.
Strapi's backend runs on Koa, so the ctx object gives you ctx.request.body for the parsed body, ctx.params for route parameters like :id, and helpers like ctx.badRequest for validation responses.
You need to enable public access to these routes for testing, or pass an API token. In the Admin Panel, go to Settings, then Users and Permissions, then Roles, and enable the relevant actions. For production, generate an API token under Settings instead.
Building the Next.js 16 Frontend
This section covers the reading interface. If you want to see the full picture of pairing Strapi with Next.js, the integration page collects patterns beyond what this reader needs.
Step 1: Set Up the Next.js 16 Project
Create the frontend in a separate directory:
npx create-next-app@latest rss-frontendChoose TypeScript, the App Router, and Tailwind CSS when prompted. Next.js 16's App Router uses the latest React Canary release, which includes React 19.2 features, and the App Router is used by default for new projects.
Set up environment variables. The Strapi URL and API token are server-only, so they get no NEXT_PUBLIC_ prefix and never reach the browser:
# ./.env.local
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token-hereOne breaking change to note if you're migrating from an older project: serverRuntimeConfig and publicRuntimeConfig were removed in Next.js 16. Use .env files instead.
Define TypeScript types matching Strapi 5's flat response format. In Strapi 5, attributes sit on the object rather than nested under data.attributes, and every record carries a documentId string:
// ./lib/types.ts
export interface Article {
id: number;
documentId: string;
title: string;
link: string;
pubDate: string | null;
contentSnippet: string | null;
content: string | null;
isRead: boolean;
}
export interface Feed {
id: number;
documentId: string;
title: string;
xmlUrl: string;
siteLink: string | null;
lastFetchedAt: string | null;
favicon: string | null;
articles?: Article[];
}
export interface StrapiResponse<T> {
data: T;
meta: Record<string, unknown>;
}Install the qs library for building nested query strings, then add a small fetch helper that centralizes the base URL, auth header, and error handling:
npm install qs @types/qs// ./lib/strapi.ts
import 'server-only';
const STRAPI_URL = process.env.STRAPI_URL;
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
export async function strapiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(`${STRAPI_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
...options.headers,
},
});
if (!res.ok) {
throw new Error(`Strapi request failed: ${res.status} ${res.statusText}`);
}
return res.json();
}The server-only module import throws at build time if this file ever gets imported into a Client Component, which keeps your API token out of the browser bundle.
Step 2: Create the Feed Sidebar and Article List
Server Components fetch data directly, no client-side data fetching needed. The home page pulls feeds with their articles using targeted populate. Strapi never populates relations by default, so you have to request the articles explicitly and limit the fields you pull:
// ./app/page.tsx
import Link from 'next/link';
import qs from 'qs';
import { strapiFetch } from '@/lib/strapi';
import type { Feed, StrapiResponse } from '@/lib/types';
export default async function HomePage() {
const query = qs.stringify(
{
fields: ['title', 'siteLink'],
populate: {
articles: {
fields: ['title', 'link', 'pubDate', 'contentSnippet', 'isRead'],
sort: ['pubDate:desc'],
},
},
},
{ encodeValuesOnly: true }
);
const { data: feeds } = await strapiFetch<StrapiResponse<Feed[]>>(
`/api/feeds?${query}`,
{ next: { revalidate: 60, tags: ['articles'] } }
);
return (
<div className="flex min-h-screen">
<aside className="w-64 border-r border-gray-200 p-4">
<h2 className="mb-4 text-lg font-semibold">Feeds</h2>
<ul className="space-y-2">
{feeds.map((feed) => (
<li key={feed.documentId}>
<span className="text-sm">{feed.title}</span>
</li>
))}
</ul>
</aside>
<main className="flex-1 p-6">
<h1 className="mb-6 text-2xl font-bold">Latest Articles</h1>
<div className="space-y-4">
{feeds.flatMap((feed) =>
(feed.articles ?? []).map((article) => (
<Link
key={article.documentId}
href={`/articles/${article.documentId}`}
className="block rounded border border-gray-200 p-4 hover:bg-gray-50"
>
<h3
className={
article.isRead
? 'font-normal text-gray-500'
: 'font-semibold'
}
>
{article.title}
</h3>
<p className="mt-1 text-sm text-gray-600">
{article.contentSnippet?.slice(0, 160)}
</p>
</Link>
))
)}
</div>
</main>
</div>
);
}The qs library builds the nested query string without encoding headaches. The next: { revalidate: 60, tags: ['articles'] } option caches the response for 60 seconds and tags it articles, which lets a Server Action invalidate it later. Read articles render in a muted style so unread items stand out.
This build uses tag-based revalidation because a single article update should refresh both the home list and the article page, and tags decouple invalidation from specific URLs. Both the home page fetch and the article page fetch carry the articles tag, so one revalidateTag('articles') call refreshes both at once.
You could reach the same result with revalidatePath('/') for the home page, but you would then have to call revalidatePath again for every other route that shows the article, like /articles/[documentId]. As the app grows and more pages read the same data, the tag approach scales with a single line while path-based invalidation grows with the number of routes.
Step 3: Render the Article Reading View
Each article gets its own page. The dynamic route segment captures the documentId, which is the canonical identifier you use for every Strapi 5 API reference:
// ./app/articles/[documentId]/page.tsx
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { strapiFetch } from '@/lib/strapi';
import type { Article, StrapiResponse } from '@/lib/types';
import { ReadToggle } from '@/app/components/read-toggle';
export default async function ArticlePage({
params,
}: {
params: Promise<{ documentId: string }>;
}) {
const { documentId } = await params;
let article: Article;
try {
const { data } = await strapiFetch<StrapiResponse<Article>>(
`/api/articles/${documentId}`,
{ next: { tags: ['articles'] } }
);
article = data;
} catch {
notFound();
}
return (
<article className="mx-auto max-w-2xl p-6">
<Link href="/" className="text-sm text-blue-600 hover:underline">
← Back to feed
</Link>
<h1 className="mt-4 text-3xl font-bold">{article.title}</h1>
{article.pubDate && (
<time className="mt-2 block text-sm text-gray-500">
{new Date(article.pubDate).toLocaleDateString()}
</time>
)}
<div className="mt-6 flex items-center gap-4">
<a
href={article.link}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
View original
</a>
<ReadToggle documentId={article.documentId} isRead={article.isRead} />
</div>
<div
className="prose mt-8"
dangerouslySetInnerHTML={{
__html: article.content ?? article.contentSnippet ?? '',
}}
/>
</article>
);
}Step 4: Toggle Read Status with Server Actions
Marking an article read is a mutation, so it belongs in a Server Action. The action calls Strapi's update endpoint by documentId, then revalidates the cached data so the UI reflects the change:
// ./app/actions/article-actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function toggleReadStatus(
documentId: string,
currentIsRead: boolean
) {
const res = await fetch(
`${process.env.STRAPI_URL}/api/articles/${documentId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
body: JSON.stringify({ data: { isRead: !currentIsRead } }),
}
);
if (!res.ok) {
throw new Error('Failed to update article');
}
revalidateTag('articles');
}Strapi 5 expects the update payload wrapped in a data object, and the endpoint references the article by documentId in the path. After the update succeeds, revalidateTag('articles') invalidates every cached fetch tagged articles. The home page and article page both use the shared tag; with revalidateTag, the next request may still serve stale content while fresh data is regenerated in the background.
Wire the action to a button. A small Client Component binds the current values and submits through a form:
// ./app/components/read-toggle.tsx
'use client';
import { toggleReadStatus } from '@/app/actions/article-actions';
export function ReadToggle({
documentId,
isRead,
}: {
documentId: string;
isRead: boolean;
}) {
return (
<form action={toggleReadStatus.bind(null, documentId, isRead)}>
<button
type="submit"
className="rounded border border-gray-300 px-3 py-1 text-sm hover:bg-gray-50"
>
{isRead ? 'Mark as unread' : 'Mark as read'}
</button>
</form>
);
}Binding arguments with .bind(null, documentId, isRead) passes the article's current state to the action when the form submits. No client-side state management, no API client: a form posting to a server function.
Putting It All Together
Time to run both halves and watch articles flow through. Start the Strapi backend first:
cd rss-backend
npm run developStrapi boots on http://localhost:1337. The cron task registers automatically and fires every 30 minutes. In a second terminal, start the frontend:
cd rss-frontend
npm run devNext.js serves on http://localhost:3000. Subscribe to a feed by calling the custom subscribe endpoint:
curl -X POST http://localhost:1337/api/feeds/subscribe \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-d '{"xmlUrl": "https://example.com/rss"}'The endpoint validates the URL, fetches the feed, creates the Feed record, and pulls in the current articles. You should get back JSON with the new feed and an articlesAdded count:
{
"feed": {
"documentId": "bw64dnu97i56nq85106yt4du",
"title": "Example Blog",
"xmlUrl": "https://example.com/rss"
},
"articlesAdded": 12
}Refresh http://localhost:3000 and the articles appear in the list. Click one to read it, then toggle its read status with the button. If you'd rather not wait for the cron schedule, trigger a manual refresh against any feed:
curl -X POST http://localhost:1337/api/feeds/bw64dnu97i56nq85106yt4du/refresh \
-H "Authorization: Bearer YOUR_API_TOKEN"The deduplication logic means running the refresh repeatedly won't create duplicate articles. Only new entries get saved.
If articles don't appear, work through a short checklist. Confirm the Strapi server log shows the line "Starting scheduled RSS feed fetch" when the cron task fires, which tells you the scheduler is running.
Verify the public role or API token has find and create permissions on both the Feed and Article Content Types, since a missing create permission blocks the service from saving. Confirm the feed URL returns valid XML by opening it in a browser or piping it through curl. The try/catch around parseURL logs a warning for any feed it can't parse, so check the log for that warning too.
Next Steps
You have a working reader, and there are several directions to extend it:
- Add full-text search across articles using Strapi's filtering API or a plugin like Meilisearch from the Strapi Marketplace.
- Deploy the backend to Strapi Cloud and the frontend to Vercel's platform for a hosted setup with the same codebase.
- Introduce feed categorization with a Category Collection Type and a many-to-many relation to feeds.
- Build an OPML export endpoint so you can move your subscriptions to another reader, completing the data-portability story.
- Explore the Strapi documentation and the Next.js docs for deeper customization of content modeling and rendering.
How Strapi Powers This
Every piece of this reader runs on Strapi 5 features you can customize. The Content-Type Builder defines the Feed and Article schemas with typed relations. The Document Service API handles creation, deduplication queries, and updates through documentId. Custom services and controllers extend the backend without patching core code, and built-in cron scheduling removes the need for external job runners.
The REST API exposes everything the frontend needs with explicit field selection and relation populate. You own every layer: the data model, the parsing logic, the refresh schedule, and the reading interface. Swap the frontend, change the database, or add new Content Types, all without vendor lock-in. Explore Strapi's feature set to see what else you can build on top of this foundation.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.