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:
- Define collection types in Strapi 5 with enumerations, relations, and reusable components.
- Consume the Strapi v5 REST API from Next.js 16 Server Components.
- Submit claim data through Server Actions with proper
{ data: ... }body wrapping. - Build a typed,
server-onlyfetch utility and handle form submissions withuseActionState.
Prerequisites
| 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.
Setting Up the Strapi Backend
Step 1: Create a New Strapi Project
Open a terminal and scaffold a new Strapi 5 project. Use --non-interactive to skip all prompts:
npx 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:
cd claims-backend
npm run developOpen http://localhost:1337/admin, create your first admin user, and keep the server running.
Step 2: Create an Address Component
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:
// src/components/shared/address.json
{
"collectionName": "components_shared_addresses",
"info": {
"displayName": "Address",
"icon": "pinMap",
"description": "Reusable address block"
},
"options": {},
"attributes": {
"street": {
"type": "string",
"required": true
},
"city": {
"type": "string",
"required": true
},
"state": {
"type": "string",
"required": true,
"maxLength": 2
},
"zipCode": {
"type": "string",
"required": true,
"maxLength": 10
}
}
}Step 3: Define the Claim Content Type
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:
// src/api/claim/content-types/claim/schema.json
{
"kind": "collectionType",
"collectionName": "claims",
"info": {
"singularName": "claim",
"pluralName": "claims",
"displayName": "Claim",
"description": "Insurance claim filed by a policyholder"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"claimNumber": {
"type": "uid",
"required": true
},
"claimantName": {
"type": "string",
"required": true,
"maxLength": 255
},
"claimantEmail": {
"type": "email",
"required": true
},
"policyNumber": {
"type": "string",
"required": true
},
"claimType": {
"type": "enumeration",
"enum": ["accident", "theft", "fire", "water_damage", "liability", "other"],
"required": true
},
"claimStatus": {
"type": "enumeration",
"enum": [
"draft",
"submitted",
"under_review",
"pending_information",
"approved",
"denied",
"closed",
"reopened"
],
"default": "submitted",
"required": true
},
"lossDate": {
"type": "date",
"required": true
},
"lossDescription": {
"type": "text",
"required": true
},
"claimAmount": {
"type": "decimal"
},
"lossAddress": {
"type": "component",
"repeatable": false,
"component": "shared.address"
},
"supportingDocuments": {
"type": "media",
"multiple": true,
"allowedTypes": ["images", "files"]
}
}
}Step 4: Add the Route, Controller, and Service
Strapi 5 uses core factories to generate the standard CRUD routes. Create three files:
// src/api/claim/routes/claim.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::claim.claim');// src/api/claim/controllers/claim.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::claim.claim');// src/api/claim/services/claim.ts
import { factories } from '@strapi/strapi';
export 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.
Step 5: Configure API Permissions and Create a Token
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:
- Name: Frontend Access
- Token type: Custom
- Duration: Unlimited
- Claim permissions: Select
find,findOne, andcreate
Copy the generated token immediately. You won't see it again unless you've configured an encryption key.
Step 6: Add Sample Data
In the Admin Panel, go to Content Manager > Claim and create two entries:
- Claim Number:
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." - Claim Number:
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.
Building the Next.js Frontend
Step 1: Scaffold the Next.js App
From the parent directory (one level above claims-backend):
pnpm create next-app@latest claims-frontend --yes
cd 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:
pnpm add server-onlyStep 2: Configure Environment Variables
Create .env.local with your Strapi connection details. The token is the one you generated in Step 5 of the backend setup:
# .env.local
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_here
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Variables without the NEXT_PUBLIC_ prefix stay server-only and never leak into the client bundle.
Step 3: Configure next.config.ts for Strapi Images
If your claims include uploaded photos, Next.js needs permission to optimize images from Strapi's domain:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '1337',
pathname: '/uploads/**',
},
],
},
};
export default nextConfig;Step 4: Define TypeScript Types
Strapi v5 returns a flattened response format where fields sit directly on the data object, not nested under .attributes. Model your types to match:
// src/types/strapi.ts
export interface StrapiDocument {
id: number;
documentId: string;
locale?: string;
createdAt?: string;
updatedAt?: string;
publishedAt?: string | null;
}
export interface StrapiResponse<T> {
data: T;
meta: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface Address {
id: number;
street: string;
city: string;
state: string;
zipCode: string;
}
export interface Claim extends StrapiDocument {
claimNumber: string;
claimantName: string;
claimantEmail: string;
policyNumber: string;
claimType: 'accident' | 'theft' | 'fire' | 'water_damage' | 'liability' | 'other';
claimStatus: 'draft' | 'submitted' | 'under_review' | 'pending_information' | 'approved' | 'denied' | 'closed' | 'reopened';
lossDate: string;
lossDescription: string;
claimAmount: number | null;
lossAddress: Address | null;
}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.
Step 5: Create the Fetch Utility
This utility runs exclusively on the server. The server-only import causes a build-time error if any Client Component tries to import it:
// src/lib/strapi.ts
import 'server-only';
const baseUrl = process.env.STRAPI_URL || 'http://localhost:1337';
export async function fetchAPI<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const url = new URL(`/api${path}`, baseUrl);
const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
...options.headers,
},
cache: 'no-store',
...options,
});
if (!response.ok) {
throw new Error(`Strapi API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}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.
Step 6: Build the Claims List Page
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:
// src/app/claims/page.tsx
import Link from 'next/link';
import { fetchAPI } from '@/lib/strapi';
import { StrapiResponse, Claim } from '@/types/strapi';
async function getClaims(): Promise<StrapiResponse<Claim[]>> {
return fetchAPI<StrapiResponse<Claim[]>>(
'/claims?sort=createdAt:desc&populate=lossAddress'
);
}
const statusColors: Record<string, string> = {
submitted: 'bg-blue-100 text-blue-800',
under_review: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
denied: 'bg-red-100 text-red-800',
closed: 'bg-gray-100 text-gray-800',
pending_information: 'bg-orange-100 text-orange-800',
reopened: 'bg-purple-100 text-purple-800',
draft: 'bg-slate-100 text-slate-800',
};
export default async function ClaimsPage() {
const { data: claims, meta } = await getClaims();
return (
<main className="max-w-4xl mx-auto p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">Your Claims</h1>
<Link
href="/claims/new"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
File New Claim
</Link>
</div>
{claims.length === 0 ? (
<p>No claims filed yet.</p>
) : (
<ul className="space-y-4">
{claims.map((claim) => (
<li key={claim.documentId} className="border rounded-lg p-4">
<Link href={`/claims/${claim.documentId}`} className="block">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold">{claim.claimNumber}</p>
<p className="text-sm text-gray-600">
{claim.claimantName} · {claim.policyNumber}
</p>
<p className="text-sm text-gray-500 mt-1">
Loss date: {new Date(claim.lossDate).toLocaleDateString()}
</p>
</div>
<span
className={`text-xs font-medium px-2 py-1 rounded ${statusColors[claim.claimStatus] || 'bg-gray-100'}`}
>
{claim.claimStatus.replace('_', ' ')}
</span>
</div>
</Link>
</li>
))}
</ul>
)}
{meta.pagination && (
<p className="text-sm text-gray-500 mt-6">
Total claims: {meta.pagination.total}
</p>
)}
</main>
);
}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.
Step 7: Build the Claim Detail Page
Dynamic routes in the Next.js 16 App Router receive params as a Promise, so you must await before destructuring:
// src/app/claims/[documentId]/page.tsx
import Link from 'next/link';
import { fetchAPI } from '@/lib/strapi';
import { StrapiResponse, Claim } from '@/types/strapi';
export default async function ClaimDetailPage({
params,
}: {
params: Promise<{ documentId: string }>;
}) {
const { documentId } = await params;
const { data: claim } = await fetchAPI<StrapiResponse<Claim>>(
`/claims/${documentId}?populate=lossAddress`
);
return (
<main className="max-w-3xl mx-auto p-8">
<Link href="/claims" className="text-blue-600 hover:underline text-sm">
← Back to claims
</Link>
<h1 className="text-2xl font-bold mt-4 mb-6">{claim.claimNumber}</h1>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-gray-500">Claimant</dt>
<dd className="font-medium">{claim.claimantName}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Status</dt>
<dd className="font-medium">{claim.claimStatus.replace('_', ' ')}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Policy Number</dt>
<dd className="font-medium">{claim.policyNumber}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Claim Type</dt>
<dd className="font-medium">{claim.claimType.replace('_', ' ')}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Loss Date</dt>
<dd className="font-medium">
{new Date(claim.lossDate).toLocaleDateString()}
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Amount</dt>
<dd className="font-medium">
{claim.claimAmount
? `${claim.claimAmount.toLocaleString()}`
: 'Not specified'}
</dd>
</div>
</dl>
<div className="mt-6">
<h2 className="text-sm text-gray-500 mb-1">Loss Description</h2>
<p>{claim.lossDescription}</p>
</div>
{claim.lossAddress && (
<div className="mt-6">
<h2 className="text-sm text-gray-500 mb-1">Loss Location</h2>
<p>
{claim.lossAddress.street}, {claim.lossAddress.city},{' '}
{claim.lossAddress.state} {claim.lossAddress.zipCode}
</p>
</div>
)}
</main>
);
}Step 8: Create the Server Action for Claim Submission
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:
// src/app/claims/new/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
export interface ClaimFormState {
success: boolean;
error?: string;
documentId?: string;
}
export async function submitClaim(
prevState: ClaimFormState | null,
formData: FormData
): Promise<ClaimFormState> {
const payload = {
claimNumber: `CLM-${Date.now()}`,
claimantName: formData.get('claimantName') as string,
claimantEmail: formData.get('claimantEmail') as string,
policyNumber: formData.get('policyNumber') as string,
claimType: formData.get('claimType') as string,
claimStatus: 'submitted',
lossDate: formData.get('lossDate') as string,
lossDescription: formData.get('lossDescription') as string,
claimAmount: parseFloat(formData.get('claimAmount') as string) || null,
lossAddress: {
street: formData.get('street') as string,
city: formData.get('city') as string,
state: formData.get('state') as string,
zipCode: formData.get('zipCode') as string,
},
publishedAt: new Date().toISOString(),
};
const response = await fetch(`${STRAPI_URL}/api/claims`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
},
body: JSON.stringify({ data: payload }),
});
if (!response.ok) {
const errorBody = await response.json();
return {
success: false,
error: errorBody.error?.message ?? 'Failed to submit claim.',
};
}
const result = await response.json();
revalidatePath('/claims');
return { success: true, documentId: result.data.documentId };
}Step 9: Build the Claim Submission Form
This Client Component uses React 19's useActionState to track the Server Action's pending state and result:
// src/app/claims/new/ClaimForm.tsx
'use client';
import { useActionState } from 'react';
import Link from 'next/link';
import { submitClaim, ClaimFormState } from './actions';
const initialState: ClaimFormState = { success: false };
const claimTypes = [
{ value: 'accident', label: 'Accident' },
{ value: 'theft', label: 'Theft' },
{ value: 'fire', label: 'Fire' },
{ value: 'water_damage', label: 'Water Damage' },
{ value: 'liability', label: 'Liability' },
{ value: 'other', label: 'Other' },
];
export default function ClaimForm() {
const [state, formAction, isPending] = useActionState(submitClaim, initialState);
if (state.success) {
return (
<div className="text-center py-12">
<h2 className="text-xl font-semibold text-green-700 mb-2">
Claim Submitted
</h2>
<p className="text-gray-600 mb-4">
Your claim has been filed and is now under review.
</p>
<Link href="/claims" className="text-blue-600 hover:underline">
View all claims
</Link>
</div>
);
}
return (
<form action={formAction} className="space-y-6">
{state.error && (
<p role="alert" className="text-red-600 bg-red-50 p-3 rounded">
{state.error}
</p>
)}
<fieldset className="space-y-4">
<legend className="text-lg font-semibold">Claimant Information</legend>
<div className="grid grid-cols-2 gap-4">
<input
name="claimantName"
type="text"
placeholder="Full name"
required
className="border rounded px-3 py-2"
/>
<input
name="claimantEmail"
type="email"
placeholder="Email address"
required
className="border rounded px-3 py-2"
/>
</div>
<input
name="policyNumber"
type="text"
placeholder="Policy number (e.g., POL-12345)"
required
className="border rounded px-3 py-2 w-full"
/>
</fieldset>
<fieldset className="space-y-4">
<legend className="text-lg font-semibold">Incident Details</legend>
<div className="grid grid-cols-2 gap-4">
<select name="claimType" required className="border rounded px-3 py-2">
<option value="">Select claim type</option>
{claimTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<input
name="lossDate"
type="date"
required
className="border rounded px-3 py-2"
/>
</div>
<textarea
name="lossDescription"
placeholder="Describe what happened..."
required
rows={4}
className="border rounded px-3 py-2 w-full"
/>
<input
name="claimAmount"
type="number"
step="0.01"
placeholder="Estimated claim amount (USD)"
className="border rounded px-3 py-2 w-full"
/>
</fieldset>
<fieldset className="space-y-4">
<legend className="text-lg font-semibold">Loss Location</legend>
<input
name="street"
type="text"
placeholder="Street address"
required
className="border rounded px-3 py-2 w-full"
/>
<div className="grid grid-cols-3 gap-4">
<input
name="city"
type="text"
placeholder="City"
required
className="border rounded px-3 py-2"
/>
<input
name="state"
type="text"
placeholder="State"
required
maxLength={2}
className="border rounded px-3 py-2"
/>
<input
name="zipCode"
type="text"
placeholder="ZIP code"
required
maxLength={10}
className="border rounded px-3 py-2"
/>
</div>
</fieldset>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Submitting...' : 'Submit Claim'}
</button>
</form>
);
}Step 10: Create the New Claim Page
This Server Component page wraps the Client Component form:
// src/app/claims/new/page.tsx
import Link from 'next/link';
import ClaimForm from './ClaimForm';
export default function NewClaimPage() {
return (
<main className="max-w-2xl mx-auto p-8">
<Link href="/claims" className="text-blue-600 hover:underline text-sm">
← Back to claims
</Link>
<h1 className="text-2xl font-bold mt-4 mb-6">File a New Claim</h1>
<ClaimForm />
</main>
);
}Running the Full Stack
Open two terminal windows. In the first, start the Strapi backend:
cd claims-backend
npm run developIn the second, start the Next.js frontend:
cd claims-frontend
pnpm 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:
curl http://localhost:1337/api/claims?sort=createdAt:desc&pagination[limit]=1 \
-H "Authorization: Bearer your_api_token_here"The response uses Strapi v5's flattened format: data[0].claimNumber directly, not data[0].attributes.claimNumber.
Next Steps
- Add authentication: Implement JWT-based login with Strapi's Users & Permissions plugin so policyholders see only their own claims. Store the JWT in an HttpOnly cookie via a Server Action.
- File uploads: Attach photos, police reports, and invoices to claims using Strapi's Media Library. Combine
FormDatawith a multipart upload Server Action. - Claim status workflow: Add a custom Strapi controller that enforces valid status transitions (e.g., only
submittedcan move tounder_review). - Deploy to production: Host Strapi on Strapi Cloud or any Node.js-capable provider, and deploy Next.js to Vercel. Update
STRAPI_URLandremotePatternswith your production hostname. - Add pagination: Wire up Strapi's page-based pagination parameters to navigation controls in the claims list.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.