✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem12 min read

How to Build an Insurance Claims Portal with Strapi 5: A Step-by-Step Guide

June 4, 2026Updated on July 6, 2026
How to Build an Insurance Claims Portal

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-only fetch utility and handle form submissions with useActionState.

Prerequisites

DependencyVersion
Node.js (LTS)24.16.0
Strapi5.47.0
Next.js16.2.6
React19.2.x
npm6+

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-example

This 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 develop

Open 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, and create

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:

  1. 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."
  2. 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-frontend

The --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-only

Step 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:1337

Variables 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} &middot; {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">
        &larr; 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">
        &larr; 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 develop

In the second, start the Next.js frontend:

cd claims-frontend
pnpm dev

Open 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 FormData with a multipart upload Server Action.
  • Claim status workflow: Add a custom Strapi controller that enforces valid status transitions (e.g., only submitted can move to under_review).
  • Deploy to production: Host Strapi on Strapi Cloud or any Node.js-capable provider, and deploy Next.js to Vercel. Update STRAPI_URL and remotePatterns with your production hostname.
  • Add pagination: Wire up Strapi's page-based pagination parameters to navigation controls in the claims list.

Paul BratslavskyDeveloper Advocate
Build a Gift Registry Platform
Ecosystem·20 min read

How to Build a Gift Registry and Wishlist Platform with Next.js 16 and Strapi 5

Gift registries have a tricky requirement most apps don't: the owner should never know who claimed what. A wedding...

·June 26, 2026
Build a Mental Health Journal
Ecosystem·23 min read

How to Build a Mental Health Journal and Mood Tracker with Next.js 16 and Strapi 5

Tracking your mood over time sounds simple until you try to build it: every entry has to stay private, the trends need...

·June 26, 2026
Ecosystem·22 min read

How to Build a Webinar and Workshop Registration Platform with Next.js 16 and Strapi 5

Running webinars and workshops sounds simple until you hit the operational details: capacity limits that need...

·June 26, 2026