# How to Build Medical records app with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a secure patient medical records portal with V0 using Next.js, Supabase, and shadcn/ui. Features patient charts with visit history, prescriptions, document uploads to private Storage buckets, and strict RLS policies ensuring providers only access their patients' records. Takes about 1-2 hours.

## Before you start

- A V0 account (Premium or higher recommended)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Understanding of the provider-patient relationship model for your clinic
- Awareness of healthcare data regulations applicable to your jurisdiction

## Step-by-step guide

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

Create the Supabase schema for patients, providers, visits, prescriptions, and documents. The critical piece is RLS policies that scope access based on provider-patient relationships.

```
// Paste this prompt into V0's AI chat:
// Build a medical records portal. Create a Supabase schema:
// 1. patients: id (uuid PK), user_id (uuid FK to auth.users), first_name (text), last_name (text), date_of_birth (date), gender (text), blood_type (text), allergies (text[]), emergency_contact (jsonb)
// 2. providers: id (uuid PK), user_id (uuid FK to auth.users), license_number (text), specialty (text), is_verified (boolean DEFAULT false)
// 3. visits: id (uuid PK), patient_id (uuid FK to patients), provider_id (uuid FK to providers), visit_date (timestamptz), chief_complaint (text), diagnosis (text), notes (text), vitals (jsonb)
// 4. prescriptions: id (uuid PK), visit_id (uuid FK to visits), patient_id (uuid FK to patients), medication (text), dosage (text), frequency (text), start_date (date), end_date (date), is_active (boolean DEFAULT true)
// 5. documents: id (uuid PK), patient_id (uuid FK to patients), uploaded_by (uuid FK to auth.users), file_url (text), document_type (text), description (text), uploaded_at (timestamptz)
// Add strict RLS: providers see only patients they have visited, patients see only their own records.
```

> Pro tip: Test your RLS policies in the Supabase SQL editor before deploying. Run SELECT queries as different user roles to verify providers cannot access other providers' patients.

**Expected result:** All tables are created with strict RLS policies. Providers can only access records for patients they have treated.

### 2. Build the patient chart with tabbed sections

Create the patient detail page with Tabs for demographics, visit history, prescriptions, and documents. This is the central view providers use to review a patient's complete medical history.

```
// Paste this prompt into V0's AI chat:
// Create a patient chart page at app/patients/[id]/page.tsx.
// Requirements:
// - Fetch patient by ID with related visits, prescriptions, and documents from Supabase
// - Show patient header Card: full name, DOB, gender, blood type, allergies as Badges
// - Use shadcn/ui Tabs with four tabs:
//   - Demographics: emergency contact details, address, insurance info
//   - Visits: Table of visits with date, provider name, chief complaint, diagnosis. Click row to expand notes.
//   - Prescriptions: Table with medication, dosage, frequency, dates, status Badge (active=green, inactive=gray)
//   - Documents: Grid of document Cards with type Badge, description, upload date, download Button (signed URL)
// - Add 'New Visit' Dialog button for providers to log a visit
// - Add 'Upload Document' Dialog with file input (PDF only, max 10MB) and document_type Select
```

**Expected result:** The patient chart shows all medical information in organized tabs with visit history, active prescriptions, and downloadable documents.

### 3. Create the secure document upload with signed URLs

Build the document upload API that stores files in a Supabase Storage private bucket and generates expiring signed URLs for authorized access. Documents are never publicly accessible.

```
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const formData = await req.formData()
  const file = formData.get('file') as File
  const patientId = formData.get('patient_id') as string
  const documentType = formData.get('document_type') as string
  const description = formData.get('description') as string

  if (!file || !patientId) {
    return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
  }

  const fileName = `${patientId}/${Date.now()}-${file.name}`
  const { error: uploadError } = await supabase.storage
    .from('medical-documents')
    .upload(fileName, file, { contentType: file.type })

  if (uploadError) {
    return NextResponse.json({ error: uploadError.message }, { status: 500 })
  }

  const { error: dbError } = await supabase.from('documents').insert({
    patient_id: patientId,
    file_url: fileName,
    document_type: documentType,
    description,
  })

  if (dbError) {
    return NextResponse.json({ error: dbError.message }, { status: 500 })
  }

  return NextResponse.json({ success: true })
}

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const filePath = searchParams.get('path')

  if (!filePath) {
    return NextResponse.json({ error: 'Missing path' }, { status: 400 })
  }

  const { data } = await supabase.storage
    .from('medical-documents')
    .createSignedUrl(filePath, 3600)

  if (!data) {
    return NextResponse.json({ error: 'Failed to generate URL' }, { status: 500 })
  }

  return NextResponse.json({ url: data.signedUrl })
}
```

> Pro tip: Use a private bucket for medical documents. Signed URLs expire after 1 hour, ensuring that shared links do not grant permanent access to sensitive files.

**Expected result:** Documents upload to a private bucket. Providers get 1-hour signed URLs for viewing. Direct file access is blocked.

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

Create the provider-facing dashboard showing their patient list, recent visits, and quick actions. Only patients the provider has treated appear in their list.

```
// Paste this prompt into V0's AI chat:
// Create a provider dashboard at app/patients/page.tsx.
// Requirements:
// - Fetch patients that the current provider has visited (JOIN visits ON provider_id = current user)
// - Display as shadcn/ui DataTable: patient name, DOB, last visit date, diagnosis from last visit, active prescriptions count
// - Add search Input to filter by patient name
// - Each row links to /patients/[id] for the full chart
// - Summary Cards at top: Total Patients, Visits This Week, Active Prescriptions, Pending Documents
// - Add a 'New Patient' Dialog for registering new patients
// - Mobile responsive: Cards stack, table scrolls horizontally
```

**Expected result:** The provider dashboard shows only their patients with search, quick stats, and links to patient charts.

### 5. Create the patient self-service portal and deploy

Build the patient-facing view where patients can see their own records, upcoming visits, and active prescriptions. Then set up RLS and deploy.

```
// Paste this prompt into V0's AI chat:
// Create a patient self-service portal at app/my-records/page.tsx.
// Requirements:
// - Fetch the current user's patient record with visits, prescriptions, and documents
// - Show a personal health Card: name, DOB, blood type, allergies Badges
// - Tabs: Visits (Table with date, provider, complaint, diagnosis), Prescriptions (Table with active Badge, medication, dosage), Documents (Card grid with download Button)
// - Upcoming visits section showing next appointment if scheduled
// - RLS ensures this page only shows the logged-in patient's own data
// - No edit capabilities — patients view only, providers edit
// - Add a note: 'Contact your provider to update your records'
```

**Expected result:** Patients see their own records in a read-only view. RLS ensures no cross-patient data access. The app is deployed to Vercel.

## Complete code example

File: `app/api/documents/upload/route.ts`

```typescript
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const formData = await req.formData()
  const file = formData.get('file') as File
  const patientId = formData.get('patient_id') as string
  const docType = formData.get('document_type') as string

  if (!file || !patientId || file.size > 10 * 1024 * 1024) {
    return NextResponse.json(
      { error: 'Invalid file or missing patient ID' },
      { status: 400 }
    )
  }

  const path = `${patientId}/${Date.now()}-${file.name}`
  const { error: uploadErr } = await supabase.storage
    .from('medical-documents')
    .upload(path, file, { contentType: file.type })

  if (uploadErr) {
    return NextResponse.json({ error: uploadErr.message }, { status: 500 })
  }

  await supabase.from('documents').insert({
    patient_id: patientId,
    file_url: path,
    document_type: docType,
    description: formData.get('description') as string,
  })

  return NextResponse.json({ success: true, path })
}

export async function GET(req: NextRequest) {
  const path = new URL(req.url).searchParams.get('path')
  if (!path) {
    return NextResponse.json({ error: 'Missing path' }, { status: 400 })
  }

  const { data } = await supabase.storage
    .from('medical-documents')
    .createSignedUrl(path, 3600)

  return NextResponse.json({ url: data?.signedUrl })
}
```

## Common mistakes

- **Using a public Supabase Storage bucket for medical documents** — Public buckets allow anyone with the URL to download files. Medical documents contain sensitive health information that must be access-controlled. Fix: Use a private bucket and generate server-side signed URLs that expire after 1 hour. Only generate URLs for authorized users verified through RLS.
- **Not scoping RLS policies to the provider-patient relationship** — Generic RLS like 'authenticated users can read all patients' means every provider can see every patient's records, violating privacy. Fix: Create RLS policies that check if the provider has a visit record with the patient: provider_id IN (SELECT provider_id FROM visits WHERE patient_id = patients.id AND provider_id = auth.uid()).
- **Storing SUPABASE_SERVICE_ROLE_KEY with NEXT_PUBLIC_ prefix** — The service role key bypasses all RLS. Exposing it means anyone can read all medical records from the browser. Fix: Store it in V0's Vars tab without any prefix. Only use it in API routes for document upload and signed URL generation.

## Best practices

- Use Supabase Storage private buckets for all medical documents and generate signed URLs with 1-hour expiry for authorized access
- Scope RLS policies to provider-patient relationships — not just role-based but relationship-based access control
- Test RLS policies in the Supabase SQL editor before deploying by running queries as different user roles
- Store all Supabase keys in V0's Vars tab without NEXT_PUBLIC_ prefix for the document upload route
- Use Server Components for patient charts to avoid sending sensitive data through client JavaScript
- Add file size limits (10MB) and type restrictions (PDF, JPEG) on document uploads to prevent abuse
- Use V0's Design Mode (Option+D) to adjust patient chart layouts and Badge colors without spending credits

## Frequently asked questions

### How are medical documents stored securely?

Documents are uploaded to a Supabase Storage private bucket, meaning they are not publicly accessible. When a provider or patient needs to view a document, the server generates a signed URL that expires after 1 hour. Only users who pass the RLS check can request these URLs.

### How does the system prevent providers from seeing other providers' patients?

RLS policies on the patients table check whether the requesting provider has a visit record with that patient. If a provider has never treated a patient, they cannot see that patient's records, even if they are authenticated.

### Is this system HIPAA compliant?

This guide builds the technical foundation for secure medical records with encryption, access control, and signed URLs. However, full HIPAA compliance also requires a Business Associate Agreement with Supabase, audit logging, and organizational policies. Consult a compliance expert for your specific requirements.

### Do I need a paid V0 plan?

Premium ($20/month) is recommended. The medical records app has multiple complex pages with strict security requirements that need several prompts to build correctly.

### Can patients see their own records?

Yes. The patient self-service portal at /my-records shows the logged-in patient's visits, prescriptions, and documents in a read-only view. RLS policies ensure they can only see their own data.

### How do I deploy the medical records app?

Click Share in V0, then Publish to Production. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in the Vars tab without NEXT_PUBLIC_ prefix. Create the private Storage bucket in the Supabase Dashboard before deploying.

### Can RapidDev help build a custom medical records system?

Yes. RapidDev has built over 600 apps including healthcare portals with HIPAA-compliant data handling, telehealth integration, and custom EHR workflows. Book a free consultation to discuss your clinic's requirements.

---

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