How to Build a Patient Portal in a Weekend with React and ClinikAPI
Step-by-step tutorial: build a working patient portal with React and ClinikAPI. Pre-built widgets for dashboards, appointments, prescriptions, and vitals. Ship in hours, not months.
By ClinikEHR Team
Duration
12 MINSQuick Answer
You can build a working, HIPAA-compliant patient portal in a weekend using ClinikAPI and the @clinikapi/react widget library. The widgets handle patient dashboards, appointment scheduling, prescription management, vitals display, lab results, intake forms, and consent signing — all through a secure proxy pattern that never exposes your API key to the browser. You write the backend proxy (5-10 lines per endpoint), drop in the React widgets, and ship.
Pre-Built Clinical UI for React
9 production-ready widgets. Patient dashboards, scheduling, prescriptions, vitals, and more. Drop them into any React app.
View React ComponentsWhat You Are Building
By the end of this tutorial, you will have a patient portal with:
- A patient dashboard showing demographics, recent visits, vitals, and medications
- An appointment scheduler where patients can book and manage visits
- A prescription viewer showing current and past medications
- A vitals chart with trend lines over time
- A lab results viewer with interpretation flags
- An intake form that patients complete before their first visit
- A consent manager for signing documents
All of this runs on real clinical data stored as FHIR R4 in a HIPAA-compliant backend. And you will build it in about 200 lines of code.
Prerequisites
- Node.js 18+ installed
- Basic React knowledge (hooks, components, props)
- A ClinikAPI account (free sandbox at clinikapi.com)
- A Next.js project (we will use App Router, but any React framework works)
Step 1: Set Up Your Project
Create a new Next.js app and install the ClinikAPI packages:
npx create-next-app@latest patient-portal --typescript --app
cd patient-portal
npm install @clinikapi/sdk @clinikapi/react
Add your ClinikAPI secret key to .env.local:
CLINIKAPI_SECRET_KEY=sk_test_your_key_here
That is your entire infrastructure setup. No FHIR server to provision. No database to configure. No HIPAA compliance checklist to work through.
Step 2: Understand the Proxy Pattern
The @clinikapi/react widgets run in the browser, but they never talk directly to ClinikAPI. Instead, they call your backend, and your backend calls ClinikAPI with your secret key. This keeps your API key safe.
Browser Widget → Your Backend Proxy → @clinikapi/sdk → ClinikAPI
This is the same pattern you would use with Stripe, Twilio, or any API that uses secret keys. The widgets just need a proxyUrl prop pointing to your backend endpoint.
Step 3: Build the Backend Proxy
Create a single API route that handles patient data requests. In Next.js App Router:
// app/api/clinik/patients/[id]/route.ts
import { Clinik } from '@clinikapi/sdk';
import { NextRequest } from 'next/server';
const clinik = new Clinik(process.env.CLINIKAPI_SECRET_KEY!);
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { data } = await clinik.patients.read(params.id, {
include: [
'Encounter',
'Observation',
'MedicationRequest',
'Appointment',
],
});
return Response.json(data);
} catch (error) {
return Response.json(
{ error: 'Failed to fetch patient data' },
{ status: 500 }
);
}
}
That is 20 lines. This single endpoint fetches a patient along with their encounters, vitals, prescriptions, and appointments — all in one call. The include parameter tells ClinikAPI to return related resources alongside the patient.
Add More Proxy Endpoints
For the appointment scheduler and other interactive widgets, add a few more routes:
// app/api/clinik/appointments/route.ts
import { Clinik } from '@clinikapi/sdk';
const clinik = new Clinik(process.env.CLINIKAPI_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.json();
const { data } = await clinik.appointments.create({
patientId: body.patientId,
practitionerId: body.practitionerId,
start: body.start,
end: body.end,
type: body.type,
});
return Response.json(data);
}
// app/api/clinik/intakes/route.ts
import { Clinik } from '@clinikapi/sdk';
const clinik = new Clinik(process.env.CLINIKAPI_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.json();
const { data } = await clinik.intakes.create({
patientId: body.patientId,
responses: body.responses,
});
return Response.json(data);
}
Each proxy endpoint is 10-15 lines. You control authentication and authorization on these routes — add your own auth middleware (NextAuth, Clerk, etc.) to ensure only the right patient sees their own data.
Step 4: Drop In the React Widgets
Now the fun part. Import the widgets and point them at your proxy endpoints.
The Patient Dashboard
This is the main view. It shows everything about a patient in one screen: demographics, recent encounters, current medications, and latest vitals.
// app/patient/[id]/page.tsx
import { PatientDashboard } from '@clinikapi/react';
export default function PatientPage({
params,
}: {
params: { id: string };
}) {
return (
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">My Health Portal</h1>
<PatientDashboard
proxyUrl={`/api/clinik/patients/${params.id}`}
onError={(err) => console.error('Dashboard error:', err)}
/>
</div>
);
}
That is it. One component. The PatientDashboard widget fetches data from your proxy, parses the response, and renders a complete patient overview with demographics, encounter history, vitals summary, and medication list.
Appointment Scheduler
Let patients book and manage their appointments:
// app/patient/[id]/appointments/page.tsx
import { AppointmentScheduler } from '@clinikapi/react';
export default function AppointmentsPage({
params,
}: {
params: { id: string };
}) {
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">My Appointments</h1>
<AppointmentScheduler
proxyUrl={`/api/clinik/appointments`}
onError={(err) => console.error(err)}
/>
</div>
);
}
The scheduler shows available time slots, lets patients pick a date and time, and creates the appointment through your proxy endpoint.
Vitals Chart
Show vital signs with trend lines so patients can track their health over time:
import { VitalsWidget } from '@clinikapi/react';
<VitalsWidget
proxyUrl={`/api/clinik/patients/${patientId}/vitals`}
onError={(err) => console.error(err)}
/>
Prescription List
Display current and past medications:
import { PrescriptionWidget } from '@clinikapi/react';
<PrescriptionWidget
proxyUrl={`/api/clinik/patients/${patientId}/prescriptions`}
onError={(err) => console.error(err)}
/>
Lab Results
Show lab reports with interpretation flags (normal, high, low):
import { LabResultsWidget } from '@clinikapi/react';
<LabResultsWidget
proxyUrl={`/api/clinik/patients/${patientId}/labs`}
onError={(err) => console.error(err)}
/>
Intake Form
Collect patient information before their first visit:
import { IntakeForm } from '@clinikapi/react';
<IntakeForm
proxyUrl="/api/clinik/intakes"
onError={(err) => console.error(err)}
/>
Consent Manager
Handle consent document signing:
import { ConsentManager } from '@clinikapi/react';
<ConsentManager
proxyUrl={`/api/clinik/patients/${patientId}/consents`}
onError={(err) => console.error(err)}
/>
Step 5: Put It All Together
Here is a complete patient portal layout with navigation:
// app/patient/[id]/layout.tsx
import Link from 'next/link';
export default function PatientLayout({
children,
params,
}: {
children: React.ReactNode;
params: { id: string };
}) {
const nav = [
{ label: 'Dashboard', href: `/patient/${params.id}` },
{ label: 'Appointments', href: `/patient/${params.id}/appointments` },
{ label: 'Prescriptions', href: `/patient/${params.id}/prescriptions` },
{ label: 'Lab Results', href: `/patient/${params.id}/labs` },
{ label: 'Vitals', href: `/patient/${params.id}/vitals` },
];
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b px-6 py-3">
<div className="max-w-6xl mx-auto flex gap-6">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-gray-600 hover:text-blue-600"
>
{item.label}
</Link>
))}
</div>
</nav>
<main className="py-6">{children}</main>
</div>
);
}
Your patient portal now has:
- A dashboard with full patient overview
- Appointment booking and management
- Prescription history
- Lab results with flags
- Vitals with trend charts
- Navigation between sections
Total frontend code: about 100 lines. Total backend code: about 60 lines. Total infrastructure setup: zero.
Step 6: Seed Test Data
Before you can see anything in the portal, you need some test data. Create a quick seed script:
// scripts/seed.ts
import { Clinik } from '@clinikapi/sdk';
const clinik = new Clinik(process.env.CLINIKAPI_SECRET_KEY!);
async function seed() {
// Create a patient
const { data: patient } = await clinik.patients.create({
firstName: 'Jane',
lastName: 'Doe',
email: '[email protected]',
gender: 'female',
birthDate: '1990-03-15',
});
console.log('Patient:', patient.id);
// Create a practitioner
const { data: doctor } = await clinik.practitioners.create({
firstName: 'Dr. Sarah',
lastName: 'Chen',
specialty: 'Internal Medicine',
});
// Create an encounter
const { data: encounter } = await clinik.encounters.create({
patientId: patient.id,
practitionerId: doctor.id,
type: 'office-visit',
status: 'finished',
});
// Record vitals
await clinik.observations.create({
patientId: patient.id,
encounterId: encounter.id,
code: 'blood-pressure',
value: { systolic: 120, diastolic: 80 },
unit: 'mmHg',
});
await clinik.observations.create({
patientId: patient.id,
encounterId: encounter.id,
code: 'heart-rate',
value: 72,
unit: 'bpm',
});
// Create a prescription
await clinik.prescriptions.create({
patientId: patient.id,
practitionerId: doctor.id,
medication: 'Lisinopril 10mg',
dosage: 'Take 1 tablet by mouth once daily',
frequency: 'once daily',
});
// Book an appointment
await clinik.appointments.create({
patientId: patient.id,
practitionerId: doctor.id,
start: '2026-05-01T09:00:00Z',
end: '2026-05-01T09:30:00Z',
type: 'Follow-up',
});
console.log('Seed complete. Visit /patient/' + patient.id);
}
seed();
Run it:
npx tsx scripts/seed.ts
Now visit http://localhost:3000/patient/pt_abc123 (using the ID printed by the seed script) and you will see a fully populated patient portal.
Customizing the Widgets
Every widget accepts a className prop for styling:
<PatientDashboard
proxyUrl={`/api/clinik/patients/${patientId}`}
className="rounded-lg shadow-sm border"
onError={(err) => console.error(err)}
/>
You can wrap widgets in your own layout components, add headers, combine multiple widgets on one page, or conditionally render them based on user roles.
For full customization options, check the ClinikAPI React component docs.
Security Checklist
Before going to production, make sure you have covered these:
API Key Security
- Your
CLINIKAPI_SECRET_KEYis only in.env.local(server-side) - The React widgets never see your API key
- Your proxy endpoints validate that the requesting user has access to the requested patient
Authentication
- Add auth middleware to your proxy routes (NextAuth, Clerk, Auth0, etc.)
- Verify the logged-in user matches the patient ID being requested
- Return 403 for unauthorized access attempts
HTTPS
- Use HTTPS in production (Vercel, Railway, and most hosts do this automatically)
- ClinikAPI enforces HTTPS on all API calls
Data Access
- Only return the data each user needs (patients see their own data, not other patients')
- Use ClinikAPI's tenant isolation for multi-clinic setups
What You Would Have Built Without ClinikAPI
To put this in perspective, here is what building the same patient portal from scratch would require:
- FHIR server — Provision and configure HAPI FHIR or AWS HealthLake (2-4 weeks)
- Data transformation — Write FHIR R4 mapping for 7+ resource types (2-3 weeks)
- API layer — Build REST endpoints with validation, error handling, pagination (2-3 weeks)
- UI components — Design and build patient dashboard, vitals charts, appointment scheduler, prescription viewer, lab results display, intake forms, consent manager (4-8 weeks)
- HIPAA compliance — Encryption, audit logging, access controls, BAA (2-4 weeks)
- Testing and QA — Integration tests, security testing, accessibility (2-3 weeks)
Total: 14-25 weeks (3-6 months) with 2-3 engineers.
With ClinikAPI + @clinikapi/react: one weekend, one developer.
Frequently Asked Questions
Do the React widgets work with frameworks other than Next.js? Yes. The widgets are standard React components. They work with Remix, Vite, Create React App, Gatsby, or any React 18/19 setup. You just need a backend that can proxy requests to ClinikAPI.
Can I style the widgets to match my brand?
Yes. All widgets accept a className prop. They are designed to be minimal and composable so you can wrap them in your own layout and apply your design system.
Is the patient data real or mock? In the sandbox (free tier), you work with test data using test API keys. When you upgrade to a production plan ($49/month+), you get live API keys for real patient data with full HIPAA compliance and BAA.
How do I add authentication to the portal? Add your preferred auth solution (NextAuth, Clerk, Auth0) to your Next.js app. Then add middleware to your proxy routes that verifies the logged-in user has permission to access the requested patient's data.
Can patients create their own accounts? ClinikAPI handles clinical data, not user authentication. You manage user accounts with your auth provider and map each user to a ClinikAPI patient ID in your database.
What happens if a widget fails to load?
Every widget accepts an onError callback. Use it to show a fallback UI, log the error, or retry the request. The widgets handle loading states internally.
Related Reading
Stay in the loop
Subscribe to our newsletter for the latest updates on healthcare technology, HIPAA compliance, and exclusive content delivered straight to your inbox.