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.
What you're building
Small clinics and telehealth providers need a secure portal for managing patient records, visit history, and prescriptions without paying enterprise EHR prices. The critical requirement is role-based access — providers must only see their patients' data, and patients must only see their own records.
V0 generates the patient chart UI, provider dashboard, and upload components from prompts. Supabase handles the database with Row Level Security for access control, and Supabase Storage with private buckets ensures medical documents are never publicly accessible.
The architecture uses Server Components for patient data display, Supabase RLS policies scoped to provider-patient relationships, private Storage buckets with expiring signed URLs for document access, and client components only for interactive forms and file uploads.
Final result
A secure medical records portal with patient charts, visit history, prescription tracking, document uploads, and role-based access control.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build a medical records portal. Create a Supabase schema:3// 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)4// 2. providers: id (uuid PK), user_id (uuid FK to auth.users), license_number (text), specialty (text), is_verified (boolean DEFAULT false)5// 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)6// 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)7// 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)8// 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.
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.
1// Paste this prompt into V0's AI chat:2// Create a patient chart page at app/patients/[id]/page.tsx.3// Requirements:4// - Fetch patient by ID with related visits, prescriptions, and documents from Supabase5// - Show patient header Card: full name, DOB, gender, blood type, allergies as Badges6// - Use shadcn/ui Tabs with four tabs:7// - Demographics: emergency contact details, address, insurance info8// - Visits: Table of visits with date, provider name, chief complaint, diagnosis. Click row to expand notes.9// - Prescriptions: Table with medication, dosage, frequency, dates, status Badge (active=green, inactive=gray)10// - Documents: Grid of document Cards with type Badge, description, upload date, download Button (signed URL)11// - Add 'New Visit' Dialog button for providers to log a visit12// - Add 'Upload Document' Dialog with file input (PDF only, max 10MB) and document_type SelectExpected result: The patient chart shows all medical information in organized tabs with visit history, active prescriptions, and downloadable documents.
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.
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function POST(req: NextRequest) {10 const formData = await req.formData()11 const file = formData.get('file') as File12 const patientId = formData.get('patient_id') as string13 const documentType = formData.get('document_type') as string14 const description = formData.get('description') as string1516 if (!file || !patientId) {17 return NextResponse.json({ error: 'Missing fields' }, { status: 400 })18 }1920 const fileName = `${patientId}/${Date.now()}-${file.name}`21 const { error: uploadError } = await supabase.storage22 .from('medical-documents')23 .upload(fileName, file, { contentType: file.type })2425 if (uploadError) {26 return NextResponse.json({ error: uploadError.message }, { status: 500 })27 }2829 const { error: dbError } = await supabase.from('documents').insert({30 patient_id: patientId,31 file_url: fileName,32 document_type: documentType,33 description,34 })3536 if (dbError) {37 return NextResponse.json({ error: dbError.message }, { status: 500 })38 }3940 return NextResponse.json({ success: true })41}4243export async function GET(req: NextRequest) {44 const { searchParams } = new URL(req.url)45 const filePath = searchParams.get('path')4647 if (!filePath) {48 return NextResponse.json({ error: 'Missing path' }, { status: 400 })49 }5051 const { data } = await supabase.storage52 .from('medical-documents')53 .createSignedUrl(filePath, 3600)5455 if (!data) {56 return NextResponse.json({ error: 'Failed to generate URL' }, { status: 500 })57 }5859 return NextResponse.json({ url: data.signedUrl })60}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.
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.
1// Paste this prompt into V0's AI chat:2// Create a provider dashboard at app/patients/page.tsx.3// Requirements:4// - Fetch patients that the current provider has visited (JOIN visits ON provider_id = current user)5// - Display as shadcn/ui DataTable: patient name, DOB, last visit date, diagnosis from last visit, active prescriptions count6// - Add search Input to filter by patient name7// - Each row links to /patients/[id] for the full chart8// - Summary Cards at top: Total Patients, Visits This Week, Active Prescriptions, Pending Documents9// - Add a 'New Patient' Dialog for registering new patients10// - Mobile responsive: Cards stack, table scrolls horizontallyExpected result: The provider dashboard shows only their patients with search, quick stats, and links to patient charts.
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.
1// Paste this prompt into V0's AI chat:2// Create a patient self-service portal at app/my-records/page.tsx.3// Requirements:4// - Fetch the current user's patient record with visits, prescriptions, and documents5// - Show a personal health Card: name, DOB, blood type, allergies Badges6// - Tabs: Visits (Table with date, provider, complaint, diagnosis), Prescriptions (Table with active Badge, medication, dosage), Documents (Card grid with download Button)7// - Upcoming visits section showing next appointment if scheduled8// - RLS ensures this page only shows the logged-in patient's own data9// - No edit capabilities — patients view only, providers edit10// - 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
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function POST(req: NextRequest) {10 const formData = await req.formData()11 const file = formData.get('file') as File12 const patientId = formData.get('patient_id') as string13 const docType = formData.get('document_type') as string1415 if (!file || !patientId || file.size > 10 * 1024 * 1024) {16 return NextResponse.json(17 { error: 'Invalid file or missing patient ID' },18 { status: 400 }19 )20 }2122 const path = `${patientId}/${Date.now()}-${file.name}`23 const { error: uploadErr } = await supabase.storage24 .from('medical-documents')25 .upload(path, file, { contentType: file.type })2627 if (uploadErr) {28 return NextResponse.json({ error: uploadErr.message }, { status: 500 })29 }3031 await supabase.from('documents').insert({32 patient_id: patientId,33 file_url: path,34 document_type: docType,35 description: formData.get('description') as string,36 })3738 return NextResponse.json({ success: true, path })39}4041export async function GET(req: NextRequest) {42 const path = new URL(req.url).searchParams.get('path')43 if (!path) {44 return NextResponse.json({ error: 'Missing path' }, { status: 400 })45 }4647 const { data } = await supabase.storage48 .from('medical-documents')49 .createSignedUrl(path, 3600)5051 return NextResponse.json({ url: data?.signedUrl })52}Customization ideas
Appointment scheduling
Add a calendar-based scheduling system where patients request appointments and providers confirm them with email notifications.
Prescription PDF generation
Generate printable prescription PDFs using @react-pdf/renderer with provider details, medication info, and digital signatures.
Audit logging
Log every record access (who viewed what, when) in a separate audit table for compliance tracking and security monitoring.
Telehealth video integration
Add a video consultation feature using the Daily.co API for real-time video calls between providers and patients.
Common pitfalls
Pitfall: Using a public Supabase Storage bucket for medical documents
How to avoid: Use a private bucket and generate server-side signed URLs that expire after 1 hour. Only generate URLs for authorized users verified through RLS.
Pitfall: Not scoping RLS policies to the provider-patient relationship
How to avoid: 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()).
Pitfall: Storing SUPABASE_SERVICE_ROLE_KEY with NEXT_PUBLIC_ prefix
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a medical records portal with Next.js App Router and Supabase. I need help with RLS policies for healthcare data. I have patients, providers, and visits tables. A provider should only access patients they have treated (have a visit record with). A patient should only access their own records. Please write the PostgreSQL RLS policies for the patients, visits, prescriptions, and documents tables.
Create a Supabase Storage integration for medical document uploads. Use a private bucket called 'medical-documents'. The upload route should accept multipart form data with a file (PDF only, max 10MB), patient_id, document_type, and description. Store the file path in the documents table. Create a separate GET endpoint that generates a signed URL expiring in 1 hour for authorized downloads. Verify the requesting user has access to the patient's records before generating the URL.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation