# How to Build Patient management system with V0

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

## TL;DR

Build a full clinic management system with V0 using Next.js, Supabase, Stripe, Resend, and shadcn/ui. Features patient registration, appointment scheduling with conflict detection, SOAP clinical notes, prescription management, invoice generation with Stripe payments, and automated appointment reminders via Vercel Cron — takes about 2-4 hours.

## Before you start

- A V0 account (Premium or higher recommended)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode works — connect via Vercel Marketplace)
- A Resend account (free tier: 100 emails/day)
- Understanding of clinic workflows (appointments, notes, billing)

## Step-by-step guide

### 1. Set up the database schema for patients, appointments, and billing

Create the Supabase schema for patients, providers, appointments with status tracking, clinical notes in SOAP format, prescriptions, and invoices with line items.

```
// Paste this prompt into V0's AI chat:
// Build a patient management system. Create a Supabase schema:
// 1. patients: id (uuid PK), user_id (uuid FK to auth.users NULL), first_name (text), last_name (text), date_of_birth (date), gender (text), phone (text), email (text), address (text), insurance_provider (text), insurance_id (text), blood_type (text), allergies (text[]), emergency_contact (jsonb), created_at (timestamptz)
// 2. providers: id (uuid PK), user_id (uuid FK to auth.users), first_name (text), last_name (text), specialty (text), license_number (text), schedule_config (jsonb)
// 3. appointments: id (uuid PK), patient_id (uuid FK to patients), provider_id (uuid FK to providers), start_time (timestamptz), end_time (timestamptz), status (text DEFAULT 'scheduled' CHECK IN 'scheduled','checked_in','in_progress','completed','cancelled','no_show'), reason (text), notes (text)
// 4. clinical_notes: id (uuid PK), appointment_id (uuid FK to appointments), provider_id (uuid FK to providers), subjective (text), objective (text), assessment (text), plan (text), created_at (timestamptz)
// 5. prescriptions: id (uuid PK), patient_id (uuid FK to patients), provider_id (uuid FK to providers), appointment_id (uuid FK to appointments), medication (text), dosage (text), frequency (text), duration (text), refills (int DEFAULT 0), status (text DEFAULT 'active')
// 6. invoices: id (uuid PK), patient_id (uuid FK to patients), appointment_id (uuid FK to appointments), amount_cents (int), insurance_covered_cents (int), patient_owes_cents (int), status (text DEFAULT 'pending'), stripe_payment_id (text), created_at (timestamptz)
// 7. invoice_items: id (uuid PK), invoice_id (uuid FK to invoices), description (text), amount_cents (int), cpt_code (text)
// Add RLS so providers see only their patients. Create an RPC function check_appointment_conflict(provider_uuid, start_ts, end_ts) that returns true if an overlapping appointment exists.
// Generate SQL and types.
```

> Pro tip: Use V0's Git panel to push code to GitHub where a clinic developer can review sensitive healthcare code changes before deploying to production.

**Expected result:** All tables created with RLS policies scoped to provider access. The conflict detection RPC prevents double-booking.

### 2. Build the patient registry and chart pages

Create the patient list with search and the patient chart page with tabbed views for demographics, appointments, clinical notes, prescriptions, and billing.

```
// Paste this prompt into V0's AI chat:
// Create patient management pages:
// 1. app/patients/page.tsx — patient registry Table with columns: name, DOB, phone, insurance Badge, last visit date. Add search Input that filters by name or phone. 'Add Patient' Button opens Dialog with registration form.
// 2. app/patients/[id]/page.tsx — patient chart with Tabs:
//    - Demographics: personal info, insurance details, allergies (Badge list), emergency contact
//    - Appointments: Table of past and upcoming appointments with status Badge, link to appointment detail
//    - Notes: clinical notes list with date, provider, and accordion for SOAP content
//    - Prescriptions: Table with medication, dosage, frequency, status Badge (active=green, expired=red), refills remaining
//    - Billing: invoices Table with amount, insurance coverage, patient balance, payment status Badge
// Use shadcn/ui Tabs, Table, Badge, Dialog, Form, Input, Select, Textarea, Avatar.
```

**Expected result:** The patient registry shows a searchable list. Clicking a patient opens a comprehensive chart with five tab sections.

### 3. Build the appointment scheduler and SOAP notes

Create the appointment calendar with time-slot views, conflict detection, and the SOAP note form for clinical documentation.

```
// Paste this prompt into V0's AI chat:
// Create appointment and clinical note features:
// 1. app/appointments/page.tsx — daily/weekly calendar view for the logged-in provider. Show appointments as colored blocks based on status. 'New Appointment' Button opens Dialog with:
//    - Patient Select (searchable), date Calendar picker, time Select, duration Select, reason Textarea
//    - Server Action that calls check_appointment_conflict RPC before inserting. Show error Toast if conflict.
// 2. app/appointments/[id]/page.tsx — appointment detail Card: patient info, time, status. Status action Buttons: 'Check In', 'Start', 'Complete', 'No Show', 'Cancel'.
//    - SOAP Note section: Form with Subjective Textarea, Objective Textarea, Assessment Textarea, Plan Textarea. Validate with zod (each field required, min 10 chars). Save via Server Action.
//    - Add Prescription Button: Dialog with medication Input, dosage Input, frequency Select, duration Input, refills number Input.
// Use shadcn/ui Calendar, Form, Textarea, Select, Dialog, Button, Badge, Toast.
```

**Expected result:** The appointment calendar shows the provider's schedule. New appointments check for conflicts. SOAP notes are validated and saved per appointment.

### 4. Build billing, payment, and appointment reminders

Create the invoice management system with Stripe payment collection, and the automated reminder endpoint triggered by Vercel Cron.

```
import { NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { Resend } from 'resend'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const resend = new Resend(process.env.RESEND_API_KEY)

export async function GET() {
  const tomorrow = new Date()
  tomorrow.setDate(tomorrow.getDate() + 1)
  const startOfDay = new Date(tomorrow.setHours(0, 0, 0, 0)).toISOString()
  const endOfDay = new Date(tomorrow.setHours(23, 59, 59, 999)).toISOString()

  const { data: appointments } = await supabase
    .from('appointments')
    .select(`
      id, start_time, reason,
      patients!inner(first_name, last_name, email),
      providers!inner(first_name, last_name)
    `)
    .eq('status', 'scheduled')
    .gte('start_time', startOfDay)
    .lte('start_time', endOfDay)

  if (!appointments?.length) {
    return NextResponse.json({ sent: 0 })
  }

  const results = await Promise.allSettled(
    appointments.map((appt: any) =>
      resend.emails.send({
        from: 'clinic@yourdomain.com',
        to: appt.patients.email,
        subject: 'Appointment Reminder — Tomorrow',
        html: `<p>Hi ${appt.patients.first_name},</p>
               <p>This is a reminder that you have an appointment tomorrow at ${new Date(appt.start_time).toLocaleTimeString()} with Dr. ${appt.providers.last_name}.</p>
               <p>Reason: ${appt.reason || 'General visit'}</p>`,
      })
    )
  )

  const sent = results.filter((r) => r.status === 'fulfilled').length
  return NextResponse.json({ sent, total: appointments.length })
}
```

**Expected result:** The reminder endpoint sends emails for tomorrow's appointments. Vercel Cron triggers this daily at 8 AM.

### 5. Add invoice management with Stripe and deploy

Build the billing page with invoice creation, Stripe payment links, and the webhook handler for payment confirmation. Configure Vercel Cron and deploy.

```
// Paste this prompt into V0's AI chat:
// Create billing features:
// 1. app/billing/page.tsx — invoice management Table: patient name, appointment date, total amount, insurance covered, patient owes, status Badge (pending=yellow, paid=green, overdue=red). Filter by status Select and date range.
// 2. 'Generate Invoice' Button on completed appointments: Dialog with invoice_items — add line items with description Input, CPT code Input, amount Input. Calculate insurance coverage (configurable percentage). Server Action creates invoice and invoice_items rows.
// 3. 'Send Payment Link' Button: Server Action creates Stripe Payment Link for patient_owes_cents and sends via Resend email.
// 4. app/api/webhooks/stripe/route.ts — handle payment_intent.succeeded: update invoice status to 'paid', store stripe_payment_id. Use request.text() for signature verification.
// 5. Configure vercel.json with cron: {"crons": [{"path": "/api/appointments/reminders", "schedule": "0 8 * * *"}]}
// Use shadcn/ui Table, Badge, Dialog, Input, Button, Separator.
```

> Pro tip: Configure Vercel Cron in vercel.json for the reminders endpoint. Set RESEND_API_KEY, STRIPE_SECRET_KEY, and STRIPE_WEBHOOK_SECRET in Vars without NEXT_PUBLIC_ prefix since they are all server-only.

**Expected result:** Invoices can be generated from completed appointments. Payment links are sent to patients. The webhook confirms payments. Reminders run daily. The app is deployed.

## Complete code example

File: `app/api/appointments/reminders/route.ts`

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

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const resend = new Resend(process.env.RESEND_API_KEY)

export async function GET() {
  const tomorrow = new Date()
  tomorrow.setDate(tomorrow.getDate() + 1)
  const start = new Date(tomorrow.setHours(0, 0, 0, 0)).toISOString()
  const end = new Date(tomorrow.setHours(23, 59, 59, 999)).toISOString()

  const { data: appts } = await supabase
    .from('appointments')
    .select(`
      id, start_time, reason,
      patients!inner(first_name, last_name, email),
      providers!inner(first_name, last_name)
    `)
    .eq('status', 'scheduled')
    .gte('start_time', start)
    .lte('start_time', end)

  if (!appts?.length) {
    return NextResponse.json({ sent: 0 })
  }

  const results = await Promise.allSettled(
    appts.map((a: any) =>
      resend.emails.send({
        from: 'clinic@yourdomain.com',
        to: a.patients.email,
        subject: 'Appointment Reminder — Tomorrow',
        html: `<p>Hi ${a.patients.first_name},</p>
               <p>Reminder: appointment tomorrow at
               ${new Date(a.start_time).toLocaleTimeString()}
               with Dr. ${a.providers.last_name}.</p>
               <p>Reason: ${a.reason || 'General visit'}</p>`,
      })
    )
  )

  const sent = results.filter((r) => r.status === 'fulfilled').length
  return NextResponse.json({ sent, total: appts.length })
}
```

## Common mistakes

- **Not checking for appointment conflicts before booking** — Without conflict detection, a provider can be double-booked at the same time. Two appointments overlap and one patient has to wait or be rescheduled. Fix: Use a Supabase RPC function that checks for overlapping appointments (start_time < new_end AND end_time > new_start) with the same provider before inserting. Return an error if a conflict exists.
- **Exposing patient data without provider-scoped RLS** — Without proper RLS policies, any authenticated user (including patients) could query other patients' medical records, prescriptions, and billing data. Fix: Create RLS policies that scope data access: providers see only their own patients (via appointments or a provider-patient junction), and patients see only their own records via user_id matching.
- **Using request.json() for the Stripe webhook body** — Stripe signature verification requires the raw request body. Parsing it as JSON changes the format and verification always fails. Fix: Use request.text() and pass the raw body to stripe.webhooks.constructEvent().

## Best practices

- Use a Supabase RPC function for appointment conflict detection to prevent double-booking
- Implement provider-scoped RLS policies on every table containing patient data
- Validate SOAP notes with zod schemas requiring minimum content length per field
- Use react-hook-form for all clinical forms to handle complex validation and dirty state tracking
- Always use request.text() for Stripe webhook signature verification
- Configure Vercel Cron in vercel.json for automated appointment reminders
- Store RESEND_API_KEY, STRIPE_SECRET_KEY, and STRIPE_WEBHOOK_SECRET in Vars without NEXT_PUBLIC_ prefix
- Use V0's Git panel to push to GitHub for code review before deploying healthcare-sensitive changes

## Frequently asked questions

### How does appointment conflict detection work?

A Supabase RPC function checks for overlapping appointments by querying where an existing appointment's start_time is before the new end_time AND the existing end_time is after the new start_time for the same provider. If any overlap exists, the function returns true and the Server Action shows an error Toast instead of creating the appointment.

### How are SOAP notes validated?

The SOAP note form uses react-hook-form with a zod schema requiring all four fields (Subjective, Objective, Assessment, Plan) with a minimum of 10 characters each. This ensures clinical documentation meets a baseline quality before saving.

### How does patient data security work?

Supabase RLS policies scope all data access. Providers can only see patients they have appointments with. Patients can only see their own records. The service role key (used only in server-side code) bypasses RLS for administrative operations like reminder emails.

### How do appointment reminders work?

A Vercel Cron job triggers the reminders endpoint daily at 8 AM. The endpoint queries tomorrow's scheduled appointments, joins patient emails and provider names, and sends reminder emails via Resend. Promise.allSettled ensures one failed email does not block the others.

### Do I need a paid V0 plan?

Premium ($20/month) is recommended. The patient management system has many complex pages (patient chart, appointment calendar, SOAP notes, billing) that require multiple prompts to generate.

### How do I deploy the system?

Click Share in V0, then Publish to Production. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RESEND_API_KEY, and Supabase keys in the Vars tab. Add the cron configuration to vercel.json. Register the Stripe webhook URL for payment_intent.succeeded.

### Can RapidDev help build a custom clinic management system?

Yes. RapidDev has built over 600 apps including healthcare platforms with HIPAA-compliant data handling, telehealth integration, and multi-location support. Book a free consultation to discuss your clinic's requirements.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/patient-management-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/patient-management-system
