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:
- Model a social graph with one extended User model and three Collection Types: Post, Follow, and Activity.
- Use Strapi 5 Document Service middlewares to auto-generate Activity records on post creation and follows.
- Expose a custom
/api/feedendpoint that returns a personalized, paginated timeline per user. - Wire the feed into a Next.js 16 frontend with JWT auth stored in HTTP-only cookies and SWR-based polling.
What You'll Build
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).
Step 1: Bootstrap the Strapi 5 Backend
Run the following to scaffold a new Strapi 5 project (see CLI guide for details):
npx 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:
cd 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.
Step 2: Design the Social Graph
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.
Extend the User Profile
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:
{
"kind": "collectionType",
"collectionName": "up_users",
"info": {
"singularName": "user",
"pluralName": "users",
"displayName": "User"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"displayName": {
"type": "string"
},
"bio": {
"type": "text"
},
"avatar": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": ["images"]
}
}
}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:
module.exports = {
'users-permissions': {
config: {
register: {
allowedFields: ['displayName', 'bio'],
},
},
},
};Create the Post Content-Type
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:
{
"kind": "collectionType",
"collectionName": "posts",
"info": {
"singularName": "post",
"pluralName": "posts",
"displayName": "Post"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"body": {
"type": "text",
"required": true
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}Create the Follow Content-Type
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.
{
"kind": "collectionType",
"collectionName": "follows",
"info": {
"singularName": "follow",
"pluralName": "follows",
"displayName": "Follow"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"follower": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"following": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}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:
const existing = await strapi.documents('api::follow.follow').findMany({
filters: { follower: userId, following: targetUserId },
});
if (existing.length > 0) {
return ctx.badRequest('Already following this user');
}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.
Create the Activity Content-Type
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.
{
"kind": "collectionType",
"collectionName": "activities",
"info": {
"singularName": "activity",
"pluralName": "activities",
"displayName": "Activity"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"verb": {
"type": "enumeration",
"enum": ["posted", "followed", "liked"],
"required": true
},
"actor": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"targetPost": {
"type": "relation",
"relation": "manyToOne",
"target": "api::post.post"
},
"targetUser": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}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.
Step 3: Auto-Generate Activities with Document Service Middlewares
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.
Set Up the Global Document Service Middleware
Document Service middlewares must be registered during Strapi's register() phase. Open src/index.ts and call strapi.documents.use():
export default {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();
// post-action logic here
return result;
});
},
};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.
Trigger Activities on Post Creation and Follows
Inside the middleware, branch on context.uid and context.action to customize the Strapi backend with automatic activity generation:
// src/index.ts
export default {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();
// Auto-create activity when a Post is created
if (
context.uid === 'api::post.post' &&
context.action === 'create' &&
result?.documentId
) {
const requestContext = strapi.requestContext.get();
const userId = requestContext?.state?.user?.id;
if (userId) {
await strapi.documents('api::activity.activity').create({
data: {
verb: 'posted',
actor: userId,
targetPost: result.documentId,
},
});
}
}
// Auto-create activity when a Follow is created
if (
context.uid === 'api::follow.follow' &&
context.action === 'create' &&
result?.documentId
) {
const requestContext = strapi.requestContext.get();
const userId = requestContext?.state?.user?.id;
if (userId) {
await strapi.documents('api::activity.activity').create({
data: {
verb: 'followed',
actor: userId,
targetUser: result.following?.documentId,
},
});
}
}
return result;
});
},
};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:
- When a user creates a post through the REST API, the document service middleware can run before and/or after the document is written.
- It reads the authenticated user from
strapi.requestContext.get(), constructs an Activity record withverb: 'posted'and a relation to the new post, and writes it to the database. - The user never sees this happen. From their perspective, they posted. The activity record is a side effect.
- The same pattern applies to follows: the Follow record is written first, then the middleware creates an Activity with
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.
Step 4: Build the Personalized Feed Endpoint
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.
Define the Custom Route
Add a custom route file that loads before the default Activity routes. In src/api/activity/routes/, create 01-feed.ts:
// src/api/activity/routes/01-feed.ts
export default {
routes: [
{
method: 'GET',
path: '/api/feed',
handler: 'api::activity.activity.feed',
config: {
policies: ['plugin::users-permissions.isAuthenticated'],
},
},
],
};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.
Implement the Controller
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):
// src/api/activity/controllers/activity.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::activity.activity', ({ strapi }) => ({
async feed(ctx) {
const userId = ctx.state.user.id;
const contentType = strapi.contentType('api::activity.activity');
// 1. Get the users this person follows
const follows = await strapi.documents('api::follow.follow').findMany({
filters: { follower: userId },
populate: ['following'],
});
const followingIds = follows
.map((f) => f.following?.documentId)
.filter(Boolean);
if (followingIds.length === 0) {
return [];
}
// 2. Fetch activities authored by those users
const activities = await strapi.documents('api::activity.activity').findMany({
filters: {
actor: {
documentId: { $in: followingIds },
},
},
populate: {
actor: { fields: ['username', 'displayName'] },
targetPost: { fields: ['body'] },
targetUser: { fields: ['username', 'displayName'] },
},
sort: 'createdAt:desc',
limit: 20,
start: parseInt(ctx.query.start as string) || 0,
});
// 3. Sanitize output before returning
return strapi.contentAPI.sanitize.output(activities, contentType, {
auth: ctx.state.auth,
});
},
}));Pagination is explicit here because feeds are often paginated. Pass ?start=20 for the next page.
Performance Considerations
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:
curl -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.
Step 5: Wire Up Next.js 16 with Authentication
Set Up the Next.js 16 Project
npx create-next-app@latest social-feed-frontend --typescript --tailwind --appAdd a STRAPI_URL environment variable to .env.local:
STRAPI_URL=http://localhost:1337Implement Login and JWT Storage
Create 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."
// app/lib/actions/auth.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function loginAction(formData: FormData) {
const identifier = formData.get('identifier') as string;
const password = formData.get('password') as string;
const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
if (!res.ok) {
return { error: 'Invalid credentials' };
}
const data = await res.json();
const cookieStore = await cookies();
cookieStore.set('strapi-jwt', data.jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
redirect('/feed');
}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.
// app/login/page.tsx
import { loginAction } from '@/app/lib/actions/auth'
export default function LoginPage() {
return (
<form action={loginAction}>
<input type="email" name="identifier" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Sign In</button>
</form>
);
}Implement Registration
Registration follows the same pattern via /api/auth/local/register. The server action POSTs username, email, password, and any whitelisted custom fields:
// app/lib/actions/auth.ts (add below loginAction)
export async function registerAction(formData: FormData) {
const username = formData.get('username') as string;
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const displayName = formData.get('displayName') as string;
const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password, displayName }),
});
if (!res.ok) {
return { error: 'Registration failed' };
}
const data = await res.json();
const cookieStore = await cookies();
cookieStore.set('strapi-jwt', data.jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
redirect('/feed');
}The registration form mirrors the login form with additional fields:
// app/register/page.tsx
import { registerAction } from '@/app/lib/actions/auth'
export default function RegisterPage() {
return (
<form action={registerAction}>
<input type="text" name="username" placeholder="Username" required />
<input type="text" name="displayName" placeholder="Display Name" required />
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Create Account</button>
</form>
);
}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.
Step 6: Render the Activity Feed
Server-Side Feed Rendering
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.
// app/feed/page.tsx
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { FeedList } from '@/app/components/FeedList'
export default async function FeedPage() {
const cookieStore = await cookies();
const jwt = cookieStore.get('strapi-jwt')?.value;
if (!jwt) redirect('/login');
const res = await fetch(`${process.env.STRAPI_URL}/api/feed`, {
headers: { Authorization: `Bearer ${jwt}` },
cache: 'no-store',
});
const activities = await res.json();
return <FeedList activities={activities} />;
}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.
// app/components/FeedList.tsx
'use client'
export function FeedList({ activities }: { activities: any[] }) {
if (!activities?.length) return <p>Follow some people to see their activity here.</p>;
return (
<ul>
{activities.map((activity: any) => (
<li key={activity.documentId}>
{activity.verb === 'posted' && (
<p><strong>{activity.actor?.username}</strong> posted: {activity.targetPost?.body}</p>
)}
{activity.verb === 'followed' && (
<p><strong>{activity.actor?.username}</strong> followed {activity.targetUser?.username}</p>
)}
</li>
))}
</ul>
);
}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.
Step 7: Add Real-Time Updates
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:
// app/api/feed/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function GET() {
const cookieStore = await cookies();
const jwt = cookieStore.get('strapi-jwt')?.value;
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const res = await fetch(`${process.env.STRAPI_URL}/api/feed`, {
headers: { Authorization: `Bearer ${jwt}` },
cache: 'no-store',
});
const data = await res.json();
return NextResponse.json(data);
}Then poll from a Client Component:
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function FeedPoller() {
const { data } = useSWR('/api/feed', fetcher, { refreshInterval: 5000 });
// render data...
}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.
Where to Take This Activity Feed Next
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:
- Document Service middlewares can generate audit logs, notification records, or analytics events using the same after-action hook.
- The fan-in-on-read feed query works for any timeline where users subscribe to content sources: team dashboards, project activity streams, or notification inboxes.
- The JWT-in-cookie pattern with a Route Handler proxy applies whenever a Next.js 16 frontend talks to any token-authenticated API.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.