Every social product needs a feed. You can fetch posts from a database easily enough, but the real question is: how do you assemble this user's timeline, showing only the activity from people they follow, ordered by recency? That's where things get interesting.
Building this from scratch usually means juggling three concerns at once: modeling the social graph, who follows whom, writing activity records when something happens, a post or a follow, and querying those records efficiently at read time. Most tutorials hand-wave through at least one of these.
This tutorial walks through all three. You'll build a social network with Strapi and Next.js that handles them in one implementation. Strapi 5 holds the content model, and Document Service middlewares can be used to run custom actions during content operations, such as generating activity records automatically. Next.js 16, App Router, handles auth and rendering. A custom /api/feed endpoint does a fan-in query on follow relationships so each user sees a personalized timeline.
By the end, you'll have a working app where a logged-in user sees only posts and follow events from people they follow. If you're new to pairing Strapi 5 and Next.js 16, start there for the basics.
In brief:
/api/feed endpoint that returns a personalized, paginated timeline per user. The architecture: Strapi 5 (REST API + Document Service) runs the backend. Next.js 16, App Router and Server Components, handles the frontend. SQLite works for development. PostgreSQL works for production. Activities are generated server-side by Document Service middlewares and queried through a custom /api/feed endpoint.
The Document Service middleware pattern keeps activity generation decoupled from the REST controllers, so adding new activity types later requires no controller changes. This is a headless CMS architecture where Strapi owns the data layer and Next.js 16 owns the presentation.
Prerequisites: Node.js v20, v22, or v24 (active LTS versions supported by Strapi 5), familiarity with React Server Components, and a basic understanding of REST APIs.
What's out of scope: Image uploads, push notifications, comment threads. The tutorial stays focused on the feed mechanic itself.
Here's the data flow in one sentence: a user posts, a Document Service middleware writes an Activity record referencing the author, and followers' feeds query Activities filtered by actor IN (people they follow).
Run the following to scaffold a new Strapi 5 project (see CLI guide for details):
1npx create-strapi@latest social-feed-backendAccept the interactive prompts. Strapi 5 detects your package manager automatically, and SQLite is fine for development.
Start the dev server:
1cd social-feed-backend && npm run developConfirm the Admin Panel loads at http://localhost:1337/admin. Register your first admin user.
Next, open Settings → Users & Permissions Plugin → Roles → Authenticated. You'll grant permissions to custom content types in Step 2. For now, note that JWT configuration is managed through Strapi's plugins configuration, and that the Users and Permissions plugin handles registration, login, and JWT issuance out of the box. No custom auth code needed.
Quick sanity check: if curl http://localhost:1337/api/users/me returns 401 Unauthorized, your auth layer is wired correctly.
Before you start creating files, orient yourself around the generated project structure. The src/api/ directory is where your custom content types live, each with its own content-types/, controllers/, routes/, and services/ subdirectories.
The src/extensions/ directory is where you extend installed plugins like Users and Permissions. The config/ directory holds server, database, middleware, and plugin configuration files. The entry point src/index.ts exposes register() and bootstrap() lifecycle functions, which you'll use in Step 3 to wire up Document Service middlewares.
The relationships you define here determine what queries are possible and how expensive the feed becomes. Three Collection Types plus the extended User model make up the social graph.
The User type already exists from the Users & Permissions plugin. Attempting to extend it by creating the file src/extensions/users-permissions/content-types/user/schema.json does not work as expected in Strapi 5, because changes to that schema file are not reflected:
1{
2 "kind": "collectionType",
3 "collectionName": "up_users",
4 "info": {
5 "singularName": "user",
6 "pluralName": "users",
7 "displayName": "User"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "displayName": {
14 "type": "string"
15 },
16 "bio": {
17 "type": "text"
18 },
19 "avatar": {
20 "type": "media",
21 "multiple": false,
22 "required": false,
23 "allowedTypes": ["images"]
24 }
25 }
26}Define the content-type schema in the extension file, or use strapi-server.js|ts to programmatically spread existing attributes and add or override fields.
One detail most developers miss: in Strapi 5, custom fields added to the User model are not automatically accepted at registration. Whitelist them in config/plugins.js:
1module.exports = {
2 'users-permissions': {
3 config: {
4 register: {
5 allowedFields: ['displayName', 'bio'],
6 },
7 },
8 },
9};Use the Content-Type Builder to define a Post Collection Type with body (text) and author (many-to-one relation with User). Strapi adds createdAt and publishedAt timestamps automatically. The resulting schema.json:
1{
2 "kind": "collectionType",
3 "collectionName": "posts",
4 "info": {
5 "singularName": "post",
6 "pluralName": "posts",
7 "displayName": "Post"
8 },
9 "options": {
10 "draftAndPublish": true
11 },
12 "attributes": {
13 "body": {
14 "type": "text",
15 "required": true
16 },
17 "author": {
18 "type": "relation",
19 "relation": "manyToOne",
20 "target": "plugin::users-permissions.user"
21 }
22 }
23}A Follow Collection Type with two relations to User: follower and following. Strapi doesn't enforce composite uniqueness in the schema, so you need to handle duplicate-follow prevention yourself.
1{
2 "kind": "collectionType",
3 "collectionName": "follows",
4 "info": {
5 "singularName": "follow",
6 "pluralName": "follows",
7 "displayName": "Follow"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "follower": {
14 "type": "relation",
15 "relation": "manyToOne",
16 "target": "plugin::users-permissions.user"
17 },
18 "following": {
19 "type": "relation",
20 "relation": "manyToOne",
21 "target": "plugin::users-permissions.user"
22 }
23 }
24}The simplest way to prevent duplicate follows is a check before creation. In a beforeCreate lifecycle hook or a route policy, query for an existing Follow with the same pair:
1const existing = await strapi.documents('api::follow.follow').findMany({
2 filters: { follower: userId, following: targetUserId },
3});
4if (existing.length > 0) {
5 return ctx.badRequest('Already following this user');
6}Use validation at the appropriate layer to reduce duplicate inserts.
With these three Collection Types defined, the social graph takes shape. Users create Posts, one-to-many via the author relation. Users create Follows pointing at other Users, two many-to-one relations. The Activity type, defined next, ties everything together into a single queryable stream.
This is the core of the feed. Understanding content modeling matters here: a single denormalized Activity table beats querying Posts + Follows + Likes separately at read time. Feeds are read-heavy, and one table with proper indexing means one query per feed load instead of three.
The Activity schema uses direct relations (targetPost, targetUser) rather than generic string references like objectType and objectId. Relations give you Strapi's built-in populate and filtering capabilities. You can write populate: ['targetPost'] and get the full post object back without a second query.
Generic string references are more flexible when you have dozens of object types, but for a feed with three or four verb types, typed relations are the better choice. They're type-safe, they work with Strapi's permission system, and they make the feed controller simpler.
1{
2 "kind": "collectionType",
3 "collectionName": "activities",
4 "info": {
5 "singularName": "activity",
6 "pluralName": "activities",
7 "displayName": "Activity"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "verb": {
14 "type": "enumeration",
15 "enum": ["posted", "followed", "liked"],
16 "required": true
17 },
18 "actor": {
19 "type": "relation",
20 "relation": "manyToOne",
21 "target": "plugin::users-permissions.user"
22 },
23 "targetPost": {
24 "type": "relation",
25 "relation": "manyToOne",
26 "target": "api::post.post"
27 },
28 "targetUser": {
29 "type": "relation",
30 "relation": "manyToOne",
31 "target": "plugin::users-permissions.user"
32 }
33 }
34}Note that draftAndPublish is false here. Activities are internal records, not editorial content. Disabling draft/publish means every created Activity is immediately visible, with no need to call a separate publish() action.
After creating all four Content-Types, go back to Settings → Users & Permissions Plugin → Roles → Authenticated and grant create and read permissions for Post, Follow, and Activity. For a deeper look at permissions guide, see the dedicated guide.
You have three options for writing activity records: do it in the frontend, which is fragile and easy to bypass, do it in a custom controller, which duplicates logic across endpoints, or do it in a Document Service middleware, which runs every time a post or follow is created through any path: REST, GraphQL, Admin Panel, or programmatic calls.
The middleware approach is usually the cleanest fit here. See middlewares reference for the full API.
Document Service middlewares must be registered during Strapi's register() phase. Open src/index.ts and call strapi.documents.use():
1export default {
2 register({ strapi }) {
3 strapi.documents.use(async (context, next) => {
4 const result = await next();
5 // post-action logic here
6 return result;
7 });
8 },
9};The context object gives you what you need to branch: context.action (create, update, delete), context.uid (the Content-Type UID), and context.params (the input data). Always return the result of next(). Omitting that return breaks Strapi.
Inside the middleware, branch on context.uid and context.action to customize the Strapi backend with automatic activity generation:
1// src/index.ts
2export default {
3 register({ strapi }) {
4 strapi.documents.use(async (context, next) => {
5 const result = await next();
6
7 // Auto-create activity when a Post is created
8 if (
9 context.uid === 'api::post.post' &&
10 context.action === 'create' &&
11 result?.documentId
12 ) {
13 const requestContext = strapi.requestContext.get();
14 const userId = requestContext?.state?.user?.id;
15
16 if (userId) {
17 await strapi.documents('api::activity.activity').create({
18 data: {
19 verb: 'posted',
20 actor: userId,
21 targetPost: result.documentId,
22 },
23 });
24 }
25 }
26
27 // Auto-create activity when a Follow is created
28 if (
29 context.uid === 'api::follow.follow' &&
30 context.action === 'create' &&
31 result?.documentId
32 ) {
33 const requestContext = strapi.requestContext.get();
34 const userId = requestContext?.state?.user?.id;
35
36 if (userId) {
37 await strapi.documents('api::activity.activity').create({
38 data: {
39 verb: 'followed',
40 actor: userId,
41 targetUser: result.following?.documentId,
42 },
43 });
44 }
45 }
46
47 return result;
48 });
49 },
50};This is the same concept as the lifecycle hooks, but in Strapi 5, Document Service middlewares are the recommended approach.
Here's the execution flow step by step:
strapi.requestContext.get(), constructs an Activity record with verb: 'posted' and a relation to the new post, and writes it to the database. verb: 'followed' and a reference to the followed user.One honest caveat: bulk Document Service operations (createMany, etc.) skip middlewares entirely. Design around single-document writes for activity-generating actions, or fan out activities explicitly in a controller for batch cases.
The feed query boils down to: get all activities where the actor is in the set of users I follow. This is the "fan-in-on-read" pattern. One query, no background workers, no denormalized feed tables.
Add a custom route file that loads before the default Activity routes. In src/api/activity/routes/, create 01-feed.ts:
1// src/api/activity/routes/01-feed.ts
2export default {
3 routes: [
4 {
5 method: 'GET',
6 path: '/api/feed',
7 handler: 'api::activity.activity.feed',
8 config: {
9 policies: ['plugin::users-permissions.isAuthenticated'],
10 },
11 },
12 ],
13};Route files load in alphabetical order, so the 01- prefix keeps this ahead of the default activity.ts routes. The isAuthenticated policy gates the endpoint so only logged-in users can hit it. This approach follows the same principles as access control.
Override the Activity controller and add the feed action. Note the sanitize.output() call, which is required when calling strapi.documents() directly in custom controllers or plugin routes (see Strapi's sanitization/controller docs for more context):
1// src/api/activity/controllers/activity.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController('api::activity.activity', ({ strapi }) => ({
5 async feed(ctx) {
6 const userId = ctx.state.user.id;
7 const contentType = strapi.contentType('api::activity.activity');
8
9 // 1. Get the users this person follows
10 const follows = await strapi.documents('api::follow.follow').findMany({
11 filters: { follower: userId },
12 populate: ['following'],
13 });
14 const followingIds = follows
15 .map((f) => f.following?.documentId)
16 .filter(Boolean);
17
18 if (followingIds.length === 0) {
19 return [];
20 }
21
22 // 2. Fetch activities authored by those users
23 const activities = await strapi.documents('api::activity.activity').findMany({
24 filters: {
25 actor: {
26 documentId: { $in: followingIds },
27 },
28 },
29 populate: {
30 actor: { fields: ['username', 'displayName'] },
31 targetPost: { fields: ['body'] },
32 targetUser: { fields: ['username', 'displayName'] },
33 },
34 sort: 'createdAt:desc',
35 limit: 20,
36 start: parseInt(ctx.query.start as string) || 0,
37 });
38
39 // 3. Sanitize output before returning
40 return strapi.contentAPI.sanitize.output(activities, contentType, {
41 auth: ctx.state.auth,
42 });
43 },
44}));Pagination is explicit here because feeds are often paginated. Pass ?start=20 for the next page.
The $in filter on actor documentIds translates to a SQL WHERE IN clause at the database level. For feeds where a user follows a few hundred people, this query remains fast with proper indexing on the actor relation column. Strapi uses Knex under the hood, so you can add database indexes through a custom Knex migration if query times grow.
The fan-in-on-read approach works well up to the point where individual users follow thousands of accounts and your activity table holds millions of rows. At that scale, you'd move to fan-out-on-write: pre-computing per-user feed tables with a background job queue like BullMQ or pg-boss. Instagram uses exactly this hybrid: fan-out-on-write for normal users, fan-in-on-read for high-follower accounts.
For a tutorial-scale app, fan-in-on-read is a practical starting point.
Test it:
1curl -H "Authorization: Bearer $TOKEN" http://localhost:1337/api/feedYou should get back an array of Activity objects, each with populated actor, targetPost, or targetUser fields.
1npx create-next-app@latest social-feed-frontend --typescript --tailwind --appAdd a STRAPI_URL environment variable to .env.local:
1STRAPI_URL=http://localhost:1337Create a server action that POSTs to Strapi's /api/auth/local, receives { jwt, user }, and stores the JWT in an cookies API. HTTP-only cookies are inaccessible to client-side JavaScript, which eliminates an entire class of XSS attacks compared to localStorage.
One critical note for Next.js 16: cookies() is async. Synchronous access was deprecated in Next.js 15 (with a temporary compatibility shim) and fully removed in 16, so you must await the call before reading or writing cookies — there is no longer a sync fallback that "works in development."
1// app/lib/actions/auth.ts
2'use server'
3
4import { cookies } from 'next/headers'
5import { redirect } from 'next/navigation'
6
7export async function loginAction(formData: FormData) {
8 const identifier = formData.get('identifier') as string;
9 const password = formData.get('password') as string;
10
11 const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
12 method: 'POST',
13 headers: { 'Content-Type': 'application/json' },
14 body: JSON.stringify({ identifier, password }),
15 });
16
17 if (!res.ok) {
18 return { error: 'Invalid credentials' };
19 }
20
21 const data = await res.json();
22
23 const cookieStore = await cookies();
24 cookieStore.set('strapi-jwt', data.jwt, {
25 httpOnly: true,
26 secure: process.env.NODE_ENV === 'production',
27 sameSite: 'lax',
28 path: '/',
29 maxAge: 60 * 60 * 24 * 7,
30 });
31
32 redirect('/feed');
33}The login form is minimal. For a deeper dive, see the email auth guide, the JWT auth guide walkthrough, or the Next.js 16 auth guide tutorial.
1// app/login/page.tsx
2import { loginAction } from '@/app/lib/actions/auth'
3
4export default function LoginPage() {
5 return (
6 <form action={loginAction}>
7 <input type="email" name="identifier" placeholder="Email" required />
8 <input type="password" name="password" placeholder="Password" required />
9 <button type="submit">Sign In</button>
10 </form>
11 );
12}Registration follows the same pattern via /api/auth/local/register. The server action POSTs username, email, password, and any whitelisted custom fields:
1// app/lib/actions/auth.ts (add below loginAction)
2export async function registerAction(formData: FormData) {
3 const username = formData.get('username') as string;
4 const email = formData.get('email') as string;
5 const password = formData.get('password') as string;
6 const displayName = formData.get('displayName') as string;
7
8 const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local/register`, {
9 method: 'POST',
10 headers: { 'Content-Type': 'application/json' },
11 body: JSON.stringify({ username, email, password, displayName }),
12 });
13
14 if (!res.ok) {
15 return { error: 'Registration failed' };
16 }
17
18 const data = await res.json();
19
20 const cookieStore = await cookies();
21 cookieStore.set('strapi-jwt', data.jwt, {
22 httpOnly: true,
23 secure: process.env.NODE_ENV === 'production',
24 sameSite: 'lax',
25 path: '/',
26 maxAge: 60 * 60 * 24 * 7,
27 });
28
29 redirect('/feed');
30}The registration form mirrors the login form with additional fields:
1// app/register/page.tsx
2import { registerAction } from '@/app/lib/actions/auth'
3
4export default function RegisterPage() {
5 return (
6 <form action={registerAction}>
7 <input type="text" name="username" placeholder="Username" required />
8 <input type="text" name="displayName" placeholder="Display Name" required />
9 <input type="email" name="email" placeholder="Email" required />
10 <input type="password" name="password" placeholder="Password" required />
11 <button type="submit">Create Account</button>
12 </form>
13 );
14}For logout, create a server action that clears the strapi-jwt cookie and redirects to the login page. Call cookieStore.delete('strapi-jwt') inside a logoutAction server action. This is sufficient because Strapi JWTs are stateless, so there is generally no traditional server-side session to invalidate.
Remember that displayName works here only if you've configured Strapi to accept and expose that custom field earlier. The JWT goes in the Authorization: Bearer header for all subsequent feed requests.
Build a /feed page as a Server Component that reads the JWT from cookies, fetches /api/feed from Strapi server-side, and passes the array to a Client Component for rendering. Use cache: 'no-store' if the feed should be fetched fresh on every request rather than cached at the Next.js 16 layer.
1// app/feed/page.tsx
2import { cookies } from 'next/headers'
3import { redirect } from 'next/navigation'
4import { FeedList } from '@/app/components/FeedList'
5
6export default async function FeedPage() {
7 const cookieStore = await cookies();
8 const jwt = cookieStore.get('strapi-jwt')?.value;
9
10 if (!jwt) redirect('/login');
11
12 const res = await fetch(`${process.env.STRAPI_URL}/api/feed`, {
13 headers: { Authorization: `Bearer ${jwt}` },
14 cache: 'no-store',
15 });
16
17 const activities = await res.json();
18
19 return <FeedList activities={activities} />;
20}The <FeedList /> Client Component maps over activities and conditionally renders cards based on verb. For type-safe requests, consider generating types from your Strapi schema.
1// app/components/FeedList.tsx
2'use client'
3
4export function FeedList({ activities }: { activities: any[] }) {
5 if (!activities?.length) return <p>Follow some people to see their activity here.</p>;
6
7 return (
8 <ul>
9 {activities.map((activity: any) => (
10 <li key={activity.documentId}>
11 {activity.verb === 'posted' && (
12 <p><strong>{activity.actor?.username}</strong> posted: {activity.targetPost?.body}</p>
13 )}
14 {activity.verb === 'followed' && (
15 <p><strong>{activity.actor?.username}</strong> followed {activity.targetUser?.username}</p>
16 )}
17 </li>
18 ))}
19 </ul>
20 );
21}Run both servers and confirm the feed renders the right activities for the logged-in user, and only those.
A production app would add skeleton loaders for the loading state and a more informative empty state. You could also implement infinite scroll by tracking the start parameter in component state and fetching more activities when the user reaches the bottom of the list. Each subsequent fetch appends results to the existing array, passing ?start=20, then ?start=40, and so on.
The feed page from Step 6 loads once on navigation. For a social app, users expect new content to appear without a manual refresh. Three approaches work with Strapi, ranked by complexity.
1. SWR polling is good enough for most apps. Because the JWT lives in an HTTP-only cookie, client-side SWR can't attach the Bearer token directly. Proxy through a Next.js 16 Route Handler:
1// app/api/feed/route.ts
2import { cookies } from 'next/headers'
3import { NextResponse } from 'next/server'
4
5export async function GET() {
6 const cookieStore = await cookies();
7 const jwt = cookieStore.get('strapi-jwt')?.value;
8 if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
9
10 const res = await fetch(`${process.env.STRAPI_URL}/api/feed`, {
11 headers: { Authorization: `Bearer ${jwt}` },
12 cache: 'no-store',
13 });
14 const data = await res.json();
15 return NextResponse.json(data);
16}Then poll from a Client Component:
1'use client'
2import useSWR from 'swr'
3
4const fetcher = (url: string) => fetch(url).then((r) => r.json());
5
6export function FeedPoller() {
7 const { data } = useSWR('/api/feed', fetcher, { refreshInterval: 5000 });
8 // render data...
9}2. Server-Sent Events: Add a custom Strapi controller that responds with Content-Type: text/event-stream and holds the HTTP connection open. When new Activity records are created, the controller writes them as SSE data frames. On the client side, an EventSource instance listens on the SSE endpoint and appends new activities to the feed in real time. This gives you sub-second latency without a third-party dependency, but requires managing open connections on the Strapi server.
3. External real-time service: InstantDB, Pusher, or Ably for sub-second updates. See the real-time updates tutorial for that approach.
Strapi has no built-in WebSocket layer, so option 1 covers most use cases without additional infrastructure.
You now have a working social network where users can post, follow each other, and see a personalized activity feed. Document Service middlewares auto-generate Activity records, a custom /api/feed endpoint assembles each user's timeline via fan-in-on-read, and Next.js 16 renders it with JWT auth and SWR polling.
Two concrete next steps: add a Like Content-Type and use the liked verb value already in the Activity enum, about 15 minutes of work given everything you've built, or move the feed query to a denormalized per-user feed table for scale once the follower graph grows past a few thousand users.
The patterns here transfer directly to other content-driven apps:
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.