# How to Build an Insurance Claims Tool with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a full insurance claims management tool in Lovable by setting up Supabase tables for policies, claims, and documents, then creating a multi-step claims workflow with role-based access for claimants, adjusters, and admins. You get a DataTable for claims, a Sheet panel with claim timeline, file uploads, and automated email notifications via Edge Functions — all without writing a line of backend code manually.

## Before you start

- A Lovable account (Pro plan required for Edge Functions and multiple Supabase tables)
- A Supabase project created at supabase.com with the project URL and anon key ready
- An email service API key for notifications (e.g., Resend — free tier works)
- Basic understanding of how Supabase Row Level Security works
- A Lovable project already created and connected to your Supabase project

## Step-by-step guide

### 1. Set up the database schema with RLS policies

Start by creating all four tables in the Supabase SQL Editor. The schema includes policies, claims, claim_documents, and claim_notes. Each table needs RLS enabled immediately. Define the status enum first so the lifecycle is enforced at the database level, not just the UI.

```
-- Run this in Supabase SQL Editor (Dashboard → SQL Editor → New query)

CREATE TYPE claim_status AS ENUM ('submitted', 'under_review', 'approved', 'rejected', 'closed');
CREATE TYPE user_role AS ENUM ('claimant', 'adjuster', 'admin');

CREATE TABLE profiles (
  id UUID REFERENCES auth.users PRIMARY KEY,
  full_name TEXT NOT NULL,
  role user_role NOT NULL DEFAULT 'claimant',
  email TEXT NOT NULL
);

CREATE TABLE policies (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  claimant_id UUID REFERENCES profiles(id) NOT NULL,
  policy_number TEXT UNIQUE NOT NULL,
  coverage_type TEXT NOT NULL,
  coverage_amount DECIMAL(12,2) NOT NULL,
  active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE claims (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  policy_id UUID REFERENCES policies(id) NOT NULL,
  claimant_id UUID REFERENCES profiles(id) NOT NULL,
  adjuster_id UUID REFERENCES profiles(id),
  status claim_status DEFAULT 'submitted',
  incident_date DATE NOT NULL,
  description TEXT NOT NULL,
  claimed_amount DECIMAL(12,2) NOT NULL,
  approved_amount DECIMAL(12,2),
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE claim_documents (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  claim_id UUID REFERENCES claims(id) ON DELETE CASCADE NOT NULL,
  uploaded_by UUID REFERENCES profiles(id) NOT NULL,
  file_name TEXT NOT NULL,
  storage_path TEXT NOT NULL,
  file_type TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE claim_notes (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  claim_id UUID REFERENCES claims(id) ON DELETE CASCADE NOT NULL,
  author_id UUID REFERENCES profiles(id) NOT NULL,
  note TEXT NOT NULL,
  is_internal BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Enable RLS on all tables
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE policies ENABLE ROW LEVEL SECURITY;
ALTER TABLE claims ENABLE ROW LEVEL SECURITY;
ALTER TABLE claim_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE claim_notes ENABLE ROW LEVEL SECURITY;

-- Profiles: users see own, admins see all
CREATE POLICY "Users see own profile" ON profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Admins see all profiles" ON profiles FOR SELECT USING (
  EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Users insert own profile" ON profiles FOR INSERT WITH CHECK (auth.uid() = id);

-- Claims: claimants see own, adjusters see assigned, admins see all
CREATE POLICY "Claimants see own claims" ON claims FOR SELECT USING (claimant_id = auth.uid());
CREATE POLICY "Adjusters see assigned claims" ON claims FOR SELECT USING (adjuster_id = auth.uid());
CREATE POLICY "Admins see all claims" ON claims FOR ALL USING (
  EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Claimants insert claims" ON claims FOR INSERT WITH CHECK (claimant_id = auth.uid());
CREATE POLICY "Adjusters update assigned claims" ON claims FOR UPDATE USING (adjuster_id = auth.uid());

-- Documents and notes follow claim access
CREATE POLICY "Claim participants see documents" ON claim_documents FOR SELECT USING (
  EXISTS (SELECT 1 FROM claims WHERE id = claim_id AND (claimant_id = auth.uid() OR adjuster_id = auth.uid()))
  OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Claim participants insert documents" ON claim_documents FOR INSERT WITH CHECK (
  EXISTS (SELECT 1 FROM claims WHERE id = claim_id AND (claimant_id = auth.uid() OR adjuster_id = auth.uid()))
);

CREATE POLICY "Non-internal notes visible to claimants" ON claim_notes FOR SELECT USING (
  (is_internal = false AND EXISTS (SELECT 1 FROM claims WHERE id = claim_id AND claimant_id = auth.uid()))
  OR EXISTS (SELECT 1 FROM claims WHERE id = claim_id AND adjuster_id = auth.uid())
  OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Participants insert notes" ON claim_notes FOR INSERT WITH CHECK (
  EXISTS (SELECT 1 FROM claims WHERE id = claim_id AND (claimant_id = auth.uid() OR adjuster_id = auth.uid()))
  OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
```

> Pro tip: Run the CREATE TYPE statements first before the CREATE TABLE statements. If you need to rerun, drop tables in reverse dependency order before dropping the types.

**Expected result:** All five tables appear in Supabase Dashboard under Table Editor with RLS enabled (shown by the lock icon). The SQL Editor shows no errors.

### 2. Prompt Lovable to build the claims DataTable and layout

Now ask Lovable to generate the main claims list page. The DataTable should show claim ID, policy number, status badge, claimed amount, incident date, and assigned adjuster. Include column filtering and a search input. The layout should have a sidebar with navigation for Claims, Policies, and Admin sections depending on user role.

```
Build an insurance claims management dashboard with the following:

1. Main layout with a left sidebar (using shadcn Sidebar) showing navigation items:
   - "My Claims" for claimants
   - "Assigned Claims" for adjusters
   - "All Claims" for admins
   - "Policies" section
   - Admin-only "Users" section

2. Claims list page (/claims) with a shadcn DataTable showing columns:
   - Claim ID (short UUID, monospace)
   - Policy Number
   - Status (shadcn Badge with color: submitted=blue, under_review=yellow, approved=green, rejected=red, closed=gray)
   - Incident Date (formatted)
   - Claimed Amount (formatted currency)
   - Adjuster (name or "Unassigned")
   - Actions column with "View" button

3. Search input above the table filtering by claim ID or policy number
4. Status filter dropdown (All, Submitted, Under Review, Approved, Rejected, Closed)

Fetch data from Supabase table 'claims' joined with 'policies' and 'profiles'. Use the Supabase client from src/integrations/supabase/client.ts. Show skeleton loading state while fetching.
```

> Pro tip: If Lovable generates the DataTable without server-side filtering, ask it to add a useEffect that re-fetches with Supabase .filter() when the status dropdown changes, instead of filtering in JavaScript.

**Expected result:** The claims list page renders with the DataTable, showing loading skeletons then populated rows. Status badges display in the correct colors. The search input filters rows as you type.

### 3. Build the claim detail Sheet with timeline and file upload

The claim detail view is a full-width slide-out Sheet panel that opens when clicking any claim row. It shows all claim fields, an activity timeline combining status changes and notes, and a multi-file upload section for claim documents. Adjusters see an additional section with status transition controls.

```
Add a claim detail Sheet panel that opens when clicking any row in the claims DataTable:

1. Sheet slides in from the right, 600px wide
2. Sheet header: Claim ID, status Badge, close button
3. Tabs inside the Sheet (shadcn Tabs):
   - "Details" tab: all claim fields in a two-column grid (policy number, incident date, description, claimed amount, approved amount if exists)
   - "Documents" tab: list of uploaded files with download links + multi-file upload dropzone
   - "Timeline" tab: chronological list of status changes and notes (internal notes shown only to adjusters/admins with a lock icon badge)

4. For adjusters and admins, add a sticky footer inside the Sheet with:
   - "Status" select showing valid next transitions only
   - "Add Note" textarea with "Internal" checkbox
   - "Save Changes" button

5. Document upload: accept PDF, PNG, JPG. Upload to Supabase Storage bucket 'claim-documents' with path: {claim_id}/{timestamp}-{filename}. Insert record into claim_documents table after successful upload.

Query claim_documents and claim_notes for the selected claim ID. Show shadcn Skeleton while loading each tab.
```

**Expected result:** Clicking a claim row opens the Sheet. All three tabs load correctly. Files can be dragged into the upload zone and appear in the Documents tab after upload. Timeline shows notes in chronological order.

### 4. Create the Edge Function for status transitions and email notifications

Status changes need validation (e.g., can't approve without a document, can't reopen a closed claim) and should trigger email notifications. Create a Supabase Edge Function that enforces these rules and sends emails via Resend. Add the Resend API key to Lovable's Secrets.

```
// supabase/functions/transition-claim-status/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

const VALID_TRANSITIONS: Record<string, string[]> = {
  submitted: ['under_review'],
  under_review: ['approved', 'rejected'],
  approved: ['closed'],
  rejected: ['closed'],
  closed: [],
};

serve(async (req) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });

  try {
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    );

    const { claim_id, new_status, note, approved_amount } = await req.json();

    const { data: claim, error: fetchError } = await supabase
      .from('claims')
      .select('*, policies(*), profiles!claims_claimant_id_fkey(*)')
      .eq('id', claim_id)
      .single();

    if (fetchError || !claim) throw new Error('Claim not found');

    const validNext = VALID_TRANSITIONS[claim.status] || [];
    if (!validNext.includes(new_status)) {
      return new Response(JSON.stringify({ error: `Cannot transition from ${claim.status} to ${new_status}` }), {
        status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }
      });
    }

    if (new_status === 'approved') {
      const { count } = await supabase
        .from('claim_documents')
        .select('id', { count: 'exact' })
        .eq('claim_id', claim_id);
      if ((count || 0) === 0) {
        return new Response(JSON.stringify({ error: 'Cannot approve claim without at least one document' }), {
          status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }
        });
      }
    }

    const updateData: Record<string, unknown> = { status: new_status, updated_at: new Date().toISOString() };
    if (new_status === 'approved' && approved_amount) updateData.approved_amount = approved_amount;

    await supabase.from('claims').update(updateData).eq('id', claim_id);

    if (note) {
      const authHeader = req.headers.get('Authorization')!;
      const { data: { user } } = await supabase.auth.getUser(authHeader.replace('Bearer ', ''));
      await supabase.from('claim_notes').insert({
        claim_id, author_id: user!.id, note,
        is_internal: new_status === 'under_review'
      });
    }

    const resendKey = Deno.env.get('RESEND_API_KEY');
    if (resendKey && claim.profiles?.email) {
      await fetch('https://api.resend.com/emails', {
        method: 'POST',
        headers: { Authorization: `Bearer ${resendKey}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          from: 'claims@yourdomain.com',
          to: claim.profiles.email,
          subject: `Claim ${claim_id.slice(0, 8)} status updated to ${new_status}`,
          html: `<p>Your claim status has been updated to <strong>${new_status}</strong>.</p>${note ? `<p>Note: ${note}</p>` : ''}`
        })
      });
    }

    return new Response(JSON.stringify({ success: true, new_status }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    });
  } catch (err) {
    return new Response(JSON.stringify({ error: err.message }), {
      status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    });
  }
});
```

> Pro tip: Add the Resend API key in Lovable via Cloud tab → Secrets → Add Secret with key name RESEND_API_KEY. Edge Functions automatically receive SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY — you don't need to add those manually.

**Expected result:** The Edge Function deploys successfully. Status transitions that violate business rules return a 400 error message. Valid transitions update the claim and trigger an email to the claimant.

### 5. Build the claim submission form for claimants

Claimants need a guided multi-step form to submit new claims. Step 1 selects a policy. Step 2 fills incident details. Step 3 uploads supporting documents. Each step is validated with Zod before proceeding. On completion, the claim is inserted and the claimant is redirected to their claims list.

```
Create a multi-step claim submission form at /claims/new with these steps:

Step 1 - Select Policy:
- shadcn Select populated with the claimant's active policies from Supabase
- Show policy number, coverage type, and coverage amount for each option
- Validate that a policy is selected before proceeding

Step 2 - Incident Details (react-hook-form + zod):
- Incident date (shadcn Calendar via Popover, max date = today)
- Description textarea (min 50 characters, max 1000)
- Claimed amount (number input, max = policy coverage_amount)
- Validation: incident date cannot be in the future

Step 3 - Document Upload:
- File dropzone accepting PDF, PNG, JPG (max 10MB each, max 5 files)
- Show file list with remove buttons
- Upload files to Supabase Storage 'claim-documents' bucket
- Files are required (at least 1)

Progress indicator at top showing Step 1 / 2 / 3 using shadcn Progress
Back and Next buttons between steps
On final submit: insert into 'claims' table, then insert into 'claim_documents' for each file
Redirect to /claims on success with a toast notification: "Claim submitted successfully"
```

**Expected result:** The three-step form renders with a progress bar. Attempting to proceed without completing a step shows inline validation errors. Successfully completing step 3 inserts the claim and redirects to the claims list with a success toast.

### 6. Add the admin dashboard with stats and assignment controls

Admins need a summary view showing claim counts by status, average processing time, and a queue of unassigned claims. They should be able to assign adjusters directly from this dashboard. Add a simple stats card row at the top of the admin view.

```
Add an admin-only dashboard page at /admin with:

1. Stats row using shadcn Card (4 cards side by side):
   - Total Claims (count of all claims)
   - Pending Review (count where status = 'under_review')
   - Approved This Month (count approved in current calendar month)
   - Unassigned (count where adjuster_id IS NULL and status = 'submitted')

2. Unassigned Claims table below the stats:
   - Show claim ID, claimant name, policy number, incident date, claimed amount
   - Each row has an "Assign Adjuster" button opening a shadcn Dialog
   - Dialog shows a Select of all users with role='adjuster'
   - On confirm, update claims.adjuster_id and show success toast

3. Redirect non-admin users to /claims with a toast error "Access denied"

Query stats using Supabase .select('status').then count in JavaScript.
Check user role from profiles table on page load.
Polling interval: re-fetch stats every 60 seconds using setInterval.
```

> Pro tip: Ask Lovable to use Supabase .select('id', { count: 'exact' }).eq('status', 'under_review') for each stat card. Four separate small queries are faster and clearer than one aggregated query when you're just counting.

**Expected result:** Admin users see the stats dashboard with accurate counts. Non-admin users are redirected. The assign adjuster dialog updates the claim and the Unassigned count card decreases immediately.

## Complete code example

File: `src/components/claims/ClaimStatusBadge.tsx`

```typescript
import { Badge } from '@/components/ui/badge';

type ClaimStatus = 'submitted' | 'under_review' | 'approved' | 'rejected' | 'closed';

const STATUS_CONFIG: Record<ClaimStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; className: string }> = {
  submitted: { label: 'Submitted', variant: 'default', className: 'bg-blue-100 text-blue-800 hover:bg-blue-100' },
  under_review: { label: 'Under Review', variant: 'secondary', className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100' },
  approved: { label: 'Approved', variant: 'default', className: 'bg-green-100 text-green-800 hover:bg-green-100' },
  rejected: { label: 'Rejected', variant: 'destructive', className: 'bg-red-100 text-red-800 hover:bg-red-100' },
  closed: { label: 'Closed', variant: 'outline', className: 'bg-gray-100 text-gray-600 hover:bg-gray-100' },
};

interface ClaimStatusBadgeProps {
  status: ClaimStatus;
}

export function ClaimStatusBadge({ status }: ClaimStatusBadgeProps) {
  const config = STATUS_CONFIG[status];
  return (
    <Badge variant={config.variant} className={config.className}>
      {config.label}
    </Badge>
  );
}

export const VALID_TRANSITIONS: Record<ClaimStatus, ClaimStatus[]> = {
  submitted: ['under_review'],
  under_review: ['approved', 'rejected'],
  approved: ['closed'],
  rejected: ['closed'],
  closed: [],
};

export function getNextStatuses(current: ClaimStatus): ClaimStatus[] {
  return VALID_TRANSITIONS[current] || [];
}
```

## Common mistakes

- **Forgetting to enable RLS before adding policies** — If you create policies but RLS is not enabled on the table, the policies have no effect and all rows are publicly readable Fix: Always run ALTER TABLE x ENABLE ROW LEVEL SECURITY before creating any policies. Check the lock icon on each table in Supabase Dashboard under Table Editor.
- **Using the anon key in the Edge Function for operations that need to bypass RLS** — The anon key respects RLS, so actions like reading all claims for admin purposes will be blocked Fix: Use SUPABASE_SERVICE_ROLE_KEY inside Edge Functions for server-side operations. Never expose the service role key in frontend code.
- **Uploading files directly from the frontend without a storage bucket policy** — If the claim-documents bucket has no RLS policy, either all uploads fail (private bucket) or anyone can read any file Fix: In Supabase Dashboard → Storage → Policies, add a policy allowing authenticated users to upload to paths starting with their claim ID, and read paths they have claim access to.
- **Not handling optimistic updates after status transitions** — After calling the Edge Function, the UI still shows the old status until the next data fetch, making the app feel broken Fix: After a successful transition response, update the local state immediately with the new status, then trigger a background re-fetch to confirm the database state.
- **Allowing any status transition in the UI without server-side validation** — Users can bypass UI restrictions by calling the API directly Fix: The Edge Function is the source of truth for valid transitions. The UI transition select should only show valid options as UX convenience, but the Edge Function always re-validates.

## Best practices

- Always use SUPABASE_SERVICE_ROLE_KEY only inside Edge Functions, never in frontend code — it bypasses all RLS
- Set up the claim-documents Storage bucket as private, and generate signed URLs server-side for document downloads
- Add a trigger function in PostgreSQL to automatically update claims.updated_at on every UPDATE
- Use Supabase Realtime on the claims table so adjusters see new submissions without refreshing
- Log all status transitions to claim_notes as internal notes for a complete audit trail
- Test RLS policies thoroughly by signing in as different role users and verifying data visibility in the Supabase Table Editor
- Add loading states on every async action — claim submission, file upload, status transitions — using shadcn Button's disabled state
- Validate file types on the frontend (accept attribute) AND in the Edge Function to prevent unauthorized file type uploads

## Frequently asked questions

### How do I make sure claimants can't see other claimants' documents?

This is handled by Row Level Security at the database level. The policy on claim_documents checks that the claim_id belongs to a claim where the current user is either the claimant or the assigned adjuster. Even if a user tries to query documents directly via the Supabase API, RLS blocks access to rows they don't own.

### Can I deploy this to my own domain after building in Lovable?

Yes. Click the Publish icon in the top-right of Lovable, then connect a custom domain in the publish settings. Lovable Pro and higher plans support custom domains. Your Supabase backend stays separate and connected regardless of where the frontend is deployed.

### Why does the Edge Function need the service role key instead of the anon key?

The anon key respects all RLS policies, which means the function would only see data the currently authenticated user is allowed to see. For operations like sending admin emails, reading all claims for validation, or performing cross-user updates, you need the service role key which bypasses RLS. Keep this key only in Secrets — never in frontend code.

### How do I handle large file uploads (100MB+ PDFs) in Supabase Storage?

For files over 6MB, use Supabase Storage's resumable upload feature. Ask Lovable to use the upload method from @supabase/storage-js with the multipart option. In the Supabase Dashboard under Storage settings, you can also increase the file size limit per bucket.

### What happens if the Edge Function fails after updating the status but before sending the email?

The status update will persist in the database but the email won't send. For production use, consider wrapping the DB update and email in a way that logs failed emails to a separate table, then add a retry Edge Function. Alternatively, use a Supabase Database Webhook that triggers the email function on status change rather than inline.

### Can I customize the status names (e.g., use 'pending' instead of 'submitted')?

Yes, but change them in the SQL enum before inserting any data. Once you have rows using enum values, you'll need to run ALTER TYPE claim_status RENAME VALUE 'submitted' TO 'pending' in the SQL Editor. Update the corresponding frontend code in Lovable to match.

### How should I test that RLS policies are working correctly?

In the Supabase Dashboard, go to the SQL Editor and run: SET ROLE authenticated; SET request.jwt.claims TO '{"sub": "user-uuid-here"}'; then run your SELECT queries. This impersonates a user and shows exactly what they can see. Create test users with each role and verify the results match your expectations.

### Can RapidDev help me extend this claims tool with custom integrations?

Yes — RapidDev specializes in extending Lovable-built apps with custom backend integrations, payment processors, and legacy system connectors. If you need to connect your claims tool to an existing policy management system or payment disbursement API, that's a common project type.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/insurance-claims-tool
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/insurance-claims-tool
