# How to Build a Medical Records App with Lovable

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

## TL;DR

Build a secure medical records app in Lovable with strict Supabase RLS ensuring providers only see their assigned patients and patients only see their own records. You get tabbed patient charts, appointment and prescription management, encrypted document storage, and audit logging — with a HIPAA disclaimer required for any real clinical use. This is an advanced build due to the security requirements.

## Before you start

- A Lovable Pro account (required for Edge Functions and multi-table Supabase setups)
- A Supabase project with URL and anon key ready
- Understanding that this demo build is NOT HIPAA-compliant for real patient data without additional compliance work
- A Lovable project connected to Supabase via Cloud tab
- Basic familiarity with Supabase RLS concepts before starting this advanced build

## Step-by-step guide

### 1. Set up the secure database schema with role-based RLS

The schema is the most important part of this build. Create tables for profiles (with roles), patients, provider_patients (the assignment table), appointments, prescriptions, medical_documents, and audit_logs. Enable RLS on every table immediately. The provider_patients junction table is what makes the access control work.

```
-- Run in Supabase SQL Editor
-- IMPORTANT: This is for demonstration only. Not for use with real patient data without HIPAA compliance.

CREATE TYPE user_role AS ENUM ('patient', 'provider', 'admin');
CREATE TYPE appointment_status AS ENUM ('scheduled', 'confirmed', 'completed', 'cancelled', 'no_show');
CREATE TYPE prescription_status AS ENUM ('active', 'completed', 'discontinued', 'expired');

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

CREATE TABLE patients (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users UNIQUE,
  first_name TEXT NOT NULL,
  last_name TEXT NOT NULL,
  date_of_birth DATE NOT NULL,
  gender TEXT,
  phone TEXT,
  email TEXT,
  address TEXT,
  emergency_contact_name TEXT,
  emergency_contact_phone TEXT,
  blood_type TEXT,
  allergies TEXT[],
  medical_history TEXT,
  insurance_provider TEXT,
  insurance_id TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE provider_patients (
  provider_id UUID REFERENCES auth.users NOT NULL,
  patient_id UUID REFERENCES patients(id) NOT NULL,
  assigned_at TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (provider_id, patient_id)
);

CREATE TABLE appointments (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  patient_id UUID REFERENCES patients(id) ON DELETE CASCADE NOT NULL,
  provider_id UUID REFERENCES auth.users NOT NULL,
  scheduled_at TIMESTAMPTZ NOT NULL,
  duration_minutes INTEGER NOT NULL DEFAULT 30,
  status appointment_status DEFAULT 'scheduled',
  appointment_type TEXT NOT NULL,
  chief_complaint TEXT,
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE prescriptions (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  patient_id UUID REFERENCES patients(id) ON DELETE CASCADE NOT NULL,
  provider_id UUID REFERENCES auth.users NOT NULL,
  medication_name TEXT NOT NULL,
  dosage TEXT NOT NULL,
  frequency TEXT NOT NULL,
  start_date DATE NOT NULL,
  end_date DATE,
  status prescription_status DEFAULT 'active',
  instructions TEXT,
  refills_remaining INTEGER DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE medical_documents (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  patient_id UUID REFERENCES patients(id) ON DELETE CASCADE NOT NULL,
  uploaded_by UUID REFERENCES auth.users NOT NULL,
  document_name TEXT NOT NULL,
  document_type TEXT NOT NULL,
  storage_path TEXT NOT NULL,
  file_size INTEGER,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE audit_logs (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users NOT NULL,
  action TEXT NOT NULL,
  table_name TEXT NOT NULL,
  record_id UUID,
  patient_id UUID REFERENCES patients(id),
  ip_address INET,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Enable RLS on ALL tables
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE provider_patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
ALTER TABLE prescriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE medical_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;

-- Helper: check if current user is a provider assigned to a patient
CREATE OR REPLACE FUNCTION is_my_patient(p_patient_id UUID)
RETURNS BOOLEAN LANGUAGE sql STABLE SECURITY DEFINER AS $$
  SELECT EXISTS (
    SELECT 1 FROM provider_patients
    WHERE provider_id = auth.uid() AND patient_id = p_patient_id
  );
$$;

-- Helper: get patient_id for current user if they are a patient
CREATE OR REPLACE FUNCTION my_patient_id()
RETURNS UUID LANGUAGE sql STABLE SECURITY DEFINER AS $$
  SELECT id FROM patients WHERE user_id = auth.uid() LIMIT 1;
$$;

-- Patients: providers see assigned patients, patients see own record, admins see all
CREATE POLICY "Providers see assigned patients" ON patients FOR SELECT USING (
  is_my_patient(id) OR
  user_id = auth.uid() OR
  EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Providers update assigned patients" ON patients FOR UPDATE USING (is_my_patient(id));
CREATE POLICY "Admins manage patients" ON patients FOR ALL USING (
  EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);

-- Appointments: same access as patients
CREATE POLICY "Providers see assigned patient appointments" ON appointments FOR SELECT USING (
  is_my_patient(patient_id) OR patient_id = my_patient_id() OR
  EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Providers manage appointments" ON appointments FOR ALL USING (provider_id = auth.uid());

-- Prescriptions: same access pattern
CREATE POLICY "Authorized access to prescriptions" ON prescriptions FOR SELECT USING (
  is_my_patient(patient_id) OR patient_id = my_patient_id() OR
  EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Providers create prescriptions" ON prescriptions FOR INSERT WITH CHECK (provider_id = auth.uid());
CREATE POLICY "Providers update own prescriptions" ON prescriptions FOR UPDATE USING (provider_id = auth.uid());

-- Documents
CREATE POLICY "Authorized access to documents" ON medical_documents FOR SELECT USING (
  is_my_patient(patient_id) OR patient_id = my_patient_id() OR
  EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Providers upload documents" ON medical_documents FOR INSERT WITH CHECK (
  is_my_patient(patient_id)
);

-- Audit logs: users see own logs, admins see all
CREATE POLICY "Users see own audit logs" ON audit_logs FOR SELECT USING (user_id = auth.uid());
CREATE POLICY "System inserts audit logs" ON audit_logs FOR INSERT WITH CHECK (user_id = auth.uid());
CREATE POLICY "Admins see all audit logs" ON audit_logs FOR SELECT USING (
  EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
```

> Pro tip: The is_my_patient() and my_patient_id() SECURITY DEFINER functions are critical — they allow RLS policies to check relationships without causing infinite recursion in the policy evaluation. Always test these functions by running SELECT is_my_patient('some-uuid') in the SQL Editor with different authenticated users.

**Expected result:** All tables created with RLS enabled. The two SQL helper functions appear in Supabase Dashboard under Database → Functions. Attempting to SELECT from patients without a matching provider_patients row returns 0 rows, not an error.

### 2. Build authentication with role selection and patient registration

The app needs distinct login flows for providers and patients. After sign-up, users select their role and complete their profile. Providers get a provider view; patients get a patient view. Admin accounts are created manually in Supabase.

```
Build the authentication and onboarding flow:

1. Login/Register page:
   - Two tabs: "Sign In" and "Create Account"
   - Email + password fields for both
   - On register: after supabase.auth.signUp(), show a role selection step

2. Role selection step (after sign-up):
   - Two large clickable cards: "I am a Healthcare Provider" and "I am a Patient"
   - Provider card: shows stethoscope icon, fields for full_name and specialty
   - Patient card: shows user icon, fields for full_name
   - On select: insert into profiles table with chosen role

3. Patient registration form (shown to new users with role='patient'):
   - Multi-step form collecting: personal details (name, DOB, gender, phone, email)
   - Medical info (blood_type, allergies as comma-separated input, insurance details)
   - Emergency contact
   - Insert into patients table with user_id = current user

4. Route guards:
   - /provider/* routes: check profiles.role = 'provider', redirect patients to /patient
   - /patient/* routes: check profiles.role = 'patient', redirect providers to /provider
   - /admin/* routes: check role = 'admin'

5. After completing onboarding, redirect to role-appropriate dashboard
```

**Expected result:** New provider accounts land on the provider dashboard after registration. New patient accounts complete the patient registration form and land on their patient portal. Trying to access /provider as a patient redirects to /patient.

### 3. Build the patient chart view with tabbed sections

The patient chart is the central view for providers. It shows a patient summary header with key vitals and demographics, then tabs for Appointments, Prescriptions, Documents, and Notes. Add audit logging on every view of a patient chart.

```
Build the patient chart view at /provider/patients/[patientId]:

1. Patient header card (full width, above tabs):
   - Patient photo placeholder (Avatar with initials)
   - Name, DOB, age (calculated), gender, blood type
   - Allergy badges (shadcn Badge, red) — show "No Known Allergies" if empty
   - Insurance info
   - Emergency contact
   - Edit button (opens Dialog with full patient edit form)

2. shadcn Tabs below the header:

   APPOINTMENTS tab:
   - DataTable: Date/Time, Type, Status (Badge), Chief Complaint, Notes
   - "Schedule Appointment" button opening Dialog with: type select, date/time picker, duration select, chief complaint textarea
   - Status update: click status cell to change (dropdown: scheduled/confirmed/completed/cancelled/no_show)

   PRESCRIPTIONS tab:
   - DataTable: Medication, Dosage, Frequency, Start Date, End Date, Status Badge, Refills
   - Status Badge: active=green, completed=gray, discontinued=red, expired=orange
   - "Add Prescription" button opening Dialog with all prescription fields
   - "Discontinue" action sets status=discontinued

   DOCUMENTS tab:
   - Card grid: document name, type icon, date, size, Download button
   - "Upload Document" button: file dropzone (PDF, JPG, PNG, DICOM), document type select
   - Upload to Supabase Storage at path: medical/{patientId}/{timestamp}-{filename} (private bucket)
   - Download: generate signed URL expiring in 15 minutes

   NOTES tab:
   - Chronological list of clinical notes
   - Textarea to add new note with timestamp
   - Notes stored in a patient_notes table

3. On page load: insert an audit_log record: action='view_patient_chart', patient_id=patientId
```

> Pro tip: Insert audit log records using the service role key via an Edge Function rather than directly from the frontend. This ensures logs can't be tampered with by frontend code and captures server-side metadata like IP address.

**Expected result:** The patient chart loads with the summary header. All tabs render with correct data. Uploading a document shows it in the Documents tab. An audit_logs record is created on each page visit.

### 4. Build the provider dashboard with patient list

Providers need a dashboard showing their assigned patients, upcoming appointments for the day, and any prescriptions expiring soon. The patient list filters to only show their assigned patients via RLS.

```
Build the provider dashboard at /provider/dashboard:

1. Stats row (4 shadcn Cards):
   - My Patients (count of provider_patients for current provider)
   - Today's Appointments (count where scheduled_at date = today and provider_id = current)
   - Active Prescriptions (count of active prescriptions for my patients)
   - Expiring Soon (prescriptions expiring in next 7 days)

2. Today's Schedule section:
   - Timeline list of today's appointments sorted by time
   - Each item: time, patient name, appointment type, status badge
   - Quick status update buttons: Confirm, Mark Complete, Cancel
   - "No appointments today" empty state

3. My Patients list:
   - shadcn DataTable: Patient Name, DOB/Age, Last Visit, Active Prescriptions count, Actions
   - Actions: "View Chart" button linking to /provider/patients/{id}
   - Search input filtering by patient name
   - "Add Patient" button: search by email to find existing patient, then insert into provider_patients

4. Expiring Prescriptions alert section (only shown if count > 0):
   - shadcn Alert with warning icon
   - List of prescriptions expiring in 7 days with patient names and renewal buttons
```

**Expected result:** The provider dashboard shows accurate stats for the logged-in provider only. The patient list shows only assigned patients. Today's schedule shows correctly timed appointments. The Expiring Prescriptions section appears only when relevant.

### 5. Build the patient self-service portal

Patients get a read-only view of their own records. They can see their appointments, active prescriptions, and download their own documents. They cannot edit clinical records — only providers and admins can do that.

```
Build the patient portal at /patient/my-records:

1. Patient header card:
   - Patient's own name and basic demographics
   - Edit button for non-clinical fields only: phone, address, emergency contact
   - Allergies listed as read-only badges

2. shadcn Tabs:

   MY APPOINTMENTS tab:
   - DataTable: Date, Provider Name, Type, Status Badge, Notes (read-only)
   - "Request Appointment" button: opens a Dialog with preferred dates/times and reason
   - Creates an appointment with status='scheduled' (provider must confirm)

   MY MEDICATIONS tab:
   - Card grid for active prescriptions: medication name, dosage, frequency, refills remaining
   - Status badges
   - Read-only — cannot be edited by patient

   MY DOCUMENTS tab:
   - List of uploaded documents: name, date, type
   - Download button generating 15-minute signed URL from Supabase Storage
   - Message: "Documents are uploaded by your healthcare provider"

   MY PROFILE tab:
   - Read-only view of all patient demographics
   - Edit button for contact information only (phone, address, emergency contact)
   - Medical information (allergies, blood type, insurance) shows with an info note: "Contact your provider to update medical information"

3. Page header banner: "This portal is for informational purposes only. In a medical emergency, call 911 or your local emergency number."

All data fetches use patient's own user_id. RLS ensures they can only see their own records.
```

> Pro tip: The HIPAA disclaimer banners are not just legal boilerplate — they're essential for any real medical application. Ask Lovable to make them prominent but non-intrusive, perhaps as a sticky alert at the top of every patient-facing page.

**Expected result:** Patients see only their own appointments, medications, and documents. The 'Request Appointment' dialog submits successfully. Downloading a document generates a short-lived signed URL. Attempting to edit clinical information shows an informational message instead of edit controls.

## Complete code example

File: `src/components/patients/AllergyList.tsx`

```typescript
import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';

interface AllergyListProps {
  allergies: string[] | null;
  showIcon?: boolean;
  maxDisplay?: number;
}

export function AllergyList({ allergies, showIcon = true, maxDisplay = 5 }: AllergyListProps) {
  if (!allergies || allergies.length === 0) {
    return (
      <span className="text-sm text-muted-foreground italic">No Known Allergies</span>
    );
  }

  const displayed = allergies.slice(0, maxDisplay);
  const remaining = allergies.length - maxDisplay;

  return (
    <div className="flex flex-wrap items-center gap-1">
      {showIcon && (
        <AlertTriangle className="h-4 w-4 text-red-500 shrink-0" aria-hidden="true" />
      )}
      {displayed.map((allergy) => (
        <Badge
          key={allergy}
          variant="destructive"
          className="text-xs font-normal"
        >
          {allergy}
        </Badge>
      ))}
      {remaining > 0 && (
        <TooltipProvider>
          <Tooltip>
            <TooltipTrigger asChild>
              <Badge variant="outline" className="text-xs cursor-help">
                +{remaining} more
              </Badge>
            </TooltipTrigger>
            <TooltipContent>
              <p className="text-xs">{allergies.slice(maxDisplay).join(', ')}</p>
            </TooltipContent>
          </Tooltip>
        </TooltipProvider>
      )}
    </div>
  );
}
```

## Common mistakes

- **Using real patient data before completing HIPAA compliance review** — HIPAA violations can result in fines from $100 to $50,000 per violation, and both Supabase and Lovable standard plans do not provide BAAs required for PHI Fix: For demonstration or testing, use only fictional data. For production with real patients, consult a healthcare compliance specialist, sign BAAs with all data processors, and implement additional technical safeguards.
- **Storing Supabase Storage signed URLs in the database** — Signed URLs expire (typically 1 hour) and storing them creates links that will eventually stop working, giving the appearance of missing documents Fix: Generate signed URLs on demand only when a user clicks to download. Never cache or store signed URLs.
- **Not testing RLS policies by impersonating different user roles** — RLS bugs in medical applications are especially dangerous — a provider seeing the wrong patient's data could lead to misdiagnosis Fix: In the Supabase SQL Editor, use SET LOCAL request.jwt.claims TO to impersonate different users. Create test accounts for each role and verify in the Supabase Dashboard table viewer what each user can see.
- **Allowing patients to upload documents to their own record** — Unverified documents uploaded by patients could be misleading or harmful in a clinical context Fix: Only allow providers and admins to upload to medical_documents. The RLS INSERT policy already enforces this via is_my_patient(patient_id) — ensure this policy is in place.
- **Skipping audit logging for performance** — In any clinical or sensitive data application, audit logs are not optional — they're required for compliance and incident investigation Fix: Log every patient record access. Use an Edge Function for logging to include server-side metadata. Accept that audit logging adds a small latency overhead.

## Best practices

- Always display a HIPAA disclaimer on every patient-facing page, especially when using this for real clinical data
- Use SECURITY DEFINER functions for RLS helper functions to prevent policy evaluation loops and keep policies readable
- Generate Supabase Storage signed URLs with the shortest practical expiry (15 minutes for medical documents)
- Audit log every patient chart view, not just data modifications — access logs are as important as change logs
- Keep provider and patient views completely separate routes (/provider/* and /patient/*) with separate route guards
- Never display full date of birth on list views — show age instead, and only show full DOB in the patient chart
- Mark all allergy information prominently with red badges and warning icons across the entire application
- Test the RLS policies by creating test accounts for each role and verifying data isolation in the Supabase Table Editor

## Frequently asked questions

### Is this app HIPAA-compliant for real patient data?

No — not without significant additional work. HIPAA compliance requires signed Business Associate Agreements (BAAs) with all data processors (Supabase and Lovable do not offer BAAs on standard plans), additional technical safeguards, workforce training, and a full risk assessment. This build is a demonstration of the architecture patterns. For real patient data, consult a healthcare compliance specialist before deployment.

### How do I test that providers can only see their assigned patients?

Create two provider accounts and two patient accounts. Assign Patient A to Provider 1 and Patient B to Provider 2 using the provider_patients table. Log in as Provider 1 and verify you can only see Patient A in the patient list. Log in as Provider 2 and verify you see only Patient B. In the Supabase SQL Editor, you can run queries as specific users using the SET LOCAL request.jwt.claims approach to verify RLS without logging in.

### Can patients see their prescriptions in this app?

Yes — the patient portal shows active prescriptions in read-only mode. The RLS policy on prescriptions allows SELECT where patient_id = my_patient_id() (the current user's patient record). Patients cannot add or modify prescriptions — only providers with an is_my_patient() relationship can INSERT or UPDATE.

### How should I handle a provider who needs to cover for another provider's patients?

Add a temporary row in provider_patients for the covering provider with the same patient_id. When the coverage period ends, delete that row. You could add a start_date and end_date to provider_patients and update the is_my_patient() function to check date ranges for temporary assignments.

### How do I deploy this app for a real clinic?

Before deploying for real clinical use: (1) upgrade to Supabase Pro or higher and request a BAA, (2) contact Lovable about enterprise compliance plans, (3) engage a HIPAA compliance consultant, (4) implement additional security controls like MFA for all staff accounts, (5) conduct a penetration test. This is not a quick checklist — allow 2-3 months for proper compliance work.

### Why does document download use a 15-minute expiry instead of longer?

Medical documents are sensitive. A 15-minute signed URL limits the window during which a shared or stolen link could be used to access the file. If a user needs to view a document multiple times, each view generates a fresh URL. This is a security trade-off: more friction for legitimate users in exchange for reduced exposure window.

### Can I add custom fields for specialized medical practices?

Yes — the patients table has a medical_history TEXT column that can store arbitrary information. For more structured custom fields, add a JSONB column called custom_fields to patients and prescriptions. Lovable can generate a dynamic form renderer that reads field definitions from a configuration table.

### Can RapidDev help build a HIPAA-compliant version of this app?

RapidDev can help with the technical architecture and Lovable/Supabase implementation. HIPAA compliance itself requires legal and compliance expertise beyond development scope — we'd work alongside your compliance team to ensure the technical implementation meets the requirements they define.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/medical-records-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/medical-records-app
