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:
proxy.ts for 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.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:
documentId-based relationsproxy.ts and Server ComponentsPin these versions. The JavaScript ecosystem moves fast, and mixing majors will break things in subtle ways.
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.
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.
1npx 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:
1cd legal-portal-api
2npm 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.
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.
1{
2 "kind": "collectionType",
3 "collectionName": "cases",
4 "info": {
5 "singularName": "case",
6 "pluralName": "cases",
7 "displayName": "Case"
8 },
9 "options": {
10 "draftAndPublish": true
11 },
12 "pluginOptions": {},
13 "attributes": {
14 "caseNumber": { "type": "string", "required": true, "unique": true },
15 "title": { "type": "string", "required": true },
16 "description": { "type": "richtext" },
17 "status": {
18 "type": "enumeration",
19 "enum": ["intake", "active", "discovery", "negotiation", "trial", "closed"],
20 "default": "intake"
21 },
22 "caseType": {
23 "type": "enumeration",
24 "enum": ["civil", "criminal", "family", "corporate"]
25 },
26 "assignedAttorney": {
27 "type": "relation",
28 "relation": "manyToOne",
29 "target": "plugin::users-permissions.user"
30 },
31 "assignedParalegal": {
32 "type": "relation",
33 "relation": "manyToOne",
34 "target": "plugin::users-permissions.user"
35 },
36 "client": {
37 "type": "relation",
38 "relation": "manyToOne",
39 "target": "plugin::users-permissions.user"
40 },
41 "openedDate": { "type": "date" },
42 "closedDate": { "type": "date" },
43 "statusHistory": { "type": "json" }
44 }
45}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.
1{
2 "kind": "collectionType",
3 "collectionName": "case_documents",
4 "info": {
5 "singularName": "case-document",
6 "pluralName": "case-documents",
7 "displayName": "Case Document"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "pluginOptions": {},
13 "attributes": {
14 "title": { "type": "string", "required": true },
15 "file": { "type": "media", "multiple": false, "allowedTypes": ["files", "images"] },
16 "documentType": {
17 "type": "enumeration",
18 "enum": ["contract", "pleading", "correspondence", "evidence", "memo"]
19 },
20 "case": {
21 "type": "relation",
22 "relation": "manyToOne",
23 "target": "api::case.case"
24 },
25 "uploadedBy": {
26 "type": "relation",
27 "relation": "manyToOne",
28 "target": "plugin::users-permissions.user"
29 },
30 "sharedWithClient": { "type": "boolean", "default": false },
31 "uploadDate": { "type": "datetime" }
32 }
33}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.
1{
2 "kind": "collectionType",
3 "collectionName": "activity_logs",
4 "info": {
5 "singularName": "activity-log",
6 "pluralName": "activity-logs",
7 "displayName": "Activity Log"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "pluginOptions": {},
13 "attributes": {
14 "case": {
15 "type": "relation",
16 "relation": "manyToOne",
17 "target": "api::case.case"
18 },
19 "actor": {
20 "type": "relation",
21 "relation": "manyToOne",
22 "target": "plugin::users-permissions.user"
23 },
24 "actionType": {
25 "type": "enumeration",
26 "enum": ["status_changed", "document_uploaded", "note_added", "message_sent"]
27 },
28 "description": { "type": "text" },
29 "timestamp": { "type": "datetime" }
30 }
31}Finally, the Message type for the client communication channel.
1{
2 "kind": "collectionType",
3 "collectionName": "messages",
4 "info": {
5 "singularName": "message",
6 "pluralName": "messages",
7 "displayName": "Message"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "pluginOptions": {},
13 "attributes": {
14 "case": {
15 "type": "relation",
16 "relation": "manyToOne",
17 "target": "api::case.case"
18 },
19 "sender": {
20 "type": "relation",
21 "relation": "manyToOne",
22 "target": "plugin::users-permissions.user"
23 },
24 "body": { "type": "text", "required": true },
25 "timestamp": { "type": "datetime" }
26 }
27}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.
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:
find, findOne, create, update on all four types.find, findOne on Case; create, update on Case-Document; find, findOne on Activity-Log and Message. No delete anywhere, and no update on Case status (handled below).findOne on Case; find, findOne on Message; create on 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.
1// ./src/api/case/policies/is-case-participant.ts
2export default async (policyContext, config, { strapi }) => {
3 const { user } = policyContext.state;
4 if (!user) return false;
5
6 const caseId = policyContext.params.id;
7 if (!caseId) return false;
8
9 const caseEntry = await strapi.documents('api::case.case').findOne({
10 documentId: caseId,
11 populate: ['assignedAttorney', 'assignedParalegal', 'client'],
12 });
13
14 if (!caseEntry) return false;
15
16 const isAttorney = caseEntry.assignedAttorney?.id === user.id;
17 const isParalegal = caseEntry.assignedParalegal?.id === user.id;
18 const isClient = caseEntry.client?.id === user.id;
19
20 return isAttorney || isParalegal || isClient;
21};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:
1// ./src/api/case/routes/01-custom-case.ts
2export default {
3 routes: [
4 {
5 method: 'GET',
6 path: '/cases/my-cases',
7 handler: 'api::case.case.findMyCases',
8 config: {
9 policies: ['plugin::users-permissions.isAuthenticated'],
10 },
11 },
12 ],
13};Then the controller action that scopes the query to the authenticated user. The role determines which relation field to filter on.
1// ./src/api/case/controllers/case.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController('api::case.case', ({ strapi }) => ({
5 async findMyCases(ctx) {
6 const user = ctx.state.user;
7 if (!user) return ctx.unauthorized('You must be logged in.');
8
9 const roleName = user.role?.name;
10 const fieldMap: Record<string, string> = {
11 Attorney: 'assignedAttorney',
12 Paralegal: 'assignedParalegal',
13 Client: 'client',
14 };
15 const relationField = fieldMap[roleName];
16 if (!relationField) return ctx.forbidden('Unrecognized role.');
17
18 const cases = await strapi.documents('api::case.case').findMany({
19 filters: { [relationField]: { id: { $eq: user.id } } },
20 populate: ['assignedAttorney', 'assignedParalegal', 'client'],
21 });
22
23 const sanitized = await this.sanitizeOutput(cases, ctx);
24 return this.transformResponse(sanitized);
25 },
26}));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.
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.
1// ./src/index.ts
2const CASE_UID = 'api::case.case';
3
4const VALID_TRANSITIONS: Record<string, string[]> = {
5 intake: ['active'],
6 active: ['discovery'],
7 discovery: ['negotiation'],
8 negotiation: ['trial'],
9 trial: ['closed'],
10 closed: [],
11};
12
13export default {
14 register({ strapi }) {
15 strapi.documents.use(async (context, next) => {
16 if (context.uid !== CASE_UID) return next();
17 if (!['update'].includes(context.action)) return next();
18
19 const incomingStatus = context.params?.data?.status;
20 if (!incomingStatus) return next();
21
22 const current = await strapi.documents(CASE_UID).findOne({
23 documentId: context.params.documentId,
24 });
25
26 if (current && current.status !== incomingStatus) {
27 const allowed = VALID_TRANSITIONS[current.status] || [];
28 if (!allowed.includes(incomingStatus)) {
29 throw new Error(
30 `Invalid status transition: ${current.status} → ${incomingStatus}`
31 );
32 }
33
34 const history = Array.isArray(current.statusHistory)
35 ? current.statusHistory
36 : [];
37 context.params.data.statusHistory = [
38 ...history,
39 {
40 from: current.status,
41 to: incomingStatus,
42 changedAt: new Date().toISOString(),
43 },
44 ];
45 }
46
47 const result = await next();
48
49 if (current && current.status !== incomingStatus) {
50 await strapi.documents('api::activity-log.activity-log').create({
51 data: {
52 actionType: 'status_changed',
53 case: result.documentId,
54 description: `Status changed from ${current.status} to ${incomingStatus}`,
55 timestamp: new Date().toISOString(),
56 },
57 });
58 }
59
60 return result;
61 });
62 },
63
64 bootstrap() {},
65};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.
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:
1// ./src/api/case-document/routes/01-custom-case-document.ts
2export default {
3 routes: [
4 {
5 method: 'GET',
6 path: '/case-documents/:id/download',
7 handler: 'api::case-document.case-document.getDownloadLink',
8 config: {
9 policies: ['plugin::users-permissions.isAuthenticated'],
10 },
11 },
12 ],
13};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.
1// ./src/api/case-document/controllers/case-document.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController(
5 'api::case-document.case-document',
6 ({ strapi }) => ({
7 async getDownloadLink(ctx) {
8 const user = ctx.state.user;
9 if (!user) return ctx.unauthorized('You must be logged in.');
10
11 const { id } = ctx.params;
12
13 const doc = await strapi.documents('api::case-document.case-document').findOne({
14 documentId: id,
15 populate: {
16 file: true,
17 case: {
18 populate: ['assignedAttorney', 'assignedParalegal', 'client'],
19 },
20 },
21 });
22
23 if (!doc || !doc.case) return ctx.notFound('Document not found.');
24
25 const relatedCase = doc.case;
26 const roleName = user.role?.name;
27 const isAttorney = relatedCase.assignedAttorney?.id === user.id;
28 const isParalegal = relatedCase.assignedParalegal?.id === user.id;
29 const isClient = relatedCase.client?.id === user.id;
30
31 if (roleName === 'Client') {
32 if (!isClient || !doc.sharedWithClient) {
33 return ctx.forbidden('You do not have access to this document.');
34 }
35 } else if (!isAttorney && !isParalegal) {
36 return ctx.forbidden('You do not have access to this document.');
37 }
38
39 return ctx.send({
40 url: doc.file?.url,
41 name: doc.file?.name,
42 title: doc.title,
43 });
44 },
45 })
46);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.
Create the frontend alongside your Strapi project. Strapi pairs naturally with Next.js; see the Next.js integration for reference.
1npx create-next-app@latest legal-portal-web
2cd legal-portal-webAccept TypeScript, the App Router, and Tailwind when prompted. Add an environment variable pointing at Strapi:
1# .env.local
2NEXT_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.
1// app/api/auth/login/route.ts
2import { cookies } from 'next/headers';
3import { NextRequest, NextResponse } from 'next/server';
4
5const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
6
7export async function POST(request: NextRequest) {
8 const { identifier, password } = await request.json();
9
10 const strapiRes = await fetch(`${STRAPI_URL}/api/auth/local`, {
11 method: 'POST',
12 headers: { 'Content-Type': 'application/json' },
13 body: JSON.stringify({ identifier, password }),
14 });
15
16 const data = await strapiRes.json();
17
18 if (!strapiRes.ok) {
19 return NextResponse.json({ message: data.error?.message }, { status: 401 });
20 }
21
22 const userRes = await fetch(`${STRAPI_URL}/api/users/me?populate=role`, {
23 headers: { Authorization: `Bearer ${data.jwt}` },
24 });
25 const fullUser = await userRes.json();
26
27 const cookieStore = await cookies();
28 cookieStore.set('auth_token', data.jwt, {
29 httpOnly: true,
30 secure: process.env.NODE_ENV === 'production',
31 sameSite: 'lax',
32 });
33 cookieStore.set('user_role', fullUser.role?.name ?? '', {
34 httpOnly: true,
35 secure: process.env.NODE_ENV === 'production',
36 sameSite: 'lax',
37 });
38
39 return NextResponse.json({ user: fullUser });
40}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."
1// proxy.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4const protectedRoutes = ['/dashboard', '/cases'];
5const publicRoutes = ['/login', '/'];
6
7export default async function proxy(req: NextRequest) {
8 const path = req.nextUrl.pathname;
9 const isProtected = protectedRoutes.some((r) => path.startsWith(r));
10 const isPublic = publicRoutes.includes(path);
11
12 const token = req.cookies.get('auth_token')?.value;
13
14 if (isProtected && !token) {
15 return NextResponse.redirect(new URL('/login', req.nextUrl));
16 }
17
18 if (isPublic && token && path === '/login') {
19 return NextResponse.redirect(new URL('/dashboard', req.nextUrl));
20 }
21
22 return NextResponse.next();
23}
24
25export const config = {
26 matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
27};The dashboard route reads the role and renders the right view server-side, so a client never even receives the attorney dashboard markup.
1// app/dashboard/page.tsx
2import { cookies } from 'next/headers';
3import { redirect } from 'next/navigation';
4import AttorneyDashboard from './AttorneyDashboard';
5import ParalegalWorkspace from './ParalegalWorkspace';
6import ClientPortal from './ClientPortal';
7
8export default async function DashboardPage() {
9 const cookieStore = await cookies();
10 const role = cookieStore.get('user_role')?.value;
11
12 if (role === 'Attorney') return <AttorneyDashboard />;
13 if (role === 'Paralegal') return <ParalegalWorkspace />;
14 if (role === 'Client') return <ClientPortal />;
15 redirect('/login');
16}A small fetch helper keeps the JWT attached to every Strapi request:
1// lib/strapi.ts
2import { cookies } from 'next/headers';
3
4const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
5
6export async function strapiFetch(path: string, options: RequestInit = {}) {
7 const cookieStore = await cookies();
8 const token = cookieStore.get('auth_token')?.value;
9
10 const res = await fetch(`${STRAPI_URL}/api${path}`, {
11 ...options,
12 headers: {
13 'Content-Type': 'application/json',
14 Authorization: `Bearer ${token}`,
15 ...options.headers,
16 },
17 cache: 'no-store',
18 });
19
20 if (!res.ok) throw new Error(`Strapi error: ${res.status}`);
21 return res.json();
22}The authentication guide recommends this cookie-read-only approach for proxy checks.
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.
1// app/dashboard/AttorneyDashboard.tsx
2import Link from 'next/link';
3import { strapiFetch } from '@/lib/strapi';
4
5const STATUS_COLORS: Record<string, string> = {
6 intake: 'bg-gray-200 text-gray-800',
7 active: 'bg-blue-200 text-blue-800',
8 discovery: 'bg-amber-200 text-amber-800',
9 negotiation: 'bg-purple-200 text-purple-800',
10 trial: 'bg-red-200 text-red-800',
11 closed: 'bg-green-200 text-green-800',
12};
13
14export default async function AttorneyDashboard() {
15 const { data: cases } = await strapiFetch('/cases/my-cases');
16
17 return (
18 <main className="p-8">
19 <h1 className="text-2xl font-semibold mb-6">My Cases</h1>
20 <ul className="space-y-3">
21 {cases.map((c: any) => (
22 <li key={c.documentId} className="border rounded p-4 flex justify-between">
23 <Link href={`/cases/${c.documentId}`} className="font-medium">
24 {c.caseNumber} — {c.title}
25 </Link>
26 <span className={`px-2 py-1 rounded text-sm ${STATUS_COLORS[c.status]}`}>
27 {c.status}
28 </span>
29 </li>
30 ))}
31 </ul>
32 </main>
33 );
34}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.
1// app/actions/caseActions.ts
2'use server';
3import { cookies } from 'next/headers';
4import { revalidatePath } from 'next/cache';
5
6const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
7
8export async function transitionStatus(documentId: string, nextStatus: string) {
9 const cookieStore = await cookies();
10 const token = cookieStore.get('auth_token')?.value;
11
12 const res = await fetch(`${STRAPI_URL}/api/cases/${documentId}`, {
13 method: 'PUT',
14 headers: {
15 'Content-Type': 'application/json',
16 Authorization: `Bearer ${token}`,
17 },
18 body: JSON.stringify({ data: { status: nextStatus } }),
19 });
20
21 revalidatePath(`/cases/${documentId}`);
22 return res.json();
23}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.
1// app/actions/uploadDocument.ts
2'use server';
3import { cookies } from 'next/headers';
4import { revalidatePath } from 'next/cache';
5
6const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
7
8export async function uploadCaseDocument(formData: FormData) {
9 const cookieStore = await cookies();
10 const token = cookieStore.get('auth_token')?.value;
11
12 const file = formData.get('file') as File;
13 const caseId = formData.get('caseId') as string;
14 const title = formData.get('title') as string;
15 const documentType = formData.get('documentType') as string;
16
17 const uploadForm = new FormData();
18 uploadForm.append('files', file, file.name);
19
20 const uploadRes = await fetch(`${STRAPI_URL}/api/upload`, {
21 method: 'POST',
22 headers: { Authorization: `Bearer ${token}` },
23 body: uploadForm,
24 });
25 const [uploadedFile] = await uploadRes.json();
26
27 await fetch(`${STRAPI_URL}/api/case-documents`, {
28 method: 'POST',
29 headers: {
30 'Content-Type': 'application/json',
31 Authorization: `Bearer ${token}`,
32 },
33 body: JSON.stringify({
34 data: {
35 title,
36 file: uploadedFile.id,
37 case: caseId,
38 documentType,
39 sharedWithClient: false,
40 },
41 }),
42 });
43
44 revalidatePath(`/cases/${caseId}`);
45}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.
1// app/cases/[id]/page.tsx
2import { strapiFetch } from '@/lib/strapi';
3import { transitionStatus } from '@/app/actions/caseActions';
4import { uploadCaseDocument } from '@/app/actions/uploadDocument';
5import { format } from 'date-fns';
6
7const NEXT_STATUS: Record<string, string | null> = {
8 intake: 'active',
9 active: 'discovery',
10 discovery: 'negotiation',
11 negotiation: 'trial',
12 trial: 'closed',
13 closed: null,
14};
15
16export default async function CaseDetailPage({
17 params,
18}: {
19 params: Promise<{ id: string }>;
20}) {
21 const { id } = await params;
22 const { data: c } = await strapiFetch(
23 `/cases/${id}?populate[assignedAttorney]=*`
24 );
25
26 const { data: docs } = await strapiFetch(
27 `/case-documents?filters[case][documentId][$eq]=${id}`
28 );
29
30 const advanceTo = NEXT_STATUS[c.status];
31
32 return (
33 <main className="p-8 space-y-8">
34 <h1 className="text-2xl font-semibold">
35 {c.caseNumber} — {c.title}
36 </h1>
37
38 <section>
39 <h2 className="text-lg font-medium mb-2">Status Timeline</h2>
40 <ol className="space-y-1">
41 {(c.statusHistory ?? []).map((h: any, i: number) => (
42 <li key={i} className="text-sm">
43 {h.from} → {h.to} on{' '}
44 {format(new Date(h.changedAt), 'PPpp')}
45 </li>
46 ))}
47 </ol>
48 {advanceTo && (
49 <form action={transitionStatus.bind(null, id, advanceTo)}>
50 <button className="mt-3 bg-blue-600 text-white px-4 py-2 rounded">
51 Advance to {advanceTo}
52 </button>
53 </form>
54 )}
55 </section>
56
57 <section>
58 <h2 className="text-lg font-medium mb-2">Documents</h2>
59 <ul className="space-y-1">
60 {docs.map((d: any) => (
61 <li key={d.documentId} className="text-sm">
62 {d.title} — {d.documentType}
63 </li>
64 ))}
65 </ul>
66 </section>
67
68 <section>
69 <h2 className="text-lg font-medium mb-2">Upload Document</h2>
70 <form action={uploadCaseDocument} className="space-y-2">
71 <input type="hidden" name="caseId" value={id} />
72 <input name="title" placeholder="Document title" className="border p-2 block" required />
73 <select name="documentType" className="border p-2 block">
74 <option value="contract">Contract</option>
75 <option value="pleading">Pleading</option>
76 <option value="correspondence">Correspondence</option>
77 <option value="evidence">Evidence</option>
78 <option value="memo">Memo</option>
79 </select>
80 <input type="file" name="file" required />
81 <button className="bg-green-600 text-white px-4 py-2 rounded">Upload</button>
82 </form>
83 </section>
84 </main>
85 );
86}The Server Actions documentation covers binding arguments and progressive enhancement.
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.
1// app/dashboard/ParalegalWorkspace.tsx
2import Link from 'next/link';
3import { strapiFetch } from '@/lib/strapi';
4
5export default async function ParalegalWorkspace() {
6 const { data: cases } = await strapiFetch('/cases/my-cases');
7
8 return (
9 <main className="p-8">
10 <h1 className="text-2xl font-semibold mb-6">Assigned Cases</h1>
11 <ul className="space-y-3">
12 {cases.map((c: any) => (
13 <li key={c.documentId} className="border rounded p-4">
14 <Link href={`/cases/${c.documentId}`} className="font-medium">
15 {c.caseNumber} — {c.title}
16 </Link>
17 <p className="text-sm text-gray-600">
18 Document management and activity log available. Status changes are
19 attorney-only.
20 </p>
21 </li>
22 ))}
23 </ul>
24 </main>
25 );
26}The activity log component fetches log entries scoped to a case:
1// app/cases/[id]/ActivityLog.tsx
2import { strapiFetch } from '@/lib/strapi';
3import { format } from 'date-fns';
4
5export default async function ActivityLog({ caseId }: { caseId: string }) {
6 const { data: logs } = await strapiFetch(
7 `/activity-logs?filters[case][documentId][$eq]=${caseId}&populate[actor]=*&sort=timestamp:desc`
8 );
9
10 return (
11 <section>
12 <h2 className="text-lg font-medium mb-2">Activity Log</h2>
13 <ul className="space-y-1">
14 {logs.map((log: any) => (
15 <li key={log.documentId} className="text-sm">
16 <span className="font-medium">{log.actionType}</span>: {log.description}{' '}
17 <span className="text-gray-500">
18 ({format(new Date(log.timestamp), 'PPp')})
19 </span>
20 </li>
21 ))}
22 </ul>
23 </section>
24 );
25}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.
1// app/dashboard/ClientPortal.tsx
2import { strapiFetch } from '@/lib/strapi';
3import { format } from 'date-fns';
4import { sendMessage } from '@/app/actions/messageActions';
5
6export default async function ClientPortal() {
7 const { data: cases } = await strapiFetch('/cases/my-cases');
8 const myCase = cases[0];
9 if (!myCase) return <p className="p-8">No active case found.</p>;
10
11 const { data: docs } = await strapiFetch(
12 `/case-documents?filters[case][documentId][$eq]=${myCase.documentId}&filters[sharedWithClient][$eq]=true`
13 );
14
15 const { data: messages } = await strapiFetch(
16 `/messages?filters[case][documentId][$eq]=${myCase.documentId}&populate[sender]=*&sort=timestamp:asc`
17 );
18
19 return (
20 <main className="p-8 space-y-8">
21 <h1 className="text-2xl font-semibold">
22 {myCase.title}
23 <span className="ml-3 text-base text-gray-600">({myCase.status})</span>
24 </h1>
25
26 <section>
27 <h2 className="text-lg font-medium mb-2">Case Timeline</h2>
28 <ol className="space-y-1">
29 {(myCase.statusHistory ?? []).map((h: any, i: number) => (
30 <li key={i} className="text-sm">
31 {h.to} — {format(new Date(h.changedAt), 'PP')}
32 </li>
33 ))}
34 </ol>
35 </section>
36
37 <section>
38 <h2 className="text-lg font-medium mb-2">Shared Documents</h2>
39 <ul className="space-y-1">
40 {docs.map((d: any) => (
41 <li key={d.documentId} className="text-sm">
42 <a href={`/api/documents/${d.documentId}/download`} className="text-blue-600">
43 {d.title}
44 </a>
45 </li>
46 ))}
47 </ul>
48 </section>
49
50 <section>
51 <h2 className="text-lg font-medium mb-2">Messages</h2>
52 <ul className="space-y-2 mb-4">
53 {messages.map((m: any) => (
54 <li key={m.documentId} className="text-sm">
55 <span className="font-medium">{m.sender?.username}:</span> {m.body}
56 </li>
57 ))}
58 </ul>
59 <form action={sendMessage} className="flex gap-2">
60 <input type="hidden" name="caseId" value={myCase.documentId} />
61 <input name="body" className="border p-2 flex-1" placeholder="Write a message…" required />
62 <button className="bg-blue-600 text-white px-4 py-2 rounded">Send</button>
63 </form>
64 </section>
65 </main>
66 );
67}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.
1// app/api/documents/[id]/download/route.ts
2import { cookies } from 'next/headers';
3import { NextRequest, NextResponse } from 'next/server';
4
5const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
6
7export async function GET(
8 request: NextRequest,
9 { params }: { params: Promise<{ id: string }> }
10) {
11 const { id } = await params;
12 const cookieStore = await cookies();
13 const token = cookieStore.get('auth_token')?.value;
14
15 const res = await fetch(`${STRAPI_URL}/api/case-documents/${id}/download`, {
16 headers: { Authorization: `Bearer ${token}` },
17 cache: 'no-store',
18 });
19
20 if (!res.ok) {
21 return NextResponse.json({ message: 'Access denied.' }, { status: res.status });
22 }
23
24 const { url } = await res.json();
25 return NextResponse.redirect(new URL(url, STRAPI_URL));
26}The message Server Action posts to Strapi and revalidates the page:
1// app/actions/messageActions.ts
2'use server';
3import { cookies } from 'next/headers';
4import { revalidatePath } from 'next/cache';
5
6const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
7
8export async function sendMessage(formData: FormData) {
9 const cookieStore = await cookies();
10 const token = cookieStore.get('auth_token')?.value;
11 const caseId = formData.get('caseId') as string;
12 const body = formData.get('body') as string;
13
14 await fetch(`${STRAPI_URL}/api/messages`, {
15 method: 'POST',
16 headers: {
17 'Content-Type': 'application/json',
18 Authorization: `Bearer ${token}`,
19 },
20 body: JSON.stringify({
21 data: { case: caseId, body, timestamp: new Date().toISOString() },
22 }),
23 });
24
25 revalidatePath('/dashboard');
26}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.
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.
You have a working portal, but production legal software keeps going. A few directions worth pursuing:
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.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.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.