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:
- Set up Strapi 5 with custom Collection Types for portal-specific content (tickets, resources, deals)
- Configure the Users and Permissions plugin so portal users only reach the content their access model allows
- Build a Next.js 16 App Router frontend with JSON Web Token (JWT) authentication and role-gated pages
- Restrict API responses with a custom policy so users only access their own data
Prerequisites
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:
- Node.js v20.9 or later
- Strapi 5
- Next.js 16 (App Router)
- Basic familiarity with REST APIs and React
Set Up the Strapi Backend
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:
npx 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.
Create the Portal Content Types
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: Textdescription: Rich Text (Markdown)status: Enumeration with valuesopen,in-progress,resolvedcustomer: 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: Textfile: Media (single file)category: Enumeration with valuesdocs,marketing,technicalvisibility: Enumeration with valuescustomer,partner,all
Deal
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: Textvalue: Number (decimal)stage: Enumeration with valuesprospecting,negotiation,closed-won,closed-lostpartner: 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:
"customer": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}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.
Configure Roles and Permissions
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:
- Open the role that should allow signed-in customer access
- Expand the permission categories for your content types
- Grant
findandfindOneon Ticket and Resource - Deny access to Deal
- Save
For partner-facing access:
- Open the role that should allow signed-in partner access
- Grant
findandfindOneon Resource and Deal - Deny access to Ticket
- Save
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.
Restrict Data Access with a Custom Policy
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.
Write an Owner-Only Policy
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:
// ./src/policies/is-owner.js
module.exports = (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user) {
return false;
}
// Inject a filter so the controller only returns entries belonging to this user
const relationField = config.relationField || 'customer';
policyContext.query = {
...policyContext.query,
filters: {
...policyContext.query?.filters,
[relationField]: {
id: { $eq: user.id },
},
},
};
return true;
};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.
Apply the Policy to Routes
Override the default core router for each Collection Type using createCoreRouter with a config object that attaches your policy:
// ./src/api/ticket/routes/ticket.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::ticket.ticket', {
config: {
find: {
policies: [
{ name: 'global::is-owner', config: { relationField: 'customer' } },
],
},
},
});Do the same for deals, pointing to the partner relation:
// ./src/api/deal/routes/deal.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::deal.deal', {
config: {
find: {
policies: [
{ name: 'global::is-owner', config: { relationField: 'partner' } },
],
},
},
});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.
Build the Next.js Frontend
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:
npx 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 layoutslib/for API helpers and the data access layercontext/for the auth providerapp/api/auth/for Route Handlers that bridge HTTP-only cookies
Handle Authentication with JWT
Strapi'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:
// app/api/auth/login/route.ts
import { cookies } from 'next/headers'
export async function POST(request: Request) {
const { email, password } = await request.json()
const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: email, password }),
})
const data = await res.json()
if (!res.ok) {
return new Response(
JSON.stringify({ error: 'Invalid credentials' }),
{ status: 401 }
)
}
const cookieStore = await cookies()
cookieStore.set('auth_token', data.jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return new Response(JSON.stringify({ user: data.user }), { status: 200 })
}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:
// lib/strapi.ts
import 'server-only'
import { cookies } from 'next/headers'
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337'
async function getToken(): Promise<string | null> {
const cookieStore = await cookies()
return cookieStore.get('auth_token')?.value ?? null
}
export async function strapiAuthFetch(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const token = await getToken()
return fetch(`${STRAPI_URL}/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
cache: 'no-store',
})
}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.
Create a Reusable Auth Context
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':
// context/AuthContext.tsx
'use client'
import React, { createContext, useContext, useState, useEffect } from 'react'
type User = { id: number; documentId: string; email: string; role?: { name: string } }
type AuthContextType = {
user: User | null
role: string | null
isLoading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
async function loadUser() {
try {
const res = await fetch('/api/auth/me')
if (res.ok) {
const data = await res.json()
setUser(data.user)
}
} catch {
setUser(null)
} finally {
setIsLoading(false)
}
}
loadUser()
}, [])
const login = async (email: string, password: string) => {
setIsLoading(true)
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (res.ok) {
const data = await res.json()
setUser(data.user)
}
setIsLoading(false)
}
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
setUser(null)
}
return (
<AuthContext.Provider
value={{ user, role: user?.role?.name ?? null, isLoading, login, logout }}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}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:
// app/layout.tsx
import { AuthProvider } from '@/context/AuthContext'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
)
}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.
Build Role-Gated Portal Pages
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.
Customer Dashboard
Create a Server Component at app/portal/customer/page.tsx that fetches the authenticated user's tickets:
// app/portal/customer/page.tsx
import { redirect } from 'next/navigation'
import { strapiAuthFetch } from '@/lib/strapi'
import { cookies } from 'next/headers'
async function getUser() {
const res = await strapiAuthFetch('/users/me?populate=role')
if (!res.ok) return null
return res.json()
}
export default async function CustomerDashboard() {
const user = await getUser()
if (!user) redirect('/login')
const ticketRes = await strapiAuthFetch(
`/tickets?filters[customer][id][$eq]=${user.id}&populate=*`
)
const { data: tickets } = await ticketRes.json()
return (
<div>
<h1>My Tickets</h1>
<table>
<thead>
<tr>
<th>Subject</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{tickets.map((ticket: any) => (
<tr key={ticket.documentId}>
<td>{ticket.subject}</td>
<td>
<span className={`badge badge-${ticket.status}`}>
{ticket.status}
</span>
</td>
<td>{new Date(ticket.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}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:
{
"data": [
{
"id": 1,
"documentId": "hgv1vny5cebq2l3czil1rpb3",
"subject": "Billing question",
"status": "open",
"createdAt": "2025-01-15T09:00:00.000Z"
}
],
"meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 } }
}Partner Dashboard
The partner page at app/portal/partner/page.tsx fetches deals and shared resources:
// app/portal/partner/page.tsx
import { redirect } from 'next/navigation'
import { strapiAuthFetch } from '@/lib/strapi'
async function getUser() {
const res = await strapiAuthFetch('/users/me?populate=role')
if (!res.ok) return null
return res.json()
}
export default async function PartnerDashboard() {
const user = await getUser()
if (!user) redirect('/login')
const dealRes = await strapiAuthFetch(
`/deals?filters[partner][id][$eq]=${user.id}&populate=*`
)
const { data: deals } = await dealRes.json()
const resourceRes = await strapiAuthFetch(
`/resources?filters[visibility][$in][0]=partner&filters[visibility][$in][1]=all`
)
const { data: resources } = await resourceRes.json()
return (
<div>
<h2>Deal Pipeline</h2>
<div className="pipeline">
{deals.map((deal: any) => (
<div key={deal.documentId} className={`card stage-${deal.stage}`}>
<h3>{deal.name}</h3>
<p>${deal.value.toLocaleString()}</p>
<span>{deal.stage}</span>
</div>
))}
</div>
<h2>Resources</h2>
<ul>
{resources.map((resource: any) => (
<li key={resource.documentId}>
<a href={resource.file?.url} download>
{resource.title}
</a>
<span>{resource.category}</span>
</li>
))}
</ul>
</div>
)
}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.
Protect Routes with Proxy
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:
// proxy.ts
import { NextRequest, NextResponse } from 'next/server'
export async function proxy(req: NextRequest) {
const path = req.nextUrl.pathname
const token = req.cookies.get('auth_token')?.value
// No token: redirect to login
if (!token) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
// Fetch user role from Strapi
const userRes = await fetch(
`${process.env.STRAPI_URL}/api/users/me?populate=role`,
{
headers: { Authorization: `Bearer ${token}` },
}
)
if (!userRes.ok) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
const user = await userRes.json()
const role = user.role?.name?.toLowerCase()
// Role mismatch: redirect to correct dashboard
if (path.startsWith('/portal/customer') && role !== 'customer') {
return NextResponse.redirect(new URL(`/portal/${role}`, req.nextUrl))
}
if (path.startsWith('/portal/partner') && role !== 'partner') {
return NextResponse.redirect(new URL(`/portal/${role}`, req.nextUrl))
}
return NextResponse.next()
}
export const config = {
matcher: ['/portal/:path*'],
}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.
Add Shared Portal Features
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.
Profile Management
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:
// app/portal/profile/page.tsx
import { strapiAuthFetch } from '@/lib/strapi'
import { redirect } from 'next/navigation'
import ProfileForm from './ProfileForm'
export default async function ProfilePage() {
const res = await strapiAuthFetch('/users/me?populate=role')
if (!res.ok) redirect('/login')
const user = await res.json()
return (
<div>
<h1>My Profile</h1>
<p>Role: {user.role?.name}</p>
<ProfileForm user={user} />
</div>
)
}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.
Resource Library with Role-Based Filtering
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:
// app/portal/resources/page.tsx
import { strapiAuthFetch } from '@/lib/strapi'
import { redirect } from 'next/navigation'
export default async function ResourcesPage() {
const userRes = await strapiAuthFetch('/users/me?populate=role')
if (!userRes.ok) redirect('/login')
const user = await userRes.json()
const role = user.role?.name?.toLowerCase()
// Filter resources by the user's role
const resourceRes = await strapiAuthFetch(
`/resources?filters[visibility][$in][0]=${role}&filters[visibility][$in][1]=all&populate=*`
)
const { data: resources } = await resourceRes.json()
return (
<div>
<h1>Resource Library</h1>
{resources.map((resource: any) => (
<div key={resource.documentId}>
<h3>{resource.title}</h3>
<span>{resource.category}</span>
{resource.file && (
<a href={resource.file.url} download>Download</a>
)}
</div>
))}
</div>
)
}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.
Notification System with Webhooks
Configure a Strapi webhook at Settings → Webhooks that fires on entry.create. Point it at a Next.js API route:
// app/api/webhooks/strapi/route.ts
export async function POST(request: Request) {
const payload = await request.json()
const { event, model, entry } = payload
if (event === 'entry.create' && model === 'ticket') {
// Trigger email or push notification for new ticket
console.log(`New ticket created: ${entry.subject}`)
}
if (event === 'entry.create' && model === 'deal') {
// Notify partner of new deal registration
console.log(`New deal registered: ${entry.name}`)
}
return new Response(null, { status: 200 })
}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.
Deploy the Portal Stack
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.
Deploy Strapi to Production
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 individualDATABASE_HOST,DATABASE_PORT, etc.)JWT_SECRET(for Users and Permissions Content API tokens)API_TOKEN_SALTAPP_KEYSADMIN_JWT_SECRET
Configure the strapi::cors middleware in ./config/middlewares.js to allow requests from your Next.js frontend origin:
// ./config/middlewares.js
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
{
name: 'strapi::cors',
config: {
origin: ['https://your-portal.vercel.app', 'http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];Deploy Next.js to Vercel
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.
How Strapi Powers This
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:
- The Content-Type Builder modeled Tickets, Deals, and Resources without writing schema files, with relation fields linking each entry to its owning user.
- The Users and Permissions plugin mapped API-level access per role so unauthorized requests get rejected before controller logic runs.
- Global policies injected ownership filters into queries, scoping list responses to the authenticated user's data.
- The flat response format with
documentIdsimplified frontend data access, removing the nested.attributeswrapper from v4. - Webhook support enabled event-driven notifications when new tickets or deals are created.
Get started with Strapi 5 for free and build your first portal Content-Type today.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.