Law firms run on confidentiality. A single document leaking to the wrong party, an attorney glimpsing another attorney's case, or a status change with no record of who made it and when can become a serious professional risk. Generic project tools often don't model this kind of access control, which is why firms can end up with a patchwork of shared drives, email threads, and spreadsheets.
This tutorial walks through building a legal case management portal with Strapi and Next.js that handles the hard parts: three roles with strict case-level data isolation, document access control that never exposes raw media URLs, a status pipeline that validates every transition, and an audit log that records who did what and when.
Strapi 5 owns the data model, RBAC, and server-side enforcement. Next.js 16, using the Next.js App Router docs as the framework reference, renders role-scoped dashboards on the server so sensitive data never ships to a browser that shouldn't see it.
In brief:
- You'll model cases, documents, activity logs, and messages as Strapi 5 Collection Types, then wire up three Users & Permissions roles (Attorney, Paralegal, Client) with custom policies that enforce per-case access.
- In Strapi 5, Document Service middlewares are the supported way to extend Document Service operations before and/or after methods run, while lifecycle hooks still exist for database activity; they can be used for custom validation, audit logging, and activity log functionality as needed.
- A custom controller serves document download links only after checking the requesting user's case assignment, but without storage-level and route protections, direct Media Library URLs can still potentially leak.
- Next.js 16 uses
proxy.tsfor request-time route handling that can support route protection and Server Components for role-scoped dashboards, with Server Actions orchestrating Strapi 5 file-upload flows such as uploading files and associating them with entries.
What We're Building
The portal serves three audiences. Attorneys get full access to the cases assigned to them: they can read everything, transition case status, upload and share documents, and message clients. Paralegals work the same cases but with narrower permissions: they read and update assigned cases, upload and categorize documents, and view the activity log, but they cannot delete records or move a case through its lifecycle. Clients see a single self-service view of their own case: current status, a timeline, the documents an attorney has chosen to share, and a messaging thread.
Strapi 5 can support a case model, role-based access control (RBAC) with custom conditions for case-level isolation, document access via its Document Service and API permissions, and activity logging via its Audit Logs feature or custom Document Service middleware implementations. The key constraint is isolation: an attorney must never see a case assigned to a different attorney, and a client must never see anything beyond their own case. That enforcement lives in custom policies on the Strapi side, not in the frontend, because frontend checks are trivially bypassed.
Next.js 16 renders the dashboards. Each role gets a different view, resolved on the server from the JSON Web Token (JWT) role claim before any markup reaches the browser.
What you'll learn:
- Defining relational Content-Types in Strapi 5 with
documentId-based relations - Writing custom policies for record-level access control
- Using Document Service middlewares for status validation and audit logging
- Building an access-controlled document download controller
- Protecting routes and rendering role-scoped UI in Next.js 16 with
proxy.tsand Server Components
Prerequisites
Pin these versions. The JavaScript ecosystem moves fast, and mixing majors will break things in subtle ways.
- Node.js v22 LTS (active LTS, supported until April 2027). Node v20 is end-of-life; do not use it. Node v24 is also a valid LTS choice if you prefer the current line.
- Next.js 16.2.x with the App Router, Server Components, and Server Actions
- React Canary with React 19.2 features (used by Next.js 16 App Router)
- Tailwind CSS v4.x for styling
- date-fns (latest stable) for date formatting
- PostgreSQL 16.x for the relational integrity this app depends on
You should be comfortable with TypeScript, REST APIs, and React Server Components. Familiarity with relational data modeling helps, since case-level isolation is fundamentally a relationships problem. Any editor works; the examples assume VS Code.
Setting Up the Strapi Backend
Step 1: Install Strapi 5
Scaffold a new project. The current command is npx create-strapi@latest; pass --skip-cloud if you want to bypass the Strapi Cloud login prompt and create a local project directly. The Strapi v4 strapi new command and its --quickstart flag are no longer part of this workflow.
npx create-strapi@latest legal-portal-apiThe CLI prompts for your setup. Choose TypeScript (the default), and when asked about the database, select PostgreSQL and supply your connection details. If you prefer to skip the database prompt, you can configure config/database.ts afterward.
Once installed, start the development server:
cd legal-portal-api
npm run developStrapi opens the Admin Panel at http://localhost:1337/admin. Create your admin account, then keep the server running while you build out the content model. For more on the install options, see the Strapi CLI installation guide, which explains the prompts and options used during project creation.
Step 2: Define the Case, Document, ActivityLog, and Message Content-Types
You can build these in the Content-Type Builder UI, but editing the schema.json files directly is faster and easier to review. Each Collection Type lives at ./src/api/[name]/content-types/[name]/schema.json.
Start with the Case type. It carries the case number, title, description, two enums for status and type, three relations to users (attorney, paralegal, client), open and close dates, and the statusHistory JSON field that the audit middleware will populate.
{
"kind": "collectionType",
"collectionName": "cases",
"info": {
"singularName": "case",
"pluralName": "cases",
"displayName": "Case"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"caseNumber": { "type": "string", "required": true, "unique": true },
"title": { "type": "string", "required": true },
"description": { "type": "richtext" },
"status": {
"type": "enumeration",
"enum": ["intake", "active", "discovery", "negotiation", "trial", "closed"],
"default": "intake"
},
"caseType": {
"type": "enumeration",
"enum": ["civil", "criminal", "family", "corporate"]
},
"assignedAttorney": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"assignedParalegal": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"client": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"openedDate": { "type": "date" },
"closedDate": { "type": "date" },
"statusHistory": { "type": "json" }
}
}Next, the CaseDocument type. The file uses a single media field. Note the sharedWithClient boolean, which the download controller checks before serving a file to a client.
{
"kind": "collectionType",
"collectionName": "case_documents",
"info": {
"singularName": "case-document",
"pluralName": "case-documents",
"displayName": "Case Document"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"title": { "type": "string", "required": true },
"file": { "type": "media", "multiple": false, "allowedTypes": ["files", "images"] },
"documentType": {
"type": "enumeration",
"enum": ["contract", "pleading", "correspondence", "evidence", "memo"]
},
"case": {
"type": "relation",
"relation": "manyToOne",
"target": "api::case.case"
},
"uploadedBy": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"sharedWithClient": { "type": "boolean", "default": false },
"uploadDate": { "type": "datetime" }
}
}The ActivityLog type records every significant action. The middleware writes to this by default, but entries can also be created manually with the Document Service API.
{
"kind": "collectionType",
"collectionName": "activity_logs",
"info": {
"singularName": "activity-log",
"pluralName": "activity-logs",
"displayName": "Activity Log"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"case": {
"type": "relation",
"relation": "manyToOne",
"target": "api::case.case"
},
"actor": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"actionType": {
"type": "enumeration",
"enum": ["status_changed", "document_uploaded", "note_added", "message_sent"]
},
"description": { "type": "text" },
"timestamp": { "type": "datetime" }
}
}Finally, the Message type for the client communication channel.
{
"kind": "collectionType",
"collectionName": "messages",
"info": {
"singularName": "message",
"pluralName": "messages",
"displayName": "Message"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"case": {
"type": "relation",
"relation": "manyToOne",
"target": "api::case.case"
},
"sender": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"body": { "type": "text", "required": true },
"timestamp": { "type": "datetime" }
}
}Restart Strapi after adding these files so the schema registers. The models documentation details every attribute type if you need to extend the schema later.
Step 3: Configure Three RBAC Roles with Case-Level Isolation
Strapi's Users & Permissions plugin handles authentication and authorization and ships with two end-user roles: Authenticated and Public. You need three custom roles instead. In the Admin Panel, go to Settings → Users & Permissions plugin → Roles, click Add new role, and create Attorney, Paralegal, and Client.
These are Users & Permissions roles, not admin roles. The distinction matters: admin roles control who can do what inside the Admin Panel, while Users & Permissions roles govern the end users who authenticate against your public API. Attorneys, paralegals, and clients never touch the Admin Panel, so all three live entirely in the Users & Permissions plugin. Spell this out for your team early, because conflating the two role systems is a common source of permission bugs.
Strapi's role-based access control is built on the Users & Permissions feature.
For each role, expand the Case, Case-Document, Activity-Log, and Message Content-Types and tick the actions that role should perform. As a starting point:
- Attorney:
find,findOne,create,updateon all four types. - Paralegal:
find,findOneon Case;create,updateon Case-Document;find,findOneon Activity-Log and Message. Nodeleteanywhere, and noupdateon Case status (handled below). - Client:
findOneon Case;find,findOneon Message;createon Message.
These role permissions gate which endpoints a role can hit, but they don't enforce that an attorney only sees their cases. That's record-level isolation, and it requires a custom policy. Policies are functions that run before the controller and return true to allow or false to block. As the policies documentation puts it, "Strapi policies are functions that execute specific logic on each request before it reaches the controller."
Create a policy that checks whether the requesting user is a participant in the case named by the route parameter.
// ./src/api/case/policies/is-case-participant.ts
export default async (policyContext, config, { strapi }) => {
const { user } = policyContext.state;
if (!user) return false;
const caseId = policyContext.params.id;
if (!caseId) return false;
const caseEntry = await strapi.documents('api::case.case').findOne({
documentId: caseId,
populate: ['assignedAttorney', 'assignedParalegal', 'client'],
});
if (!caseEntry) return false;
const isAttorney = caseEntry.assignedAttorney?.id === user.id;
const isParalegal = caseEntry.assignedParalegal?.id === user.id;
const isClient = caseEntry.client?.id === user.id;
return isAttorney || isParalegal || isClient;
};For list endpoints, filtering by the current user is the cleaner approach. A custom route plus controller action returns only the cases the user participates in. Add the route:
// ./src/api/case/routes/01-custom-case.ts
export default {
routes: [
{
method: 'GET',
path: '/cases/my-cases',
handler: 'api::case.case.findMyCases',
config: {
policies: ['plugin::users-permissions.isAuthenticated'],
},
},
],
};Then the controller action that scopes the query to the authenticated user. The role determines which relation field to filter on.
// ./src/api/case/controllers/case.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::case.case', ({ strapi }) => ({
async findMyCases(ctx) {
const user = ctx.state.user;
if (!user) return ctx.unauthorized('You must be logged in.');
const roleName = user.role?.name;
const fieldMap: Record<string, string> = {
Attorney: 'assignedAttorney',
Paralegal: 'assignedParalegal',
Client: 'client',
};
const relationField = fieldMap[roleName];
if (!relationField) return ctx.forbidden('Unrecognized role.');
const cases = await strapi.documents('api::case.case').findMany({
filters: { [relationField]: { id: { $eq: user.id } } },
populate: ['assignedAttorney', 'assignedParalegal', 'client'],
});
const sanitized = await this.sanitizeOutput(cases, ctx);
return this.transformResponse(sanitized);
},
}));Because the filter is built from ctx.state.user.id and never from client input, there's no way for a caller to widen their own scope. The custom controllers documentation covers sanitizeOutput and transformResponse in depth.
Step 4: Build the Case Status Transition Middleware
The status pipeline runs intake → active → discovery → negotiation → trial → closed. Not every jump is legal: a case shouldn't leap from intake straight to trial. A Document Service middleware validates transitions, writes each change to statusHistory, and creates an activity log entry.
This is the supported Strapi 5 pattern. As the docs note, "we recommend you use Document Service middlewares unless you absolutely need to directly interact with the database." Entity Service decorators from v4 are gone.
Register the middleware in register(). The pre-next() block validates the transition and stamps the history; the post-next() block writes the audit log against the returned documentId.
// ./src/index.ts
const CASE_UID = 'api::case.case';
const VALID_TRANSITIONS: Record<string, string[]> = {
intake: ['active'],
active: ['discovery'],
discovery: ['negotiation'],
negotiation: ['trial'],
trial: ['closed'],
closed: [],
};
export default {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid !== CASE_UID) return next();
if (!['update'].includes(context.action)) return next();
const incomingStatus = context.params?.data?.status;
if (!incomingStatus) return next();
const current = await strapi.documents(CASE_UID).findOne({
documentId: context.params.documentId,
});
if (current && current.status !== incomingStatus) {
const allowed = VALID_TRANSITIONS[current.status] || [];
if (!allowed.includes(incomingStatus)) {
throw new Error(
`Invalid status transition: ${current.status} → ${incomingStatus}`
);
}
const history = Array.isArray(current.statusHistory)
? current.statusHistory
: [];
context.params.data.statusHistory = [
...history,
{
from: current.status,
to: incomingStatus,
changedAt: new Date().toISOString(),
},
];
}
const result = await next();
if (current && current.status !== incomingStatus) {
await strapi.documents('api::activity-log.activity-log').create({
data: {
actionType: 'status_changed',
case: result.documentId,
description: `Status changed from ${current.status} to ${incomingStatus}`,
timestamp: new Date().toISOString(),
},
});
}
return result;
});
},
bootstrap() {},
};A few details worth flagging. The middleware scopes to the Case UID and the update action immediately, so it adds zero overhead to other operations. Reading the current document before next() gives you the previous status to validate against. Throwing inside the middleware aborts the operation, so an invalid transition never persists.
And by running the activity log creation after next(), you log only successful changes. This post-operation pattern is the canonical replacement for the v4 afterCreate hook, and because the middleware only fires on update, you avoid running the audit logic on create operations.
That double-fire problem was a frequent source of duplicate audit entries in v4. A lifecycle hook registered on both create and update could run twice for what a user perceived as a single save, producing two log rows for one action. Scoping the middleware to the update action and a single UID removes that ambiguity. You log exactly once per successful transition, and the audit trail stays trustworthy, which is the whole point in a legal context where the record itself can become evidence.
The middlewares documentation details the full context object.
Step 5: Build Access-Controlled Document Serving
Exposing raw Media Library URLs is a leak waiting to happen: anyone with the link can fetch the file, no authentication required. Instead, route every download through a controller that checks the requesting user's case assignment first, and for clients, additionally checks sharedWithClient.
Add the route with a numeric prefix so it loads before the core routes:
// ./src/api/case-document/routes/01-custom-case-document.ts
export default {
routes: [
{
method: 'GET',
path: '/case-documents/:id/download',
handler: 'api::case-document.case-document.getDownloadLink',
config: {
policies: ['plugin::users-permissions.isAuthenticated'],
},
},
],
};The controller resolves the document, walks up to the parent case to check assignment, and only returns the file URL when the user genuinely has access.
// ./src/api/case-document/controllers/case-document.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::case-document.case-document',
({ strapi }) => ({
async getDownloadLink(ctx) {
const user = ctx.state.user;
if (!user) return ctx.unauthorized('You must be logged in.');
const { id } = ctx.params;
const doc = await strapi.documents('api::case-document.case-document').findOne({
documentId: id,
populate: {
file: true,
case: {
populate: ['assignedAttorney', 'assignedParalegal', 'client'],
},
},
});
if (!doc || !doc.case) return ctx.notFound('Document not found.');
const relatedCase = doc.case;
const roleName = user.role?.name;
const isAttorney = relatedCase.assignedAttorney?.id === user.id;
const isParalegal = relatedCase.assignedParalegal?.id === user.id;
const isClient = relatedCase.client?.id === user.id;
if (roleName === 'Client') {
if (!isClient || !doc.sharedWithClient) {
return ctx.forbidden('You do not have access to this document.');
}
} else if (!isAttorney && !isParalegal) {
return ctx.forbidden('You do not have access to this document.');
}
return ctx.send({
url: doc.file?.url,
name: doc.file?.name,
title: doc.title,
});
},
})
);The client branch is strict: a client must both be the case's client and have the document explicitly shared. Everyone else needs to be the assigned attorney or paralegal. No path returns the URL without a passing check. The routes documentation explains the numeric-prefix loading order.
Building the Next.js 16 Frontend
Step 1: Set Up the Next.js 16 Project
Create the frontend alongside your Strapi project. Strapi pairs naturally with Next.js; see the Next.js integration for reference.
npx create-next-app@latest legal-portal-web
cd legal-portal-webAccept TypeScript, the App Router, and Tailwind when prompted. Add an environment variable pointing at Strapi:
# .env.local
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Authentication starts with a login Route Handler that exchanges credentials with Strapi and stores the returned JWT in an httpOnly cookie.
// app/api/auth/login/route.ts
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export async function POST(request: NextRequest) {
const { identifier, password } = await request.json();
const strapiRes = await fetch(`${STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
const data = await strapiRes.json();
if (!strapiRes.ok) {
return NextResponse.json({ message: data.error?.message }, { status: 401 });
}
const userRes = await fetch(`${STRAPI_URL}/api/users/me?populate=role`, {
headers: { Authorization: `Bearer ${data.jwt}` },
});
const fullUser = await userRes.json();
const cookieStore = await cookies();
cookieStore.set('auth_token', data.jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
cookieStore.set('user_role', fullUser.role?.name ?? '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
return NextResponse.json({ user: fullUser });
}Storing the role in the cookie matters because route protection in Next.js 16 should read from cookies only, without hitting Strapi on every request. In Next.js 16, the middleware.ts convention was renamed to proxy.ts. Per the Proxy docs, "Proxy executes before routes are rendered. It's particularly useful for implementing custom server-side logic like authentication, logging, or handling redirects."
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
const protectedRoutes = ['/dashboard', '/cases'];
const publicRoutes = ['/login', '/'];
export default async function proxy(req: NextRequest) {
const path = req.nextUrl.pathname;
const isProtected = protectedRoutes.some((r) => path.startsWith(r));
const isPublic = publicRoutes.includes(path);
const token = req.cookies.get('auth_token')?.value;
if (isProtected && !token) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
if (isPublic && token && path === '/login') {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};The dashboard route reads the role and renders the right view server-side, so a client never even receives the attorney dashboard markup.
// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import AttorneyDashboard from './AttorneyDashboard';
import ParalegalWorkspace from './ParalegalWorkspace';
import ClientPortal from './ClientPortal';
export default async function DashboardPage() {
const cookieStore = await cookies();
const role = cookieStore.get('user_role')?.value;
if (role === 'Attorney') return <AttorneyDashboard />;
if (role === 'Paralegal') return <ParalegalWorkspace />;
if (role === 'Client') return <ClientPortal />;
redirect('/login');
}A small fetch helper keeps the JWT attached to every Strapi request:
// lib/strapi.ts
import { cookies } from 'next/headers';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export async function strapiFetch(path: string, options: RequestInit = {}) {
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
const res = await fetch(`${STRAPI_URL}/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
cache: 'no-store',
});
if (!res.ok) throw new Error(`Strapi error: ${res.status}`);
return res.json();
}The authentication guide recommends this cookie-read-only approach for proxy checks.
Step 2: Build the Attorney Dashboard
The attorney view lists assigned cases with status badges and links to a detail page. It hits the custom /cases/my-cases endpoint, which scopes results to the logged-in attorney server-side.
// app/dashboard/AttorneyDashboard.tsx
import Link from 'next/link';
import { strapiFetch } from '@/lib/strapi';
const STATUS_COLORS: Record<string, string> = {
intake: 'bg-gray-200 text-gray-800',
active: 'bg-blue-200 text-blue-800',
discovery: 'bg-amber-200 text-amber-800',
negotiation: 'bg-purple-200 text-purple-800',
trial: 'bg-red-200 text-red-800',
closed: 'bg-green-200 text-green-800',
};
export default async function AttorneyDashboard() {
const { data: cases } = await strapiFetch('/cases/my-cases');
return (
<main className="p-8">
<h1 className="text-2xl font-semibold mb-6">My Cases</h1>
<ul className="space-y-3">
{cases.map((c: any) => (
<li key={c.documentId} className="border rounded p-4 flex justify-between">
<Link href={`/cases/${c.documentId}`} className="font-medium">
{c.caseNumber} — {c.title}
</Link>
<span className={`px-2 py-1 rounded text-sm ${STATUS_COLORS[c.status]}`}>
{c.status}
</span>
</li>
))}
</ul>
</main>
);
}The detail page shows the timeline from statusHistory, a status transition control, and the document upload form. Status transitions go through a Server Action that calls the Strapi update endpoint, which fires the validation middleware.
// app/actions/caseActions.ts
'use server';
import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export async function transitionStatus(documentId: string, nextStatus: string) {
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
const res = await fetch(`${STRAPI_URL}/api/cases/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ data: { status: nextStatus } }),
});
revalidatePath(`/cases/${documentId}`);
return res.json();
}Document upload is the two-step process Strapi 5 requires: upload the file first, then create the entry referencing the returned numeric file id. Uploading a file at entry creation time is no longer supported.
// app/actions/uploadDocument.ts
'use server';
import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export async function uploadCaseDocument(formData: FormData) {
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
const file = formData.get('file') as File;
const caseId = formData.get('caseId') as string;
const title = formData.get('title') as string;
const documentType = formData.get('documentType') as string;
const uploadForm = new FormData();
uploadForm.append('files', file, file.name);
const uploadRes = await fetch(`${STRAPI_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: uploadForm,
});
const [uploadedFile] = await uploadRes.json();
await fetch(`${STRAPI_URL}/api/case-documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: {
title,
file: uploadedFile.id,
case: caseId,
documentType,
sharedWithClient: false,
},
}),
});
revalidatePath(`/cases/${caseId}`);
}The detail page itself wires these actions to forms. Server Actions called from a Server Component support progressive enhancement, so the forms work even before client JavaScript loads.
// app/cases/[id]/page.tsx
import { strapiFetch } from '@/lib/strapi';
import { transitionStatus } from '@/app/actions/caseActions';
import { uploadCaseDocument } from '@/app/actions/uploadDocument';
import { format } from 'date-fns';
const NEXT_STATUS: Record<string, string | null> = {
intake: 'active',
active: 'discovery',
discovery: 'negotiation',
negotiation: 'trial',
trial: 'closed',
closed: null,
};
export default async function CaseDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const { data: c } = await strapiFetch(
`/cases/${id}?populate[assignedAttorney]=*`
);
const { data: docs } = await strapiFetch(
`/case-documents?filters[case][documentId][$eq]=${id}`
);
const advanceTo = NEXT_STATUS[c.status];
return (
<main className="p-8 space-y-8">
<h1 className="text-2xl font-semibold">
{c.caseNumber} — {c.title}
</h1>
<section>
<h2 className="text-lg font-medium mb-2">Status Timeline</h2>
<ol className="space-y-1">
{(c.statusHistory ?? []).map((h: any, i: number) => (
<li key={i} className="text-sm">
{h.from} → {h.to} on{' '}
{format(new Date(h.changedAt), 'PPpp')}
</li>
))}
</ol>
{advanceTo && (
<form action={transitionStatus.bind(null, id, advanceTo)}>
<button className="mt-3 bg-blue-600 text-white px-4 py-2 rounded">
Advance to {advanceTo}
</button>
</form>
)}
</section>
<section>
<h2 className="text-lg font-medium mb-2">Documents</h2>
<ul className="space-y-1">
{docs.map((d: any) => (
<li key={d.documentId} className="text-sm">
{d.title} — {d.documentType}
</li>
))}
</ul>
</section>
<section>
<h2 className="text-lg font-medium mb-2">Upload Document</h2>
<form action={uploadCaseDocument} className="space-y-2">
<input type="hidden" name="caseId" value={id} />
<input name="title" placeholder="Document title" className="border p-2 block" required />
<select name="documentType" className="border p-2 block">
<option value="contract">Contract</option>
<option value="pleading">Pleading</option>
<option value="correspondence">Correspondence</option>
<option value="evidence">Evidence</option>
<option value="memo">Memo</option>
</select>
<input type="file" name="file" required />
<button className="bg-green-600 text-white px-4 py-2 rounded">Upload</button>
</form>
</section>
</main>
);
}The Server Actions documentation covers binding arguments and progressive enhancement.
Step 3: Build the Paralegal Workspace
The paralegal view reuses the same case list and document upload, but drops the status transition control and any delete affordance. Those permissions don't exist for the Paralegal role in Strapi, so even a hand-crafted request would be rejected server-side. The UI simply reflects that. The workspace adds an activity log view so paralegals can audit recent actions on a case.
// app/dashboard/ParalegalWorkspace.tsx
import Link from 'next/link';
import { strapiFetch } from '@/lib/strapi';
export default async function ParalegalWorkspace() {
const { data: cases } = await strapiFetch('/cases/my-cases');
return (
<main className="p-8">
<h1 className="text-2xl font-semibold mb-6">Assigned Cases</h1>
<ul className="space-y-3">
{cases.map((c: any) => (
<li key={c.documentId} className="border rounded p-4">
<Link href={`/cases/${c.documentId}`} className="font-medium">
{c.caseNumber} — {c.title}
</Link>
<p className="text-sm text-gray-600">
Document management and activity log available. Status changes are
attorney-only.
</p>
</li>
))}
</ul>
</main>
);
}The activity log component fetches log entries scoped to a case:
// app/cases/[id]/ActivityLog.tsx
import { strapiFetch } from '@/lib/strapi';
import { format } from 'date-fns';
export default async function ActivityLog({ caseId }: { caseId: string }) {
const { data: logs } = await strapiFetch(
`/activity-logs?filters[case][documentId][$eq]=${caseId}&populate[actor]=*&sort=timestamp:desc`
);
return (
<section>
<h2 className="text-lg font-medium mb-2">Activity Log</h2>
<ul className="space-y-1">
{logs.map((log: any) => (
<li key={log.documentId} className="text-sm">
<span className="font-medium">{log.actionType}</span>: {log.description}{' '}
<span className="text-gray-500">
({format(new Date(log.timestamp), 'PPp')})
</span>
</li>
))}
</ul>
</section>
);
}Step 4: Build the Client Portal
The client portal is the most locked-down view: one case, the status timeline, only shared documents, and a messaging thread. Because the client's Strapi role lacks broad find permissions and the download controller enforces sharedWithClient, the frontend can be relatively simple while the backend guarantees the boundaries.
// app/dashboard/ClientPortal.tsx
import { strapiFetch } from '@/lib/strapi';
import { format } from 'date-fns';
import { sendMessage } from '@/app/actions/messageActions';
export default async function ClientPortal() {
const { data: cases } = await strapiFetch('/cases/my-cases');
const myCase = cases[0];
if (!myCase) return <p className="p-8">No active case found.</p>;
const { data: docs } = await strapiFetch(
`/case-documents?filters[case][documentId][$eq]=${myCase.documentId}&filters[sharedWithClient][$eq]=true`
);
const { data: messages } = await strapiFetch(
`/messages?filters[case][documentId][$eq]=${myCase.documentId}&populate[sender]=*&sort=timestamp:asc`
);
return (
<main className="p-8 space-y-8">
<h1 className="text-2xl font-semibold">
{myCase.title}
<span className="ml-3 text-base text-gray-600">({myCase.status})</span>
</h1>
<section>
<h2 className="text-lg font-medium mb-2">Case Timeline</h2>
<ol className="space-y-1">
{(myCase.statusHistory ?? []).map((h: any, i: number) => (
<li key={i} className="text-sm">
{h.to} — {format(new Date(h.changedAt), 'PP')}
</li>
))}
</ol>
</section>
<section>
<h2 className="text-lg font-medium mb-2">Shared Documents</h2>
<ul className="space-y-1">
{docs.map((d: any) => (
<li key={d.documentId} className="text-sm">
<a href={`/api/documents/${d.documentId}/download`} className="text-blue-600">
{d.title}
</a>
</li>
))}
</ul>
</section>
<section>
<h2 className="text-lg font-medium mb-2">Messages</h2>
<ul className="space-y-2 mb-4">
{messages.map((m: any) => (
<li key={m.documentId} className="text-sm">
<span className="font-medium">{m.sender?.username}:</span> {m.body}
</li>
))}
</ul>
<form action={sendMessage} className="flex gap-2">
<input type="hidden" name="caseId" value={myCase.documentId} />
<input name="body" className="border p-2 flex-1" placeholder="Write a message…" required />
<button className="bg-blue-600 text-white px-4 py-2 rounded">Send</button>
</form>
</section>
</main>
);
}The link routes through a Next.js Route Handler that forwards the request to the access-controlled Strapi endpoint, attaching the JWT server-side so the token never reaches the browser.
// app/api/documents/[id]/download/route.ts
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
const res = await fetch(`${STRAPI_URL}/api/case-documents/${id}/download`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json({ message: 'Access denied.' }, { status: res.status });
}
const { url } = await res.json();
return NextResponse.redirect(new URL(url, STRAPI_URL));
}The message Server Action posts to Strapi and revalidates the page:
// app/actions/messageActions.ts
'use server';
import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export async function sendMessage(formData: FormData) {
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
const caseId = formData.get('caseId') as string;
const body = formData.get('body') as string;
await fetch(`${STRAPI_URL}/api/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: { case: caseId, body, timestamp: new Date().toISOString() },
}),
});
revalidatePath('/dashboard');
}The client almost never sees internal documents because the query filters on sharedWithClient and the download controller double-checks the same flag. However, to ensure real enforcement, always pair Strapi's permission system with API security best practices for validation and sanitization.
Putting It All Together
Time to walk the full lifecycle. As an attorney, create a case: open the Admin Panel or use your dashboard's create flow, set a case number and title, and assign yourself as the attorney, a paralegal, and a client (create those users with the appropriate roles first). The status starts at intake.
Upload a couple of documents through the attorney detail page: a contract and a pleading. Flip sharedWithClient to true on the contract only, leaving the pleading internal. Advance the case status through the pipeline using the transition button. Each click moves it one legal step: intake → active → discovery, and so on. Try forcing an illegal jump by sending a manual PUT that sets status to trial from intake; the middleware throws and the change is rejected.
Check the Activity Log Collection Type in the Admin Panel. You should see a status_changed entry for every transition, each timestamped, plus the statusHistory array growing on the case itself. That's your audit trail, written automatically with no manual logging.
Open the case record itself and inspect the statusHistory JSON field alongside the Activity Log. The two are complementary: statusHistory gives you an inline, per-case change list that travels with the record, while the Activity Log aggregates actions across every case for firm-wide review. Confirm the timestamps line up and that each from/to pair reflects a legal transition. If you ever see a gap or an out-of-sequence jump, the middleware was bypassed, which should never happen through the API.
Log out and log back in as the client. The portal shows only their case, the timeline, the single shared contract (not the pleading), and the message thread. Attempt to fetch the pleading's download link directly; the controller returns a 403 because sharedWithClient is false. The isolation holds end to end, enforced by Strapi rather than trusted to the UI.
Next Steps
You have a working portal, but production legal software keeps going. A few directions worth pursuing:
- Deploy it. Push the Strapi project to Strapi Cloud with
strapi deploy, and deploy the Next.js frontend to Vercel via Git import. Enable automatic deploys on push so changes ship without manual steps. Strapi supports several deployment options if you would rather self-host. - Add deadline and statute-of-limitations tracking. Strapi's cron tasks can run a daily job that queries cases with deadlines inside 24 hours and notifies the assigned attorney.
- Add billing and time tracking as a new Collection Type linked to cases, capturing billable hours per activity.
- Add conflict-of-interest checking that scans new client and matter names against existing cases before intake.
- Integrate document e-signature so contracts can be signed inside the portal.
Browse the Strapi Marketplace for plugins that add e-signature, audit, or notification capabilities.
For deeper reference, the Strapi documentation and Next.js docs cover everything touched here in more detail.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.