Healthcare

Therapist Practice Portal

React Router 7NestJSPostgreSQLTypeORMStripeGoogle CalendarDocker

Replaced Calendly + Mailchimp + Google Docs with one unified system a therapist actually controls

The Problem

A hypnotherapist in private practice was managing her entire client lifecycle across disconnected tools — Calendly for scheduling, Mailchimp for outreach, Google Docs for session notes, and manual email threads for everything in between. None of it talked to each other, and none of it understood the therapeutic relationship.

She needed one system where booking, messaging, content delivery, and client history all lived together — and where the boundaries of her practice were reflected in how the software actually worked.

Therapist Practice Portal — screenshot 1Therapist Practice Portal — screenshot 2Therapist Practice Portal — screenshot 3Therapist Practice Portal — screenshot 4Therapist Practice Portal — screenshot 5

My Role

Solo developer, full ownership — from discovery conversations with the therapist through architecture, implementation, deployment, and ongoing iteration. I made every technical decision: stack selection, data modeling, integration strategy, auth approach, payment flow design. The therapist's input shaped the domain rules; the engineering was entirely mine.

The therapist is a solo practitioner — no office manager, no tech support, no admin staff. She was duct-taping Calendly, Mailchimp, and Google Docs into a workflow that constantly leaked context between tools. I didn't just replace those tools; I built around how her practice actually works. Therapeutic relationships have stages — discovery, intake, ongoing care — and the software needed to understand and enforce those stages rather than treating every client the same way a generic scheduling app would.

Approach

React Router 7 frontend with NestJS API and PostgreSQL. The frontend handles three distinct surfaces — public booking pages, a client dashboard, and a therapist admin portal — all server-rendered through nested Remix routes. NestJS provides the structured backend with modular separation: auth, bookings, messaging, content/feed, notifications, availability, Stripe payments, and Google Calendar integration. TypeORM manages the schema with explicit migrations.

I chose this stack because the system needed SSR for public pages (SEO matters for a therapist's web presence), real-time data for the dashboards, and enough backend structure to support a dozen modules without the code becoming a maze. NestJS's module system gave me clean boundaries between booking logic, payment processing, calendar sync, and messaging — each with its own service, controller, and test suite.

Directus as a headless CMS for articles, podcasts, and static site content. Twenty CRM (open-source, self-hosted) for client relationship tracking. Both integrate through the NestJS API layer so the frontend never touches them directly.

Gateways of the Mind — therapist dashboard with booking calendar and client overview

Key Decisions

The discovery gate as a domain boundary, not a product limitation. Anonymous visitors can only book a free 20-minute discovery call. Paid session types are locked until discoveryCompletedAt is set on the user record. This isn't a paywall — it's how this therapist actually works. She doesn't take clients she hasn't spoken with. I encoded that boundary in the data model, enforced it in the API guards, and reflected it in the UI state. The system understands that the therapeutic relationship has stages.

Guest booking with optional account creation. A prospective client can book a discovery call without creating an account — just name, email, phone. After booking, they get a magic link to optionally create an account for their dashboard. I rejected requiring registration upfront because it adds friction at exactly the moment someone is making a vulnerable decision. The booking itself creates the user record; the account is just access to it.

Magic-link auth over passwords. Passwordless login via Resend (with SMTP fallback). 32-byte tokens, SHA-256 hashed in the database, single-use, 15-minute expiry. Silent success on unknown emails to prevent enumeration. I kept password login available in dev mode only. For a therapy practice where clients log in infrequently, magic links remove the "forgot password" problem entirely.

Stripe Checkout over embedded payment forms. Paid bookings redirect to Stripe-hosted checkout rather than processing cards in my UI. The booking is created with PENDING_PAYMENT status, Stripe handles PCI compliance, and a webhook (checkout.session.completed) confirms the booking and fires all side-effects — emails, in-app notifications, Google Calendar event creation, and a welcome message thread. I return 200 on webhook errors with idempotency guards to prevent Stripe retries from creating duplicate bookings.

Gateways of the Mind — booking flow with calendar and time slot selection

What Was Hard

Google Calendar bidirectional sync. The therapist manages her life in Google Calendar, and her availability in the booking system needed to reflect that. Bookings push events to Google Calendar (with automatic Google Meet links for virtual sessions). Google Calendar events pull back as blocked dates so personal appointments reduce available booking slots. The sync runs every 15 minutes via cron, paginates through up to 120 days of events, handles all-day vs. timed events differently, and does a full delete-and-recreate on each cycle to avoid stale timezone data. OAuth token refresh, revocation on disconnect, and graceful degradation when the Google API is unreachable all needed to work correctly. This was the most integration-heavy feature in the project.

Double-booking prevention under concurrent requests. Two clients selecting the same slot at the same time. I used a unique database constraint on date + start time for non-cancelled bookings, wrapped the insert in a serializable transaction with row-level locking, and return a 409 with a friendly message if the slot was taken between page load and submission. Simple in concept, but getting the transaction isolation right with TypeORM required careful testing.

Side-effect orchestration on booking confirmation. When a booking is confirmed (directly for free sessions, via webhook for paid), five things fire asynchronously: confirmation email to client, notification email to therapist, in-app notification records for both, Google Calendar event creation, and a welcome message thread for first-time clients. All are fire-and-forget so the user gets instant feedback, but each failure path needed to be logged and non-blocking. Getting the error handling right — especially when Google Calendar or Resend is temporarily down — took more iteration than the happy path.

Gateways of the Mind — client dashboard with content feed and messaging

The Result

The therapist manages her entire practice from one system. Clients book sessions (free discovery or paid), see their appointment history, receive personalized content (audio recordings, worksheets, guided meditations, links), and message their therapist — all through a single portal that reflects the actual structure of a therapeutic relationship.

The backend has 17 NestJS modules, explicit TypeORM migrations, structured logging, rate limiting, and 225 test files across the project. Booking confirmation orchestrates five async side-effects. The content feed supports four media types with per-client visibility and optional session linking. Six branded HTML email templates handle the full notification lifecycle.

What makes this project different from a generic scheduling app is that the domain rules are in the code, not bolted on. The discovery gate, the messaging access progression, the therapist-only content uploads, the client isolation — these aren't configuration toggles. They're the data model. The system doesn't just schedule appointments; it understands that a therapeutic practice has structure, and it enforces that structure at every layer.

Want to work together?

I'm looking for a full-stack engineering role. Let's talk.

Get in touch