Self-hosted referral portal for an outpatient imaging clinic. Referring physicians submit patients for MRI / X-ray / ultrasound studies, pick a slot, and track each referral through scheduling, imaging, and final report. Front-desk staff run the inbox side: accept, reschedule, cancel, complete.
Built on Medplum (FHIR backend + auth) with a Next.js 15 App Router front-end. Originally deployed for a single clinic in production; this repo is the same code, with branding pulled behind env vars so it can run for any tenant.
Status. Production-deployed v1.0. Everything tagged
[v1.1]in the planning docs is deferred — seeTODO.mdfor what's intentionally not done.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
All patient names, DOBs, phone numbers, member IDs, and clinical notes shown above are LLM-generated synthetic demo data — no real PHI. The seed fixture lives at
infra/compose/seed/src/realistic-seed.ts.
If you're scanning the repo for ideas worth stealing:
-
AccessPolicy via FHIRPath
%profile. A singleReferrerAccessPolicy gives every referrer access only to resources whererequesterresolves to their own Practitioner. No per-user policies, no app-layer filters — the isolation is enforced inside Medplum's query rewriter. Integration test ininfra/compose/seed/src/isolation.integration.test.tshits a real Medplum and asserts that referrer A literally cannot see referrer B's referrals over FHIR REST. -
Magic-link auth that doesn't fight Medplum. Medplum has no first-class passwordless flow, so the portal mints its own HMAC-signed token, then exchanges it for a Medplum session via a service-account login. The token carries
{ sub, em, jti, exp }and is replay-proof through a JTI table. Seeapps/portal/src/lib/auth/magic-link.tsandapps/portal/src/lib/auth/medplum-login.ts. -
Self-healing watchdog. A 60-second systemd timer probes Docker health
- an end-to-end
/api/healthrequest. Three consecutive failures →docker compose up -dthe affected service, with a 10-minute cooldown so a permanent failure doesn't thrash. Catches the "container alive, app hung" failure that Docker's own restart policy misses. Code ininfra/compose/scripts/watchdog.sh.
- an end-to-end
-
Brand-aligned transactional email. Every notification (welcome, magic link, scheduled, rescheduled, cancelled, completed, no-show, password reset) renders as inline-styled HTML + plaintext, in one envelope, with tokens that match the portal's CSS. Outlook-Word-renderer-safe (no flex, no grid).
apps/portal/src/lib/email/templates.ts. -
Wizard with two callers. The same multi-step referral wizard backs both
/refer/new(referrer-initiated) and/clinic/book(front-desk walk-in). Variant strings live in one config block; the rest is shared.apps/portal/src/components/wizard/WizardShell.tsx. -
Decision-log driven dev. Every non-trivial architectural choice is written down in
docs/adrs/before being implemented — Medplum-as-backend, isolation via%profileAccessPolicy criteria, insurance-as-extension vs Coverage resource, iron-session over JWT. Useful for picking up where the work left off. -
Upstream-friendly. Concrete fix proposals for Medplum live in
docs/upstream/. Outstanding right now: PR medplum#9137 — betterauth/loginerror when noProjectMembershipexists (maintainer ✅ "much more actionable", needs DCO sign-off). The magic-link RFC we sent landed on a better, already-in-flight upstream design (discussion #9109), so we added three downstream-use notes there and closed our own RFC.
┌─────────────────────┐
referring physician │ Cloudflare Tunnel │ no inbound
──────────────────────────────│ (zero-trust edge) │ ports on VPS
└──────────┬──────────┘
│
┌───────────────────────────────┴──────────────┐
│ │
┌────────▼─────────┐ ┌────────▼─────────┐
│ Next.js portal │ ── server actions ──> │ Medplum API │
│ (App Router 15) │ │ (FHIR + auth) │
└────────┬─────────┘ └────────┬─────────┘
│ iron-session cookies │
│ HMAC magic-link JWTs │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ nodemailer SMTP │ │ PostgreSQL + │
│ (Postmark/SES) │ │ Redis │
└──────────────────┘ └──────────────────┘
The portal is the only thing referrers ever see. Medplum's admin console is mounted on a separate hostname for clinic-side ops. AccessPolicies + role identifiers do all the authorization work; the portal trusts what FHIR returns.
| Layer | Choice |
|---|---|
| FHIR / auth backend | Medplum (self-hosted) |
| Web framework | Next.js 15 (App Router, Server Actions) |
| UI | React 19, Tailwind, custom design tokens (no component library) |
| Auth | iron-session cookies + HMAC magic-link JWTs |
| nodemailer + inline-styled HTML (StubTransport in dev) | |
| DB | Postgres 15 (Medplum's persistence) |
| Cache | Redis (Medplum's session/job store) |
| Edge | Cloudflare Tunnel (no exposed ports) |
| Tests | Vitest unit + Medplum-integration suite |
| Build | pnpm workspaces, Turborepo |
| Runtime | Node 22, Docker Compose, systemd watchdog |
apps/
portal/ Next.js 15 referrer + clinic-staff portal
bots/ Medplum Bot bundles (notifications)
infra/
compose/ Local + production Docker Compose
compose/seed/ Idempotent seed package (admin, AccessPolicy, schedules, slots)
compose/scripts/ gen-keys, gen-prod-config, watchdog, postgres backup
docs/
adrs/ Architectural decisions (4 short notes)
upstream/ PR-ready fix proposals for Medplum
screenshots/ UI captures
DEPLOY.md End-to-end production deploy guide
TODO.md Known issues + deferred work
Prereqs: Docker, Node 22, pnpm 9, openssl, jq.
# 1. Bring up the Medplum stack (Postgres + Redis + Medplum + MailHog)
cd infra/compose
cp .env.example .env
./scripts/gen-keys.sh
docker compose up -d --wait
# 2. Seed admin + project + AccessPolicy + Schedules + Slots
cd seed
cp .env.example .env
pnpm install
pnpm seed
# 3. (Optional) Seed realistic demo data — 4 referrers, 12 patients, 14 referrals
pnpm seed:realistic
# 4. Run the portal
cd ../../../apps/portal
cp .env.example .env
pnpm install
pnpm dev
# → http://localhost:3000Demo logins live in apps/portal/src/app/dev-login/client.tsx once the
realistic seed has run.
MailHog UI for inspecting rendered emails: http://localhost:8025.
pnpm -r typecheck
pnpm -r test
pnpm -C apps/portal buildThe cross-referrer isolation gate runs against a live Medplum:
cd infra/compose/seed && pnpm test:integrationIt asserts that even with a hand-crafted FHIR query, referrer A cannot see
referrer B's ServiceRequest, Patient, or DocumentReference resources.
End-to-end deploy guide (Cloudflare Tunnel + Docker Compose, single VPS,
~30 min): DEPLOY.md.
Watchdog timer install: infra/compose/scripts/install-watchdog.sh.
All brand strings (clinic name, phone, address, support email, portal URL)
are read from NEXT_PUBLIC_BRAND_* env vars at build time. Defaults live in
apps/portal/src/lib/branding.ts and ship
as Vendo Demo Clinic placeholder values. Override for a real tenant via
apps/portal/.env:
NEXT_PUBLIC_BRAND_NAME=Acme Imaging
NEXT_PUBLIC_BRAND_SHORT_NAME=Acme
NEXT_PUBLIC_BRAND_INITIALS=AI
NEXT_PUBLIC_BRAND_PHONE=(555) 010-0100
# ...etc — see apps/portal/.env.example for the full list
See TODO.md for known issues and the v1.1 backlog. Headlines:
no TOTP MFA, no self-service forgot-password, no Sentry, no patient-facing
accounts. Everything is documented.
Big chunk of the heavy lifting (FHIR, AccessPolicies, search index, Bot runtime, audit log) is Medplum. Their work, their license. See https://github.com/medplum/medplum.
MIT — see LICENSE.





