healthcarebeginner
Project: HIPAA-Compliant Patient Portal
Build a HIPAA-compliant patient portal with Next.js, Supabase, and AWS: secure auth, PHI access controls, appointment booking, document upload, and audit logging.
Asma HafeezApril 17, 20266 min read
healthcarehipaaprojectnextjssupabaseaws
Project: HIPAA-Compliant Patient Portal
Build a real patient portal where patients log in, view their records, book appointments, and securely upload documents. Every PHI access is logged, all data is encrypted.
Project Structure
patient-portal/
├── src/
│ ├── app/
│ │ ├── (auth)/
│ │ │ ├── login/page.tsx
│ │ │ └── register/page.tsx
│ │ ├── (portal)/
│ │ │ ├── dashboard/page.tsx
│ │ │ ├── appointments/page.tsx
│ │ │ ├── records/page.tsx
│ │ │ └── messages/page.tsx
│ │ └── api/
│ │ ├── appointments/route.ts
│ │ ├── documents/route.ts
│ │ └── messages/route.ts
│ ├── lib/
│ │ ├── supabase/
│ │ │ ├── client.ts
│ │ │ └── server.ts
│ │ └── audit.ts
│ └── middleware.tsDatabase Schema
SQL
-- Run in Supabase SQL editor
-- Patient profiles (extends auth.users)
CREATE TABLE patient_profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
mrn TEXT UNIQUE NOT NULL, -- Medical Record Number
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
dob DATE NOT NULL,
phone TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Appointments
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES auth.users(id),
provider_name TEXT NOT NULL,
appointment_type TEXT NOT NULL,
scheduled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'confirmed', 'completed', 'cancelled')),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Secure messages
CREATE TABLE secure_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES auth.users(id),
sender_id UUID NOT NULL REFERENCES auth.users(id),
subject TEXT NOT NULL,
body TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- PHI audit log
CREATE TABLE phi_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
patient_id UUID,
action TEXT NOT NULL,
resource TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE patient_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
ALTER TABLE secure_messages ENABLE ROW LEVEL SECURITY;
-- Patients see only their own data
CREATE POLICY "patients_own_profile" ON patient_profiles FOR ALL USING (id = auth.uid());
CREATE POLICY "patients_own_appts" ON appointments FOR ALL USING (patient_id = auth.uid());
CREATE POLICY "patients_own_messages" ON secure_messages FOR ALL USING (patient_id = auth.uid());Supabase Client Setup
TYPESCRIPT
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createServerSupabase() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cs) => cs.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options))
}
}
)
}Authentication Middleware
TYPESCRIPT
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
const response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => request.cookies.getAll(),
setAll: (cs) => cs.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)) } }
)
const { data: { user } } = await supabase.auth.getUser()
// Redirect unauthenticated users to login
if (!user && request.nextUrl.pathname.startsWith('/(portal)')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
}
export const config = {
matcher: ['/(portal)/:path*', '/api/:path*']
}Dashboard Page
TYPESCRIPT
// src/app/(portal)/dashboard/page.tsx
import { createServerSupabase } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { logPhiAccess } from '@/lib/audit'
export default async function DashboardPage() {
const supabase = await createServerSupabase()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
// Fetch patient profile — RLS ensures they only see their own
const { data: profile } = await supabase
.from('patient_profiles')
.select('first_name, last_name, mrn')
.single()
const { data: upcomingAppts } = await supabase
.from('appointments')
.select('*')
.eq('status', 'scheduled')
.gte('scheduled_at', new Date().toISOString())
.order('scheduled_at')
.limit(3)
await logPhiAccess(user.id, user.id, 'READ', 'dashboard')
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">
Welcome, {profile?.first_name}
</h1>
<p className="text-sm text-muted-foreground mb-6">MRN: {profile?.mrn}</p>
<h2 className="text-lg font-semibold mb-3">Upcoming Appointments</h2>
{upcomingAppts?.map(appt => (
<div key={appt.id} className="p-4 border rounded-lg mb-2">
<p className="font-medium">{appt.appointment_type}</p>
<p className="text-sm text-muted-foreground">
{new Date(appt.scheduled_at).toLocaleString()} — {appt.provider_name}
</p>
</div>
))}
</div>
)
}Document Upload API
TYPESCRIPT
// src/app/api/documents/route.ts
import { createServerSupabase } from '@/lib/supabase/server'
import { logPhiAccess } from '@/lib/audit'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const supabase = await createServerSupabase()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const form = await request.formData()
const file = form.get('file') as File
const label = form.get('label') as string
if (!file) return NextResponse.json({ error: 'No file' }, { status: 400 })
// Validate file type — only PDFs and images
const allowed = ['application/pdf', 'image/jpeg', 'image/png']
if (!allowed.includes(file.type))
return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })
// Validate size (10MB max)
if (file.size > 10 * 1024 * 1024)
return NextResponse.json({ error: 'File too large' }, { status: 400 })
const path = `${user.id}/${Date.now()}-${file.name}`
const bytes = await file.arrayBuffer()
const { error } = await supabase.storage
.from('patient-documents')
.upload(path, bytes, { contentType: file.type })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
await logPhiAccess(user.id, user.id, 'UPLOAD', `document:${label}`)
return NextResponse.json({ path, label })
}Audit Logging
TYPESCRIPT
// src/lib/audit.ts
import { createServerSupabase } from './supabase/server'
import { headers } from 'next/headers'
export async function logPhiAccess(
userId: string,
patientId: string,
action: string,
resource: string
) {
const supabase = await createServerSupabase()
const headersList = await headers()
await supabase.from('phi_audit_log').insert({
user_id: userId,
patient_id: patientId,
action,
resource,
ip_address: headersList.get('x-forwarded-for') ?? 'unknown',
user_agent: headersList.get('user-agent') ?? 'unknown'
})
}Environment Variables
Bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Never expose these publicly
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...HIPAA Compliance Checklist
Authentication:
☐ Automatic session timeout (15-30 minutes of inactivity)
☐ Strong password requirements enforced
☐ MFA optional but encouraged for providers
Data:
☐ RLS enabled on all PHI tables
☐ Storage bucket: private, RLS-protected
☐ No PHI in URL parameters or browser history
Audit:
☐ Every PHI access logged with user, patient, action, timestamp, IP
☐ Audit logs not deletable by regular users
☐ Logs retained per HIPAA requirements (6 years)
Infrastructure:
☐ Supabase HIPAA tier with signed BAA
☐ HTTPS enforced everywhere
☐ Environment variables for all credentialsKey Takeaways
- RLS does the access control — every PHI table needs policies; test them with a real user, not the service role
- Audit every PHI access — who accessed what, when, from where — this is a HIPAA audit requirement
- File uploads need validation — type, size, and path isolation per user
- Middleware protects routes — unauthenticated requests never reach portal pages or API routes
- Get a signed BAA from Supabase before storing any real patient data — it's a legal requirement under HIPAA
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.