Businesses routinely need to show different content to different audiences. Customers want their orders and support tickets. Partners want shared resources and deal pipelines. Unauthenticated visitors should see none of it. Most teams solve this by spinning up separate applications or layering custom backend logic that gets harder to maintain over time.
This tutorial takes a different approach: a single portal application where Strapi 5 works as the headless CMS for content modeling, permissions, and API delivery, while Next.js 16 handles authentication, routing, and role-gated rendering. Customers see their tickets. Partners see their deals. One codebase serves both audiences with role-based content separation enforced at every layer.
In Brief:
You'll need Node.js 20.9 or later since Next.js 16 requires it as the minimum LTS version (and Strapi 5 also targets Active or Maintenance LTS releases), plus working installs of Strapi 5 and Next.js 16 before you start. Confirm you have the following ready:
The Strapi backend provides the content models, role configuration, and API layer that the portal depends on. Scaffold a fresh Strapi 5 project with the following command:
1npx create-strapi@latest portal-backendFollow the prompts to choose your database (SQLite works fine for development) and wait for the installation to finish. Once complete, cd portal-backend and run npm run develop. Strapi starts at http://localhost:1337. Create your first admin account through the registration screen.
With the Admin Panel running, you're ready to define the data structures that power your portal in a headless CMS setup.
Content Types define the shape of your data in Strapi 5. Each Collection Type you create generates its own REST API endpoints, admin panel entry screens, and permission rules automatically.
For this portal, you need three Collection Types: one for customer support tickets, one for shared resources accessible to both audiences, and one for the partner deal pipeline. Open the Content-Type Builder from the left navigation and create the following:
Ticket
The Ticket Collection Type stores customer support requests. Each ticket links to the user who submitted it through a relation field, and tracks resolution progress with a status enumeration. Add the following fields:
subject: Text description: Rich Text (Markdown) status: Enumeration with values open, in-progress, resolved customer: Relation (many-to-one) → User (plugin::users-permissions.user)Resource
Resources are shared documents and files available to customers, partners, or both. A visibility field controls which portal audience can access each entry. Add the following fields:
title: Text file: Media (single file) category: Enumeration with values docs, marketing, technical visibility: Enumeration with values customer, partner, allDeal
The Deal Collection Type tracks the partner sales pipeline. Each deal links to the partner who owns it and moves through stages from prospecting to close. Add the following fields:
name: Text value: Number (decimal) stage: Enumeration with values prospecting, negotiation, closed-won, closed-lost partner: Relation (many-to-one) → User (plugin::users-permissions.user)Each Relation field targets plugin::users-permissions.user. In the schema file, that looks like this:
1"customer": {
2 "type": "relation",
3 "relation": "manyToOne",
4 "target": "plugin::users-permissions.user"
5}Click Save after creating each type. in development mode, Strapi hot-reloads automatically and the first restart after a schema change typically takes a few seconds longer than usual.
Strapi 5 uses documentId (a stable string identifier) instead of the numeric id from v4 for content entries in API operations. You'll reference documentId throughout your content queries. For user relations, keep your checks aligned with the user object returned by authentication.
Strapi has two distinct role systems, and confusing them is a common mistake. Admin Role-Based Access Control (RBAC) governs admin panel users (editors, authors). The Users and Permissions plugin governs end users of your API, which is what you need here.
The plugin ships with two default end-user roles: Authenticated and Public. In this portal, the important part is mapping access rules so customer-facing users can reach Tickets and Resources, partner-facing users can reach Deals and Resources, and public users reach none of it.
Configure the permissions in Settings → Users and Permissions plugin → Roles so your portal users only get access to the routes they need.
For customer-facing access:
find and findOne on Ticket and Resource For partner-facing access:
find and findOne on Resource and Deal The permissions grid shows bound API routes on the right panel as you toggle checkboxes. This visual confirmation helps verify that each role can only reach the endpoints you intend.
Fine-grained API-level permissions matter here because they form your first line of defense. If a user should not access /api/tickets, the request gets rejected before any controller logic runs.
Permissions control which endpoints a role can call, but they don't filter whose data comes back. A customer-facing user calling GET /api/tickets can still receive every ticket in the system unless you scope the query. Policies help with that.
Policies are functions that run before the controller on each request. They return true to allow the request or false to block it. Create a global policy at ./src/policies/is-owner.js:
1// ./src/policies/is-owner.js
2module.exports = (policyContext, config, { strapi }) => {
3 const user = policyContext.state.user;
4
5 if (!user) {
6 return false;
7 }
8
9 // Inject a filter so the controller only returns entries belonging to this user
10 const relationField = config.relationField || 'customer';
11 policyContext.query = {
12 ...policyContext.query,
13 filters: {
14 ...policyContext.query?.filters,
15 [relationField]: {
16 id: { $eq: user.id },
17 },
18 },
19 };
20
21 return true;
22};The function signature is (policyContext, config, { strapi }). policyContext wraps the controller context and works for both REST and GraphQL. The policy's config parameter receives the route's options object exactly as defined in the route configuration when the policy is attached. policyContext.state.user holds the authenticated user object.
Be aware that returning nothing (undefined) from a policy does not block the request. Always return an explicit false to deny access.
Override the default core router for each Collection Type using createCoreRouter with a config object that attaches your policy:
1// ./src/api/ticket/routes/ticket.js
2const { createCoreRouter } = require('@strapi/strapi').factories;
3
4module.exports = createCoreRouter('api::ticket.ticket', {
5 config: {
6 find: {
7 policies: [
8 { name: 'global::is-owner', config: { relationField: 'customer' } },
9 ],
10 },
11 },
12});Do the same for deals, pointing to the partner relation:
1// ./src/api/deal/routes/deal.js
2const { createCoreRouter } = require('@strapi/strapi').factories;
3
4module.exports = createCoreRouter('api::deal.deal', {
5 config: {
6 find: {
7 policies: [
8 { name: 'global::is-owner', config: { relationField: 'partner' } },
9 ],
10 },
11 },
12});Global policies use the global:: prefix. API-scoped policies use api::api-name.policy-name. The config object you pass at the route level maps directly to the config parameter in the policy function, so config.relationField resolves to 'customer' or 'partner' depending on the route.
If you also need strict owner checks on findOne, use an explicit ownership check in custom controller logic instead of relying on an injected filter alone. An alternative approach is to write a custom controller or Document Service middleware that filters results via strapi.documents('api::ticket.ticket').findMany({ filters: ... }). The trade-off is clear: policies can shape list queries early, while controller-level logic gives you a clearer place to verify ownership before returning a single entry.
The Next.js frontend handles authentication, role-based routing, and data rendering for each portal audience. Scaffold a Next.js 16 project with the following command:
1npx create-next-app@latest portal-frontendThe Next.js 16 CLI defaults to TypeScript, ESLint, Tailwind CSS, the App Router, and Turbopack, so you can accept the defaults at each prompt. The project structure you'll work with:
app/ for routes and layouts lib/ for API helpers and the data access layer context/ for the auth provider app/api/auth/ for Route Handlers that bridge HTTP-only cookiesStrapi's auth endpoints expose /api/auth/local for login and /api/auth/local/register for registration. The login endpoint accepts an identifier (email or username) and password, then returns a JWT and user object.
The JWT should live in an HTTP-only cookie, not localStorage. Client-side JavaScript cannot read HTTP-only cookies, which protects the token from Cross-Site Scripting (XSS) attacks. Since Next.js client components cannot set HTTP-only cookies directly, you need a Route Handler as an intermediary:
1// app/api/auth/login/route.ts
2import { cookies } from 'next/headers'
3
4export async function POST(request: Request) {
5 const { email, password } = await request.json()
6
7 const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
8 method: 'POST',
9 headers: { 'Content-Type': 'application/json' },
10 body: JSON.stringify({ identifier: email, password }),
11 })
12
13 const data = await res.json()
14
15 if (!res.ok) {
16 return new Response(
17 JSON.stringify({ error: 'Invalid credentials' }),
18 { status: 401 }
19 )
20 }
21
22 const cookieStore = await cookies()
23 cookieStore.set('auth_token', data.jwt, {
24 httpOnly: true,
25 secure: process.env.NODE_ENV === 'production',
26 sameSite: 'lax',
27 maxAge: 60 * 60 * 24 * 7,
28 path: '/',
29 })
30
31 return new Response(JSON.stringify({ user: data.user }), { status: 200 })
32}Note that the auth response format doesn't follow the standard data wrapper. It returns { jwt, user } directly.
Build a lib/strapi.ts helper that attaches the Authorization header to every server-side fetch:
1// lib/strapi.ts
2import 'server-only'
3import { cookies } from 'next/headers'
4
5const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337'
6
7async function getToken(): Promise<string | null> {
8 const cookieStore = await cookies()
9 return cookieStore.get('auth_token')?.value ?? null
10}
11
12export async function strapiAuthFetch(
13 endpoint: string,
14 options: RequestInit = {}
15): Promise<Response> {
16 const token = await getToken()
17
18 return fetch(`${STRAPI_URL}/api${endpoint}`, {
19 ...options,
20 headers: {
21 'Content-Type': 'application/json',
22 ...(token ? { Authorization: `Bearer ${token}` } : {}),
23 ...options.headers,
24 },
25 cache: 'no-store',
26 })
27}The cache: 'no-store' option matters for portal data. Every request is user-specific, so caching shared responses would risk leaking data between sessions.
Registration hits /api/auth/local/register. The role assigned to new users depends on the default role setting at Settings → Users and Permissions plugin → Advanced settings.
Programmatic role assignment at registration time is typically customized by extending the Users & Permissions plugin's registration logic via the plugin extension system under src/extensions/users-permissions/. For most portal setups, you'll create users through the admin panel and assign access deliberately.
Wrap the app in a context provider that exposes user, role, login(), logout(), and isLoading. Since it uses useState and browser APIs, mark it as 'use client':
1// context/AuthContext.tsx
2'use client'
3
4import React, { createContext, useContext, useState, useEffect } from 'react'
5
6type User = { id: number; documentId: string; email: string; role?: { name: string } }
7
8type AuthContextType = {
9 user: User | null
10 role: string | null
11 isLoading: boolean
12 login: (email: string, password: string) => Promise<void>
13 logout: () => Promise<void>
14}
15
16const AuthContext = createContext<AuthContextType | null>(null)
17
18export function AuthProvider({ children }: { children: React.ReactNode }) {
19 const [user, setUser] = useState<User | null>(null)
20 const [isLoading, setIsLoading] = useState(true)
21
22 useEffect(() => {
23 async function loadUser() {
24 try {
25 const res = await fetch('/api/auth/me')
26 if (res.ok) {
27 const data = await res.json()
28 setUser(data.user)
29 }
30 } catch {
31 setUser(null)
32 } finally {
33 setIsLoading(false)
34 }
35 }
36 loadUser()
37 }, [])
38
39 const login = async (email: string, password: string) => {
40 setIsLoading(true)
41 const res = await fetch('/api/auth/login', {
42 method: 'POST',
43 headers: { 'Content-Type': 'application/json' },
44 body: JSON.stringify({ email, password }),
45 })
46 if (res.ok) {
47 const data = await res.json()
48 setUser(data.user)
49 }
50 setIsLoading(false)
51 }
52
53 const logout = async () => {
54 await fetch('/api/auth/logout', { method: 'POST' })
55 setUser(null)
56 }
57
58return (
59 <AuthContext.Provider
60 value={{ user, role: user?.role?.name ?? null, isLoading, login, logout }}
61 >
62 {children}
63 </AuthContext.Provider>
64 )
65}
66
67export function useAuth() {
68 const ctx = useContext(AuthContext)
69 if (!ctx) throw new Error('useAuth must be used within AuthProvider')
70 return ctx
71}Place the provider in your root layout. The layout file itself can stay a Server Component, while the imported AuthProvider is a Client Component boundary that is prerendered on the server and hydrated to run in the browser:
1// app/layout.tsx
2import { AuthProvider } from '@/context/AuthContext'
3
4export default function RootLayout({ children }: { children: React.ReactNode }) {
5 return (
6 <html lang="en">
7 <body>
8 <AuthProvider>{children}</AuthProvider>
9 </body>
10 </html>
11 )
12}React context is not supported in Server Components, so Server Components can read data directly from cookies and pass it down to Client Components when needed. The context provider handles client-side UI state, while Server Components use cookies() from next/headers for authorization.
Each portal view fetches from a different Strapi endpoint and renders content based on the logged-in user's role. This is where the roles, policies, and data types converge.
Create a Server Component at app/portal/customer/page.tsx that fetches the authenticated user's tickets:
1// app/portal/customer/page.tsx
2import { redirect } from 'next/navigation'
3import { strapiAuthFetch } from '@/lib/strapi'
4import { cookies } from 'next/headers'
5
6async function getUser() {
7 const res = await strapiAuthFetch('/users/me?populate=role')
8 if (!res.ok) return null
9 return res.json()
10}
11
12export default async function CustomerDashboard() {
13 const user = await getUser()
14 if (!user) redirect('/login')
15
16 const ticketRes = await strapiAuthFetch(
17 `/tickets?filters[customer][id][$eq]=${user.id}&populate=*`
18 )
19 const { data: tickets } = await ticketRes.json()
20
21 return (
22 <div>
23 <h1>My Tickets</h1>
24 <table>
25 <thead>
26 <tr>
27 <th>Subject</th>
28 <th>Status</th>
29 <th>Created</th>
30 </tr>
31 </thead>
32 <tbody>
33 {tickets.map((ticket: any) => (
34 <tr key={ticket.documentId}>
35 <td>{ticket.subject}</td>
36 <td>
37 <span className={`badge badge-${ticket.status}`}>
38 {ticket.status}
39 </span>
40 </td>
41 <td>{new Date(ticket.createdAt).toLocaleDateString()}</td>
42 </tr>
43 ))}
44 </tbody>
45 </table>
46 </div>
47 )
48}Strapi 5's flat response format means you access fields directly at ticket.subject, not the old v4 pattern of ticket.attributes.subject. The response structure is:
1{
2 "data": [
3 {
4 "id": 1,
5 "documentId": "hgv1vny5cebq2l3czil1rpb3",
6 "subject": "Billing question",
7 "status": "open",
8 "createdAt": "2025-01-15T09:00:00.000Z"
9 }
10 ],
11 "meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 } }
12}The partner page at app/portal/partner/page.tsx fetches deals and shared resources:
1// app/portal/partner/page.tsx
2import { redirect } from 'next/navigation'
3import { strapiAuthFetch } from '@/lib/strapi'
4
5async function getUser() {
6 const res = await strapiAuthFetch('/users/me?populate=role')
7 if (!res.ok) return null
8 return res.json()
9}
10
11export default async function PartnerDashboard() {
12 const user = await getUser()
13 if (!user) redirect('/login')
14
15 const dealRes = await strapiAuthFetch(
16 `/deals?filters[partner][id][$eq]=${user.id}&populate=*`
17 )
18 const { data: deals } = await dealRes.json()
19
20 const resourceRes = await strapiAuthFetch(
21 `/resources?filters[visibility][$in][0]=partner&filters[visibility][$in][1]=all`
22 )
23 const { data: resources } = await resourceRes.json()
24
25 return (
26 <div>
27 <h2>Deal Pipeline</h2>
28 <div className="pipeline">
29 {deals.map((deal: any) => (
30 <div key={deal.documentId} className={`card stage-${deal.stage}`}>
31 <h3>{deal.name}</h3>
32 <p>${deal.value.toLocaleString()}</p>
33 <span>{deal.stage}</span>
34 </div>
35 ))}
36 </div>
37
38 <h2>Resources</h2>
39 <ul>
40 {resources.map((resource: any) => (
41 <li key={resource.documentId}>
42 <a href={resource.file?.url} download>
43 {resource.title}
44 </a>
45 <span>{resource.category}</span>
46 </li>
47 ))}
48 </ul>
49 </div>
50 )
51}The $in operator uses bracket notation with indexed values: filters[visibility][$in][0]=partner&filters[visibility][$in][1]=all. This returns resources visible to partners or to everyone.
By default, the REST API does not populate any relations or media fields. Add populate=* to get one level of related data, or use selective population like populate[file][fields][0]=url to keep responses lean.
Create a proxy.ts at the project root. In Next.js 16, the middleware file convention was deprecated and renamed to proxy, and the runtime now defaults to Node.js (the edge runtime is not supported in proxy files). If you're upgrading an existing project, run npx @next/codemod@canary middleware-to-proxy . to migrate automatically.
This Next.js proxy intercepts requests to /portal/* and handles both authentication and role-based routing:
1// proxy.ts
2import { NextRequest, NextResponse } from 'next/server'
3
4export async function proxy(req: NextRequest) {
5 const path = req.nextUrl.pathname
6 const token = req.cookies.get('auth_token')?.value
7
8 // No token: redirect to login
9 if (!token) {
10 return NextResponse.redirect(new URL('/login', req.nextUrl))
11 }
12
13 // Fetch user role from Strapi
14 const userRes = await fetch(
15 `${process.env.STRAPI_URL}/api/users/me?populate=role`,
16 {
17 headers: { Authorization: `Bearer ${token}` },
18 }
19 )
20
21 if (!userRes.ok) {
22 return NextResponse.redirect(new URL('/login', req.nextUrl))
23 }
24
25 const user = await userRes.json()
26 const role = user.role?.name?.toLowerCase()
27
28 // Role mismatch: redirect to correct dashboard
29 if (path.startsWith('/portal/customer') && role !== 'customer') {
30 return NextResponse.redirect(new URL(`/portal/${role}`, req.nextUrl))
31 }
32 if (path.startsWith('/portal/partner') && role !== 'partner') {
33 return NextResponse.redirect(new URL(`/portal/${role}`, req.nextUrl))
34 }
35
36 return NextResponse.next()
37}
38
39export const config = {
40 matcher: ['/portal/:path*'],
41}The :path* pattern matches zero or more path segments under /portal/. NextResponse.next() lets the request proceed when everything checks out.
For performance, the official Next.js auth guide recommends using the proxy only for optimistic checks, such as reading cookies or basic JWT validation, and performing authoritative session verification as close to the data source as possible. For a production deployment, consider verifying session state in your Server Components and Route Handlers instead of calling Strapi from the proxy on every request.
Some portal functionality applies to both customers and partners: profile management, a shared resource library, and event-driven notifications. These features use the same role-aware patterns established above.
Both Customers and Partners need a profile page. Create app/portal/profile/page.tsx that reads the current user from GET /api/users/me?populate=role and updates details via PUT /api/users/:id:
1// app/portal/profile/page.tsx
2import { strapiAuthFetch } from '@/lib/strapi'
3import { redirect } from 'next/navigation'
4import ProfileForm from './ProfileForm'
5
6export default async function ProfilePage() {
7 const res = await strapiAuthFetch('/users/me?populate=role')
8 if (!res.ok) redirect('/login')
9
10 const user = await res.json()
11
12 return (
13 <div>
14 <h1>My Profile</h1>
15 <p>Role: {user.role?.name}</p>
16 <ProfileForm user={user} />
17 </div>
18 )
19}The ProfileForm client component calls a Route Handler that proxies the PUT /api/users/:id request, keeping the JWT in the HTTP-only cookie and out of client-side code.
Rather than building separate resource pages for each role, a single /portal/resources page serves both customers and partners. The page reads the authenticated user's role, then constructs a query filter that returns only resources matching that role's visibility level plus any resources marked as visible to all users:
1// app/portal/resources/page.tsx
2import { strapiAuthFetch } from '@/lib/strapi'
3import { redirect } from 'next/navigation'
4
5export default async function ResourcesPage() {
6 const userRes = await strapiAuthFetch('/users/me?populate=role')
7 if (!userRes.ok) redirect('/login')
8
9 const user = await userRes.json()
10 const role = user.role?.name?.toLowerCase()
11
12 // Filter resources by the user's role
13 const resourceRes = await strapiAuthFetch(
14 `/resources?filters[visibility][$in][0]=${role}&filters[visibility][$in][1]=all&populate=*`
15 )
16 const { data: resources } = await resourceRes.json()
17
18 return (
19 <div>
20 <h1>Resource Library</h1>
21 {resources.map((resource: any) => (
22 <div key={resource.documentId}>
23 <h3>{resource.title}</h3>
24 <span>{resource.category}</span>
25 {resource.file && (
26 <a href={resource.file.url} download>Download</a>
27 )}
28 </div>
29 ))}
30 </div>
31 )
32}Customers see resources where visibility is customer or all. Partners see partner or all. The role value passes dynamically into the filter, so one page serves both audiences.
Configure a Strapi webhook at Settings → Webhooks that fires on entry.create. Point it at a Next.js API route:
1// app/api/webhooks/strapi/route.ts
2export async function POST(request: Request) {
3 const payload = await request.json()
4
5 const { event, model, entry } = payload
6
7 if (event === 'entry.create' && model === 'ticket') {
8 // Trigger email or push notification for new ticket
9 console.log(`New ticket created: ${entry.subject}`)
10 }
11
12 if (event === 'entry.create' && model === 'deal') {
13 // Notify partner of new deal registration
14 console.log(`New deal registered: ${entry.name}`)
15 }
16
17 return new Response(null, { status: 200 })
18}You can add a shared secret via webhook headers in ./config/server.js and validate it in the route handler. One limitation to know: webhooks do not fire for the User content-type. If you need notifications on new user registrations, use the users-permissions plugin lifecycle hooks or related customization under src/extensions/users-permissions/... instead.
Deploying a Strapi + Next.js portal means running two services: the Strapi API backend and the Next.js frontend. Deploy Strapi first so the API is available when Next.js builds.
Strapi Cloud is the fastest path. It connects to your GitHub or GitLab repository and can deploy on push. A managed database is included, with infrastructure handled by Strapi for typical projects.
Railway and Render are solid alternatives. Railway offers usage-based pricing that scales to zero, with integrated PostgreSQL in the same environment. Render provides zero-downtime deploys and supports horizontal autoscaling.
Regardless of host, set these environment variables:
DATABASE_URL (or individual DATABASE_HOST, DATABASE_PORT, etc.) JWT_SECRET (for Users and Permissions Content API tokens) API_TOKEN_SALT APP_KEYS ADMIN_JWT_SECRETConfigure the strapi::cors middleware in ./config/middlewares.js to allow requests from your Next.js frontend origin:
1// ./config/middlewares.js
2module.exports = [
3 'strapi::logger',
4 'strapi::errors',
5 'strapi::security',
6 {
7 name: 'strapi::cors',
8 config: {
9 origin: ['https://your-portal.vercel.app', 'http://localhost:3000'],
10 methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
11 headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
12 },
13 },
14 'strapi::poweredBy',
15 'strapi::query',
16 'strapi::body',
17 'strapi::session',
18 'strapi::favicon',
19 'strapi::public',
20];Set the STRAPI_URL environment variable in your Vercel project settings. Do not use the NEXT_PUBLIC_ prefix for this variable since the Strapi URL should only be accessed server-side through your Route Handlers and Server Components.
Since all portal pages use cache: 'no-store', they fetch at request time, not build time. This avoids the common failure scenario where next build crashes because Strapi is not reachable during the build process. Deploy Strapi first, confirm it's running, then trigger the Next.js build.
This tutorial built a single portal application that serves customers and partners from the same codebase, with role-based content separation and owner-scoped data access enforced at every layer. Strapi 5 enabled this approach through:
documentId simplified frontend data access, removing the nested .attributes wrapper from v4. Get started with Strapi 5 for free and build your first portal Content-Type today.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.