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:
rss-parser.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:
rss-parserconfig/cron-tasks.tsBefore starting, set up the following:
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.
Create a new Strapi project with the official CLI. Run this in the directory where you keep your projects:
1npx 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:
1cd rss-backend
2npm install pgConfigure the database connection. Strapi supports PostgreSQL 14.0 and up, so version 16 sits comfortably in range:
1// ./config/database.ts
2export default ({ env }) => ({
3 connection: {
4 client: 'postgres',
5 connection: {
6 host: env('DATABASE_HOST', '127.0.0.1'),
7 port: env.int('DATABASE_PORT', 5432),
8 database: env('DATABASE_NAME', 'strapi'),
9 user: env('DATABASE_USERNAME', 'strapi'),
10 password: env('DATABASE_PASSWORD', ''),
11 ssl: env.bool('DATABASE_SSL', false)
12 ? { rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true) }
13 : false,
14 schema: env('DATABASE_SCHEMA', 'public'),
15 },
16 },
17});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.
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:
1// ./src/api/feed/content-types/feed/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "feeds",
5 "info": {
6 "singularName": "feed",
7 "pluralName": "feeds",
8 "displayName": "Feed"
9 },
10 "options": {
11 "draftAndPublish": false
12 },
13 "attributes": {
14 "title": {
15 "type": "string",
16 "required": true
17 },
18 "xmlUrl": {
19 "type": "string",
20 "required": true
21 },
22 "siteLink": {
23 "type": "string"
24 },
25 "lastFetchedAt": {
26 "type": "datetime"
27 },
28 "favicon": {
29 "type": "string"
30 },
31 "articles": {
32 "type": "relation",
33 "relation": "oneToMany",
34 "target": "api::article.article",
35 "mappedBy": "feed"
36 }
37 }
38}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:
1// ./src/api/article/content-types/article/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "articles",
5 "info": {
6 "singularName": "article",
7 "pluralName": "articles",
8 "displayName": "Article"
9 },
10 "options": {
11 "draftAndPublish": false
12 },
13 "attributes": {
14 "title": {
15 "type": "string",
16 "required": true
17 },
18 "link": {
19 "type": "string",
20 "required": true
21 },
22 "pubDate": {
23 "type": "datetime"
24 },
25 "isRead": {
26 "type": "boolean"
27 },
28 "contentSnippet": {
29 "type": "text"
30 },
31 "content": {
32 "type": "richtext"
33 },
34 "feed": {
35 "type": "relation",
36 "relation": "manyToOne",
37 "target": "api::feed.feed",
38 "inversedBy": "articles"
39 }
40 }
41}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:
1// ./src/api/feed/controllers/feed.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController('api::feed.feed');1// ./src/api/feed/routes/feed.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreRouter('api::feed.feed');1// ./src/api/feed/services/feed.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreService('api::feed.feed');1// ./src/api/article/controllers/article.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController('api::article.article');1// ./src/api/article/routes/article.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreRouter('api::article.article');1// ./src/api/article/services/article.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreService('api::article.article');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:
1npm 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:
1// ./src/api/feed/services/feed.ts
2import { factories } from '@strapi/strapi';
3import Parser from 'rss-parser';
4
5const parser = new Parser({
6 timeout: 60000,
7 headers: { 'User-Agent': 'Self-Hosted RSS Reader/1.0' },
8});
9
10export default factories.createCoreService('api::feed.feed', ({ strapi }) => ({
11 async fetchAndSaveArticles(feedDocumentId: string) {
12 const feed = await strapi.documents('api::feed.feed').findOne({
13 documentId: feedDocumentId,
14 });
15
16 if (!feed) {
17 throw new Error('Feed not found');
18 }
19
20 let parsed;
21 try {
22 parsed = await parser.parseURL(feed.xmlUrl);
23 } catch (err) {
24 strapi.log.warn(`Failed to fetch feed ${feed.xmlUrl}: ${err.message}`);
25 return { count: 0, skipped: true };
26 }
27
28 let created = 0;
29
30 for (const item of parsed.items) {
31 if (!item.link) {
32 continue;
33 }
34
35 const existing = await strapi.documents('api::article.article').findMany({
36 filters: {
37 link: { $eq: item.link },
38 },
39 });
40
41 if (existing.length > 0) {
42 continue;
43 }
44
45 await strapi.documents('api::article.article').create({
46 data: {
47 title: item.title ?? 'Untitled',
48 link: item.link,
49 pubDate: item.isoDate ?? null,
50 contentSnippet: item.contentSnippet ?? null,
51 content: item.content ?? null,
52 isRead: false,
53 feed: feedDocumentId,
54 },
55 });
56
57 created += 1;
58 }
59
60 await strapi.documents('api::feed.feed').update({
61 documentId: feedDocumentId,
62 data: { lastFetchedAt: new Date() },
63 });
64
65 return { count: created, skipped: false };
66 },
67
68 async fetchAllFeeds() {
69 const feeds = await strapi.documents('api::feed.feed').findMany({
70 fields: ['xmlUrl'],
71 });
72
73 let totalCreated = 0;
74
75 for (const feed of feeds) {
76 const result = await this.fetchAndSaveArticles(feed.documentId);
77 totalCreated += result.count;
78 }
79
80 return { feedsProcessed: feeds.length, articlesAdded: totalCreated };
81 },
82}));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:
1// ./src/api/feed/services/feed.ts
2const parser = new Parser({
3 customFields: {
4 item: [['media:content', 'media'], ['author', 'author']],
5 },
6});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'):
1// Usage from any controller or service
2const result = await strapi
3 .service('api::feed.feed')
4 .fetchAndSaveArticles(documentId);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:
1// ./config/server.ts
2import cronTasks from './cron-tasks';
3
4export default ({ env }) => ({
5 host: env('HOST', '0.0.0.0'),
6 port: env.int('PORT', 1337),
7 cron: {
8 enabled: true,
9 tasks: cronTasks,
10 },
11});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:
1// ./config/cron-tasks.ts
2export default {
3 fetchRssFeeds: {
4 task: async ({ strapi }) => {
5 strapi.log.info('Starting scheduled RSS feed fetch');
6 try {
7 const result = await strapi
8 .service('api::feed.feed')
9 .fetchAllFeeds();
10 strapi.log.info(
11 `Feed fetch complete: ${result.articlesAdded} new articles across ${result.feedsProcessed} feeds`
12 );
13 } catch (err) {
14 strapi.log.error(`Scheduled feed fetch failed: ${err.message}`);
15 }
16 },
17 options: {
18 rule: '0 */30 * * * *',
19 },
20 },
21};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.
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:
1// ./src/api/feed/routes/01-custom-feed.ts
2export default {
3 routes: [
4 {
5 method: 'POST',
6 path: '/feeds/subscribe',
7 handler: 'api::feed.feed.subscribe',
8 config: {
9 middlewares: [],
10 },
11 },
12 {
13 method: 'POST',
14 path: '/feeds/:id/refresh',
15 handler: 'api::feed.feed.refresh',
16 config: {
17 middlewares: [],
18 },
19 },
20 {
21 method: 'POST',
22 path: '/feeds/import-opml',
23 handler: 'api::feed.feed.importOpml',
24 config: {
25 middlewares: [],
26 },
27 },
28 ],
29};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:
1// ./src/api/feed/controllers/feed.ts
2import { factories } from '@strapi/strapi';
3import Parser from 'rss-parser';
4
5const parser = new Parser();
6
7export default factories.createCoreController('api::feed.feed', ({ strapi }) => ({
8 async subscribe(ctx) {
9 const { xmlUrl } = ctx.request.body;
10
11 if (!xmlUrl) {
12 return ctx.badRequest('xmlUrl is required');
13 }
14
15 try {
16 new URL(xmlUrl);
17 } catch {
18 return ctx.badRequest('Invalid feed URL');
19 }
20
21 const existing = await strapi.documents('api::feed.feed').findMany({
22 filters: { xmlUrl: { $eq: xmlUrl } },
23 });
24
25 if (existing.length > 0) {
26 return ctx.badRequest('Already subscribed to this feed');
27 }
28
29 let parsed;
30 try {
31 parsed = await parser.parseURL(xmlUrl);
32 } catch {
33 return ctx.badRequest('Could not fetch or parse the feed');
34 }
35
36 const feed = await strapi.documents('api::feed.feed').create({
37 data: {
38 title: parsed.title ?? xmlUrl,
39 xmlUrl,
40 siteLink: parsed.link ?? null,
41 },
42 });
43
44 const result = await strapi
45 .service('api::feed.feed')
46 .fetchAndSaveArticles(feed.documentId);
47
48 return ctx.send({
49 feed,
50 articlesAdded: result.count,
51 });
52 },
53
54 async refresh(ctx) {
55 const { id } = ctx.params;
56
57 if (!id) {
58 return ctx.badRequest('Feed ID is required');
59 }
60
61 const result = await strapi
62 .service('api::feed.feed')
63 .fetchAndSaveArticles(id);
64
65 return ctx.send({ refreshed: true, articlesAdded: result.count });
66 },
67
68 async importOpml(ctx) {
69 const { xmlContent } = ctx.request.body;
70
71 if (!xmlContent) {
72 return ctx.badRequest('xmlContent is missing');
73 }
74
75 const urlMatches = [...xmlContent.matchAll(/xmlUrl="([^"]+)"/g)];
76 const feedUrls = urlMatches.map((match) => match[1]);
77
78 if (feedUrls.length === 0) {
79 return ctx.badRequest('No feed URLs found in OPML');
80 }
81
82 const imported: string[] = [];
83
84 for (const url of feedUrls) {
85 const existing = await strapi.documents('api::feed.feed').findMany({
86 filters: { xmlUrl: { $eq: url } },
87 });
88
89 if (existing.length > 0) {
90 continue;
91 }
92
93 try {
94 const parsed = await parser.parseURL(url);
95 const feed = await strapi.documents('api::feed.feed').create({
96 data: {
97 title: parsed.title ?? url,
98 xmlUrl: url,
99 siteLink: parsed.link ?? null,
100 },
101 });
102 await strapi.service('api::feed.feed').fetchAndSaveArticles(feed.documentId);
103 imported.push(url);
104 } catch {
105 strapi.log.warn(`Skipped unreachable OPML feed: ${url}`);
106 }
107 }
108
109 return ctx.send({ importedCount: imported.length, imported });
110 },
111}));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.
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.
Create the frontend in a separate directory:
1npx 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:
1# ./.env.local
2STRAPI_URL=http://localhost:1337
3STRAPI_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:
1// ./lib/types.ts
2export interface Article {
3 id: number;
4 documentId: string;
5 title: string;
6 link: string;
7 pubDate: string | null;
8 contentSnippet: string | null;
9 content: string | null;
10 isRead: boolean;
11}
12
13export interface Feed {
14 id: number;
15 documentId: string;
16 title: string;
17 xmlUrl: string;
18 siteLink: string | null;
19 lastFetchedAt: string | null;
20 favicon: string | null;
21 articles?: Article[];
22}
23
24export interface StrapiResponse<T> {
25 data: T;
26 meta: Record<string, unknown>;
27}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:
1npm install qs @types/qs1// ./lib/strapi.ts
2import 'server-only';
3
4const STRAPI_URL = process.env.STRAPI_URL;
5const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
6
7export async function strapiFetch<T>(
8 path: string,
9 options: RequestInit = {}
10): Promise<T> {
11 const res = await fetch(`${STRAPI_URL}${path}`, {
12 ...options,
13 headers: {
14 'Content-Type': 'application/json',
15 Authorization: `Bearer ${STRAPI_API_TOKEN}`,
16 ...options.headers,
17 },
18 });
19
20 if (!res.ok) {
21 throw new Error(`Strapi request failed: ${res.status} ${res.statusText}`);
22 }
23
24 return res.json();
25}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.
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:
1// ./app/page.tsx
2import Link from 'next/link';
3import qs from 'qs';
4import { strapiFetch } from '@/lib/strapi';
5import type { Feed, StrapiResponse } from '@/lib/types';
6
7export default async function HomePage() {
8 const query = qs.stringify(
9 {
10 fields: ['title', 'siteLink'],
11 populate: {
12 articles: {
13 fields: ['title', 'link', 'pubDate', 'contentSnippet', 'isRead'],
14 sort: ['pubDate:desc'],
15 },
16 },
17 },
18 { encodeValuesOnly: true }
19 );
20
21 const { data: feeds } = await strapiFetch<StrapiResponse<Feed[]>>(
22 `/api/feeds?${query}`,
23 { next: { revalidate: 60, tags: ['articles'] } }
24 );
25
26 return (
27 <div className="flex min-h-screen">
28 <aside className="w-64 border-r border-gray-200 p-4">
29 <h2 className="mb-4 text-lg font-semibold">Feeds</h2>
30 <ul className="space-y-2">
31 {feeds.map((feed) => (
32 <li key={feed.documentId}>
33 <span className="text-sm">{feed.title}</span>
34 </li>
35 ))}
36 </ul>
37 </aside>
38
39 <main className="flex-1 p-6">
40 <h1 className="mb-6 text-2xl font-bold">Latest Articles</h1>
41 <div className="space-y-4">
42 {feeds.flatMap((feed) =>
43 (feed.articles ?? []).map((article) => (
44 <Link
45 key={article.documentId}
46 href={`/articles/${article.documentId}`}
47 className="block rounded border border-gray-200 p-4 hover:bg-gray-50"
48 >
49 <h3
50 className={
51 article.isRead
52 ? 'font-normal text-gray-500'
53 : 'font-semibold'
54 }
55 >
56 {article.title}
57 </h3>
58 <p className="mt-1 text-sm text-gray-600">
59 {article.contentSnippet?.slice(0, 160)}
60 </p>
61 </Link>
62 ))
63 )}
64 </div>
65 </main>
66 </div>
67 );
68}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.
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:
1// ./app/articles/[documentId]/page.tsx
2import Link from 'next/link';
3import { notFound } from 'next/navigation';
4import { strapiFetch } from '@/lib/strapi';
5import type { Article, StrapiResponse } from '@/lib/types';
6import { ReadToggle } from '@/app/components/read-toggle';
7
8export default async function ArticlePage({
9 params,
10}: {
11 params: Promise<{ documentId: string }>;
12}) {
13 const { documentId } = await params;
14
15 let article: Article;
16 try {
17 const { data } = await strapiFetch<StrapiResponse<Article>>(
18 `/api/articles/${documentId}`,
19 { next: { tags: ['articles'] } }
20 );
21 article = data;
22 } catch {
23 notFound();
24 }
25
26 return (
27 <article className="mx-auto max-w-2xl p-6">
28 <Link href="/" className="text-sm text-blue-600 hover:underline">
29 ← Back to feed
30 </Link>
31
32 <h1 className="mt-4 text-3xl font-bold">{article.title}</h1>
33
34 {article.pubDate && (
35 <time className="mt-2 block text-sm text-gray-500">
36 {new Date(article.pubDate).toLocaleDateString()}
37 </time>
38 )}
39
40 <div className="mt-6 flex items-center gap-4">
41 <a
42 href={article.link}
43 target="_blank"
44 rel="noopener noreferrer"
45 className="text-sm text-blue-600 hover:underline"
46 >
47 View original
48 </a>
49 <ReadToggle documentId={article.documentId} isRead={article.isRead} />
50 </div>
51
52 <div
53 className="prose mt-8"
54 dangerouslySetInnerHTML={{
55 __html: article.content ?? article.contentSnippet ?? '',
56 }}
57 />
58 </article>
59 );
60}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:
1// ./app/actions/article-actions.ts
2'use server';
3
4import { revalidateTag } from 'next/cache';
5
6export async function toggleReadStatus(
7 documentId: string,
8 currentIsRead: boolean
9) {
10 const res = await fetch(
11 `${process.env.STRAPI_URL}/api/articles/${documentId}`,
12 {
13 method: 'PUT',
14 headers: {
15 'Content-Type': 'application/json',
16 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
17 },
18 body: JSON.stringify({ data: { isRead: !currentIsRead } }),
19 }
20 );
21
22 if (!res.ok) {
23 throw new Error('Failed to update article');
24 }
25
26 revalidateTag('articles');
27}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:
1// ./app/components/read-toggle.tsx
2'use client';
3
4import { toggleReadStatus } from '@/app/actions/article-actions';
5
6export function ReadToggle({
7 documentId,
8 isRead,
9}: {
10 documentId: string;
11 isRead: boolean;
12}) {
13 return (
14 <form action={toggleReadStatus.bind(null, documentId, isRead)}>
15 <button
16 type="submit"
17 className="rounded border border-gray-300 px-3 py-1 text-sm hover:bg-gray-50"
18 >
19 {isRead ? 'Mark as unread' : 'Mark as read'}
20 </button>
21 </form>
22 );
23}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.
Time to run both halves and watch articles flow through. Start the Strapi backend first:
1cd rss-backend
2npm run developStrapi boots on http://localhost:1337. The cron task registers automatically and fires every 30 minutes. In a second terminal, start the frontend:
1cd rss-frontend
2npm run devNext.js serves on http://localhost:3000. Subscribe to a feed by calling the custom subscribe endpoint:
1curl -X POST http://localhost:1337/api/feeds/subscribe \
2 -H "Content-Type: application/json" \
3 -H "Authorization: Bearer YOUR_API_TOKEN" \
4 -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:
1{
2 "feed": {
3 "documentId": "bw64dnu97i56nq85106yt4du",
4 "title": "Example Blog",
5 "xmlUrl": "https://example.com/rss"
6 },
7 "articlesAdded": 12
8}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:
1curl -X POST http://localhost:1337/api/feeds/bw64dnu97i56nq85106yt4du/refresh \
2 -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.
You have a working reader, and there are several directions to extend it:
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.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.