Insurance claims processing starts with data entry, and a self-service portal removes the phone-call bottleneck from that first step. In this tutorial, you'll build a claims portal where policyholders can submit new claims, view a list of their existing claims, and drill into individual claim details.
The backend runs on Strapi 5, which provides the content model, REST API, and admin panel for reviewing submitted claims. The frontend is a Next.js 16 App Router application using Server Components for data fetching and Server Actions for form submissions. Strapi's Document Service API powers the backend, and its flattened v5 REST response format keeps frontend data access straightforward.
In brief:
{ data: ... } body wrapping. server-only fetch utility and handle form submissions with useActionState.| Dependency | Version |
|---|---|
| Node.js (LTS) | 24.16.0 |
| Strapi | 5.47.0 |
| Next.js | 16.2.6 |
| React | 19.2.x |
| npm | 6+ |
You need a working Node.js 20.9+ LTS installation (for example, Node.js 20, 22, or 24). Strapi 5.47.0 supports Node.js LTS versions 20.x, 22.x, and 24.x. Next.js 16.2.6 requires Node.js 20.9 minimum, and Node.js 24.x exceeds that requirement comfortably.
Background knowledge assumed: TypeScript basics, React component patterns, REST API concepts, and comfort with the terminal.
Open a terminal and scaffold a new Strapi 5 project. Use --non-interactive to skip all prompts:
1npx create-strapi@latest claims-backend --typescript --non-interactive --skip-cloud --no-exampleThis creates a claims-backend directory with TypeScript, SQLite as the default database, and all dependencies installed.
Start the dev server to create your admin account:
1cd claims-backend
2npm run developOpen http://localhost:1337/admin, create your first admin user, and keep the server running.
Insurance claims reference addresses for the loss location. A reusable Strapi component avoids duplicating those fields across content types.
Create the component directory and schema file:
1// src/components/shared/address.json
2{
3 "collectionName": "components_shared_addresses",
4 "info": {
5 "displayName": "Address",
6 "icon": "pinMap",
7 "description": "Reusable address block"
8 },
9 "options": {},
10 "attributes": {
11 "street": {
12 "type": "string",
13 "required": true
14 },
15 "city": {
16 "type": "string",
17 "required": true
18 },
19 "state": {
20 "type": "string",
21 "required": true,
22 "maxLength": 2
23 },
24 "zipCode": {
25 "type": "string",
26 "required": true,
27 "maxLength": 10
28 }
29 }
30}The Claim is the primary entity. Its schema.json maps directly to insurance domain terminology: a claim number, status enum, loss details, and the address component you just created.
Create the full directory structure:
1// src/api/claim/content-types/claim/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "claims",
5 "info": {
6 "singularName": "claim",
7 "pluralName": "claims",
8 "displayName": "Claim",
9 "description": "Insurance claim filed by a policyholder"
10 },
11 "options": {
12 "draftAndPublish": true
13 },
14 "pluginOptions": {},
15 "attributes": {
16 "claimNumber": {
17 "type": "uid",
18 "required": true
19 },
20 "claimantName": {
21 "type": "string",
22 "required": true,
23 "maxLength": 255
24 },
25 "claimantEmail": {
26 "type": "email",
27 "required": true
28 },
29 "policyNumber": {
30 "type": "string",
31 "required": true
32 },
33 "claimType": {
34 "type": "enumeration",
35 "enum": ["accident", "theft", "fire", "water_damage", "liability", "other"],
36 "required": true
37 },
38 "claimStatus": {
39 "type": "enumeration",
40 "enum": [
41 "draft",
42 "submitted",
43 "under_review",
44 "pending_information",
45 "approved",
46 "denied",
47 "closed",
48 "reopened"
49 ],
50 "default": "submitted",
51 "required": true
52 },
53 "lossDate": {
54 "type": "date",
55 "required": true
56 },
57 "lossDescription": {
58 "type": "text",
59 "required": true
60 },
61 "claimAmount": {
62 "type": "decimal"
63 },
64 "lossAddress": {
65 "type": "component",
66 "repeatable": false,
67 "component": "shared.address"
68 },
69 "supportingDocuments": {
70 "type": "media",
71 "multiple": true,
72 "allowedTypes": ["images", "files"]
73 }
74 }
75}Strapi 5 uses core factories to generate the standard CRUD routes. Create three files:
1// src/api/claim/routes/claim.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreRouter('api::claim.claim');1// src/api/claim/controllers/claim.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController('api::claim.claim');1// src/api/claim/services/claim.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreService('api::claim.claim');Restart the Strapi dev server (Ctrl+C, then npm run develop). The Claim content type now appears in the Admin Panel under Content Manager.
Open Settings > Roles & Permissions > Public. Under the relevant permissions section, enable the actions needed for the Claim content type, such as read and create access. Click Save.
Next, create an API token for authenticated requests from the frontend. Go to Settings > Global settings > API Tokens. Click Create new API Token with these settings:
find, findOne, and createCopy the generated token immediately. You won't see it again unless you've configured an encryption key.
In the Admin Panel, go to Content Manager > Claim and create two entries:
CLM-2026-001, Claimant Name: Priya Sharma, Status: submitted, Type: water_damage, Policy Number: POL-88421, Loss Date: 2026-05-15, Description: "Burst pipe in basement caused flooding to finished rooms." CLM-2026-002, Claimant Name: Marcus Chen, Status: under_review, Type: accident, Policy Number: POL-77310, Loss Date: 2026-05-20, Description: "Rear-end collision at intersection. Vehicle towed from scene."Publish both entries. The REST API returns published entries by default, so unpublished drafts won't appear on the frontend unless you pass ?status=draft. When you create new entries via the REST API with draftAndPublish enabled, they're created as published by default—you'll need to either unpublish them through the Admin Panel or adjust their status accordingly if you want them to be drafts.
From the parent directory (one level above claims-backend):
1pnpm create next-app@latest claims-frontend --yes
2cd claims-frontendThe --yes flag accepts defaults: TypeScript, Tailwind CSS, App Router, and Turbopack. Use pnpm create next-app to scaffold the project.
Install the server-only package to guard server-side utilities from accidental client imports:
1pnpm add server-onlyCreate .env.local with your Strapi connection details. The token is the one you generated in Step 5 of the backend setup:
1# .env.local
2STRAPI_URL=http://localhost:1337
3STRAPI_API_TOKEN=your_api_token_here
4NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Variables without the NEXT_PUBLIC_ prefix stay server-only and never leak into the client bundle.
If your claims include uploaded photos, Next.js needs permission to optimize images from Strapi's domain:
1// next.config.ts
2import type { NextConfig } from 'next';
3
4const nextConfig: NextConfig = {
5 images: {
6 remotePatterns: [
7 {
8 protocol: 'http',
9 hostname: 'localhost',
10 port: '1337',
11 pathname: '/uploads/**',
12 },
13 ],
14 },
15};
16
17export default nextConfig;Strapi v5 returns a flattened response format where fields sit directly on the data object, not nested under .attributes. Model your types to match:
1// src/types/strapi.ts
2export interface StrapiDocument {
3 id: number;
4 documentId: string;
5 locale?: string;
6 createdAt?: string;
7 updatedAt?: string;
8 publishedAt?: string | null;
9}
10
11export interface StrapiResponse<T> {
12 data: T;
13 meta: {
14 pagination?: {
15 page: number;
16 pageSize: number;
17 pageCount: number;
18 total: number;
19 };
20 };
21}
22
23export interface Address {
24 id: number;
25 street: string;
26 city: string;
27 state: string;
28 zipCode: string;
29}
30
31export interface Claim extends StrapiDocument {
32 claimNumber: string;
33 claimantName: string;
34 claimantEmail: string;
35 policyNumber: string;
36 claimType: 'accident' | 'theft' | 'fire' | 'water_damage' | 'liability' | 'other';
37 claimStatus: 'draft' | 'submitted' | 'under_review' | 'pending_information' | 'approved' | 'denied' | 'closed' | 'reopened';
38 lossDate: string;
39 lossDescription: string;
40 claimAmount: number | null;
41 lossAddress: Address | null;
42}Both id (numeric) and documentId (string) appear in Strapi v5 responses. The documentId is what you use in URL paths; the numeric id still appears in response bodies. Components like Address are returned as component objects when populated.
This utility runs exclusively on the server. The server-only import causes a build-time error if any Client Component tries to import it:
1// src/lib/strapi.ts
2import 'server-only';
3
4const baseUrl = process.env.STRAPI_URL || 'http://localhost:1337';
5
6export async function fetchAPI<T>(
7 path: string,
8 options: RequestInit = {}
9): Promise<T> {
10 const url = new URL(`/api${path}`, baseUrl);
11
12 const response = await fetch(url.toString(), {
13 headers: {
14 'Content-Type': 'application/json',
15 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
16 ...options.headers,
17 },
18 cache: 'no-store',
19 ...options,
20 });
21
22 if (!response.ok) {
23 throw new Error(`Strapi API error: ${response.status} ${response.statusText}`);
24 }
25
26 return response.json() as Promise<T>;
27}Since Next.js 15, fetch is not cached by default (auto no cache) rather than defaulting to force-cache. The explicit cache: 'no-store' makes this behavior clear and ensures fresh data on every request.
In Next.js 16, pages and layouts are Server Components by default, so you can make the component async and await the Strapi API call directly in the component body. No useEffect, no loading state management, no client-side data fetching library:
1// src/app/claims/page.tsx
2import Link from 'next/link';
3import { fetchAPI } from '@/lib/strapi';
4import { StrapiResponse, Claim } from '@/types/strapi';
5
6async function getClaims(): Promise<StrapiResponse<Claim[]>> {
7 return fetchAPI<StrapiResponse<Claim[]>>(
8 '/claims?sort=createdAt:desc&populate=lossAddress'
9 );
10}
11
12const statusColors: Record<string, string> = {
13 submitted: 'bg-blue-100 text-blue-800',
14 under_review: 'bg-yellow-100 text-yellow-800',
15 approved: 'bg-green-100 text-green-800',
16 denied: 'bg-red-100 text-red-800',
17 closed: 'bg-gray-100 text-gray-800',
18 pending_information: 'bg-orange-100 text-orange-800',
19 reopened: 'bg-purple-100 text-purple-800',
20 draft: 'bg-slate-100 text-slate-800',
21};
22
23export default async function ClaimsPage() {
24 const { data: claims, meta } = await getClaims();
25
26 return (
27 <main className="max-w-4xl mx-auto p-8">
28 <div className="flex justify-between items-center mb-8">
29 <h1 className="text-2xl font-bold">Your Claims</h1>
30 <Link
31 href="/claims/new"
32 className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
33 >
34 File New Claim
35 </Link>
36 </div>
37
38 {claims.length === 0 ? (
39 <p>No claims filed yet.</p>
40 ) : (
41 <ul className="space-y-4">
42 {claims.map((claim) => (
43 <li key={claim.documentId} className="border rounded-lg p-4">
44 <Link href={`/claims/${claim.documentId}`} className="block">
45 <div className="flex justify-between items-start">
46 <div>
47 <p className="font-semibold">{claim.claimNumber}</p>
48 <p className="text-sm text-gray-600">
49 {claim.claimantName} · {claim.policyNumber}
50 </p>
51 <p className="text-sm text-gray-500 mt-1">
52 Loss date: {new Date(claim.lossDate).toLocaleDateString()}
53 </p>
54 </div>
55 <span
56 className={`text-xs font-medium px-2 py-1 rounded ${statusColors[claim.claimStatus] || 'bg-gray-100'}`}
57 >
58 {claim.claimStatus.replace('_', ' ')}
59 </span>
60 </div>
61 </Link>
62 </li>
63 ))}
64 </ul>
65 )}
66
67 {meta.pagination && (
68 <p className="text-sm text-gray-500 mt-6">
69 Total claims: {meta.pagination.total}
70 </p>
71 )}
72 </main>
73 );
74}The claim.documentId is both the React list key and the dynamic route parameter. In Strapi 5, documentId is the stable identifier across locales and draft/published versions.
Dynamic routes in the Next.js 16 App Router receive params as a Promise, so you must await before destructuring:
1// src/app/claims/[documentId]/page.tsx
2import Link from 'next/link';
3import { fetchAPI } from '@/lib/strapi';
4import { StrapiResponse, Claim } from '@/types/strapi';
5
6export default async function ClaimDetailPage({
7 params,
8}: {
9 params: Promise<{ documentId: string }>;
10}) {
11 const { documentId } = await params;
12
13 const { data: claim } = await fetchAPI<StrapiResponse<Claim>>(
14 `/claims/${documentId}?populate=lossAddress`
15 );
16
17 return (
18 <main className="max-w-3xl mx-auto p-8">
19 <Link href="/claims" className="text-blue-600 hover:underline text-sm">
20 ← Back to claims
21 </Link>
22
23 <h1 className="text-2xl font-bold mt-4 mb-6">{claim.claimNumber}</h1>
24
25 <dl className="grid grid-cols-2 gap-4">
26 <div>
27 <dt className="text-sm text-gray-500">Claimant</dt>
28 <dd className="font-medium">{claim.claimantName}</dd>
29 </div>
30 <div>
31 <dt className="text-sm text-gray-500">Status</dt>
32 <dd className="font-medium">{claim.claimStatus.replace('_', ' ')}</dd>
33 </div>
34 <div>
35 <dt className="text-sm text-gray-500">Policy Number</dt>
36 <dd className="font-medium">{claim.policyNumber}</dd>
37 </div>
38 <div>
39 <dt className="text-sm text-gray-500">Claim Type</dt>
40 <dd className="font-medium">{claim.claimType.replace('_', ' ')}</dd>
41 </div>
42 <div>
43 <dt className="text-sm text-gray-500">Loss Date</dt>
44 <dd className="font-medium">
45 {new Date(claim.lossDate).toLocaleDateString()}
46 </dd>
47 </div>
48 <div>
49 <dt className="text-sm text-gray-500">Amount</dt>
50 <dd className="font-medium">
51 {claim.claimAmount
52 ? `${claim.claimAmount.toLocaleString()}`
53 : 'Not specified'}
54 </dd>
55 </div>
56 </dl>
57
58 <div className="mt-6">
59 <h2 className="text-sm text-gray-500 mb-1">Loss Description</h2>
60 <p>{claim.lossDescription}</p>
61 </div>
62
63 {claim.lossAddress && (
64 <div className="mt-6">
65 <h2 className="text-sm text-gray-500 mb-1">Loss Location</h2>
66 <p>
67 {claim.lossAddress.street}, {claim.lossAddress.city},{' '}
68 {claim.lossAddress.state} {claim.lossAddress.zipCode}
69 </p>
70 </div>
71 )}
72 </main>
73 );
74}When creating documents through Strapi 5's REST API, the request body should wrap fields in a data key. Omitting this wrapper can produce a 400 error. Because draftAndPublish is enabled on the Claim content type, entries created via POST are treated as draft/published content managed through Strapi's Draft & Publish workflow.
To make an entry visible in its published state, you must publish it using the appropriate API operation; setting publishedAt in the payload does not itself publish the entry:
1// src/app/claims/new/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5
6const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
7const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
8
9export interface ClaimFormState {
10 success: boolean;
11 error?: string;
12 documentId?: string;
13}
14
15export async function submitClaim(
16 prevState: ClaimFormState | null,
17 formData: FormData
18): Promise<ClaimFormState> {
19 const payload = {
20 claimNumber: `CLM-${Date.now()}`,
21 claimantName: formData.get('claimantName') as string,
22 claimantEmail: formData.get('claimantEmail') as string,
23 policyNumber: formData.get('policyNumber') as string,
24 claimType: formData.get('claimType') as string,
25 claimStatus: 'submitted',
26 lossDate: formData.get('lossDate') as string,
27 lossDescription: formData.get('lossDescription') as string,
28 claimAmount: parseFloat(formData.get('claimAmount') as string) || null,
29 lossAddress: {
30 street: formData.get('street') as string,
31 city: formData.get('city') as string,
32 state: formData.get('state') as string,
33 zipCode: formData.get('zipCode') as string,
34 },
35 publishedAt: new Date().toISOString(),
36 };
37
38 const response = await fetch(`${STRAPI_URL}/api/claims`, {
39 method: 'POST',
40 headers: {
41 'Content-Type': 'application/json',
42 Authorization: `Bearer ${STRAPI_API_TOKEN}`,
43 },
44 body: JSON.stringify({ data: payload }),
45 });
46
47 if (!response.ok) {
48 const errorBody = await response.json();
49 return {
50 success: false,
51 error: errorBody.error?.message ?? 'Failed to submit claim.',
52 };
53 }
54
55 const result = await response.json();
56
57 revalidatePath('/claims');
58 return { success: true, documentId: result.data.documentId };
59}This Client Component uses React 19's useActionState to track the Server Action's pending state and result:
1// src/app/claims/new/ClaimForm.tsx
2'use client';
3
4import { useActionState } from 'react';
5import Link from 'next/link';
6import { submitClaim, ClaimFormState } from './actions';
7
8const initialState: ClaimFormState = { success: false };
9
10const claimTypes = [
11 { value: 'accident', label: 'Accident' },
12 { value: 'theft', label: 'Theft' },
13 { value: 'fire', label: 'Fire' },
14 { value: 'water_damage', label: 'Water Damage' },
15 { value: 'liability', label: 'Liability' },
16 { value: 'other', label: 'Other' },
17];
18
19export default function ClaimForm() {
20 const [state, formAction, isPending] = useActionState(submitClaim, initialState);
21
22 if (state.success) {
23 return (
24 <div className="text-center py-12">
25 <h2 className="text-xl font-semibold text-green-700 mb-2">
26 Claim Submitted
27 </h2>
28 <p className="text-gray-600 mb-4">
29 Your claim has been filed and is now under review.
30 </p>
31 <Link href="/claims" className="text-blue-600 hover:underline">
32 View all claims
33 </Link>
34 </div>
35 );
36 }
37
38 return (
39 <form action={formAction} className="space-y-6">
40 {state.error && (
41 <p role="alert" className="text-red-600 bg-red-50 p-3 rounded">
42 {state.error}
43 </p>
44 )}
45
46 <fieldset className="space-y-4">
47 <legend className="text-lg font-semibold">Claimant Information</legend>
48 <div className="grid grid-cols-2 gap-4">
49 <input
50 name="claimantName"
51 type="text"
52 placeholder="Full name"
53 required
54 className="border rounded px-3 py-2"
55 />
56 <input
57 name="claimantEmail"
58 type="email"
59 placeholder="Email address"
60 required
61 className="border rounded px-3 py-2"
62 />
63 </div>
64 <input
65 name="policyNumber"
66 type="text"
67 placeholder="Policy number (e.g., POL-12345)"
68 required
69 className="border rounded px-3 py-2 w-full"
70 />
71 </fieldset>
72
73 <fieldset className="space-y-4">
74 <legend className="text-lg font-semibold">Incident Details</legend>
75 <div className="grid grid-cols-2 gap-4">
76 <select name="claimType" required className="border rounded px-3 py-2">
77 <option value="">Select claim type</option>
78 {claimTypes.map((type) => (
79 <option key={type.value} value={type.value}>
80 {type.label}
81 </option>
82 ))}
83 </select>
84 <input
85 name="lossDate"
86 type="date"
87 required
88 className="border rounded px-3 py-2"
89 />
90 </div>
91 <textarea
92 name="lossDescription"
93 placeholder="Describe what happened..."
94 required
95 rows={4}
96 className="border rounded px-3 py-2 w-full"
97 />
98 <input
99 name="claimAmount"
100 type="number"
101 step="0.01"
102 placeholder="Estimated claim amount (USD)"
103 className="border rounded px-3 py-2 w-full"
104 />
105 </fieldset>
106
107 <fieldset className="space-y-4">
108 <legend className="text-lg font-semibold">Loss Location</legend>
109 <input
110 name="street"
111 type="text"
112 placeholder="Street address"
113 required
114 className="border rounded px-3 py-2 w-full"
115 />
116 <div className="grid grid-cols-3 gap-4">
117 <input
118 name="city"
119 type="text"
120 placeholder="City"
121 required
122 className="border rounded px-3 py-2"
123 />
124 <input
125 name="state"
126 type="text"
127 placeholder="State"
128 required
129 maxLength={2}
130 className="border rounded px-3 py-2"
131 />
132 <input
133 name="zipCode"
134 type="text"
135 placeholder="ZIP code"
136 required
137 maxLength={10}
138 className="border rounded px-3 py-2"
139 />
140 </div>
141 </fieldset>
142
143 <button
144 type="submit"
145 disabled={isPending}
146 className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
147 >
148 {isPending ? 'Submitting...' : 'Submit Claim'}
149 </button>
150 </form>
151 );
152}This Server Component page wraps the Client Component form:
1// src/app/claims/new/page.tsx
2import Link from 'next/link';
3import ClaimForm from './ClaimForm';
4
5export default function NewClaimPage() {
6 return (
7 <main className="max-w-2xl mx-auto p-8">
8 <Link href="/claims" className="text-blue-600 hover:underline text-sm">
9 ← Back to claims
10 </Link>
11 <h1 className="text-2xl font-bold mt-4 mb-6">File a New Claim</h1>
12 <ClaimForm />
13 </main>
14 );
15}Open two terminal windows. In the first, start the Strapi backend:
1cd claims-backend
2npm run developIn the second, start the Next.js frontend:
1cd claims-frontend
2pnpm devOpen http://localhost:3000/claims in your browser. You should see the two sample claims you created earlier, each showing the claim number, claimant name, policy number, and status badge.
Click a claim to see its full details on the [documentId] route. Click "File New Claim" to open the submission form, fill it out, and submit. The Server Action POSTs to Strapi's REST API, but note that including publishedAt in the payload does not publish the entry when draftAndPublish is enabled in Strapi 5—use the Document Service API with status: 'published' or call publish() after creation to make it visible. revalidatePath('/claims') invalidates the claims list cache, and the new claim appears when you navigate back.
Verify the submission directly with curl:
1curl http://localhost:1337/api/claims?sort=createdAt:desc&pagination[limit]=1 \
2 -H "Authorization: Bearer your_api_token_here"The response uses Strapi v5's flattened format: data[0].claimNumber directly, not data[0].attributes.claimNumber.
FormData with a multipart upload Server Action. submitted can move to under_review). STRAPI_URL and remotePatterns with your production hostname. npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.