---
title: 'Share Packages, Not Applications: 3 Years of a Multi-Brand Next.js Monorepo'
author: 'Xinjiang Shao'
date: '2026-02-14'
description:
  'Three years modernizing a multi-brand healthcare platform — what I learned about monorepos, GraphQL gateways, and why sharing packages beats sharing apps.'
tags:
  [
    'Platform Architecture',
    'Engineering Leadership',
    'Next.js',
    'GraphQL',
    'Observability',
    'Monorepo',
  ]
coverImage: './images/cover.jpg'
---


I spent three years leading platform modernization at a large healthcare company — five consumer brands, 1,400+ physical locations across 48 states, serving tens of thousands of patients daily. This wasn't a frontend facelift. It was a **full-scope platform rewrite**: consumer-facing web apps, backend API services, async worker pipelines, native mobile apps, scheduled jobs, and — most recently — AI agent tooling. We migrated from six completely different legacy stacks to a unified modern architecture. The journey went through three distinct architectural eras, and each transition taught me something the previous one couldn't.


The biggest lesson wasn't about any particular technology choice. It was about **where to draw the boundary between shared and owned**. We got it wrong the first time, and getting it right the second time required the courage to add more deployments, not fewer.


This post is the story of those three eras, the trade-offs we made in each, and the pattern I'd now apply to any multi-tenant platform.

> **TL;DR:** Six legacy stacks became one monorepo across three eras. Sharing whole apps between
> brands gave us 80% code reuse but coupled every deploy. Giving each brand its own Next.js App
> Router app, built from shared packages, kept 60–80% reuse and made every deploy independent. The
> boundary between shared and owned should be the deployment artifact.
>
> For the program built on this platform, from fraud detection to the identity layer, see the
> [multi-brand Next.js platform case study](/case-study/digital-commerce).

Here's the overall platform architecture we landed on — seven layers from the edge down to scheduled jobs, with shared packages powering per-brand applications:

- **Edge / CDN** — per-brand CloudFront distributions terminate TLS and cache static assets.
- **Gateway** — a single Nginx reverse-proxy routes requests to the correct UI app by host or path.
- **UI Apps** — brand-specific schedulers plus shared marketing and directory apps, all built from the same monorepo packages.
- **Orchestration** — a single GraphQL gateway that stitches downstream subgraphs and connects directly to ReST data sources, while an MCP server exposes tools to AI agents and a ReST proxy bridges legacy consumers.
- **Core Services** — ReST APIs, GraphQL Subgraphs, gRPC services, and vendor API integrations that own the business logic.
- **Core Data** — Firestore for real-time document data, Postgres and MySQL for relational workloads, and Redis for caching and pub/sub.
- **Scheduled Jobs** — background workers that sync provider, facility, and review data from upstream systems and push reserve feeds downstream.

<LayeredDiagram
  layers={[
    {
      id: "edge",
      label: "Edge / CDN",
      color: "#6366f1",
      nodes: [
        { id: "cf-a", label: "Brand A CloudFront", icon: "☁️" },
        { id: "cf-b", label: "Brand B CloudFront", icon: "☁️" }
      ]
    },
    {
      id: "gateway",
      label: "Gateway",
      color: "#8b5cf6",
      nodes: [
        { id: "nginx", label: "Nginx Web Gateway", icon: "🔀" }
      ]
    },
    {
      id: "ui",
      label: "UI Apps",
      color: "#059669",
      nodes: [
        { id: "sched-a", label: "Brand A Scheduler", icon: "📅" },
        { id: "sched-b", label: "Brand B Scheduler", icon: "📅" },
        { id: "marketing", label: "Digital Marketing", icon: "📣" },
        { id: "directory", label: "Office Directory", icon: "🏢" }
      ]
    },
    {
      id: "orchestration",
      label: "Orchestration",
      color: "#d97706",
      nodes: [
        { id: "graphql", label: "GraphQL", icon: "📡" },
        { id: "mcp", label: "MCP Server", icon: "🤖" },
        { id: "rest-proxy", label: "ReST API Proxy", icon: "🔀" }
      ]
    },
    {
      id: "core",
      label: "Core Services",
      color: "#dc2626",
      nodes: [
        { id: "rest-apis", label: "ReST APIs", icon: "🔗" },
        { id: "graphql-apis", label: "GraphQL Subgraphs", icon: "📡" },
        { id: "grpc-services", label: "gRPC Services", icon: "⚡" },
        { id: "vendor-apis", label: "Vendor APIs", icon: "🔌" }
      ]
    },
    {
      id: "core-data",
      label: "Core Data",
      color: "#dc2626",
      nodes: [
        { id: "firestore", label: "Firestore", icon: "🔥" },
        { id: "postgres", label: "Postgres", icon: "🐘" },
        { id: "redis", label: "Redis", icon: "🟥" },
        { id: "mysql", label: "MySQL", icon: "🐬" }
      ]
    },
    {
      id: "jobs",
      label: "Scheduled Jobs",
      color: "#0891b2",
      nodes: [
        { id: "job-reserve", label: "Reserve Feed", icon: "📤" },
        { id: "job-provider", label: "Provider Sync", icon: "👨‍⚕️" },
        { id: "job-facility", label: "Facility Sync", icon: "🏥" },
        { id: "job-review", label: "Review Sync", icon: "⭐" }
      ]
    }
  ]}
  edges={[
    { source: "cf-a", target: "nginx" },
    { source: "cf-b", target: "nginx" },

    { source: "nginx", target: "sched-a" },
    { source: "nginx", target: "sched-b" },

    { source: "nginx", target: "marketing" },
    { source: "nginx", target: "directory" },
    { source: "sched-a", target: "graphql" },
    { source: "sched-b", target: "graphql" },

    { source: "marketing", target: "graphql" },
    { source: "directory", target: "graphql" },
    { source: "marketing", target: "rest-proxy" },
    { source: "directory", target: "rest-proxy" },
    { source: "graphql", target: "rest-apis" },
    { source: "graphql", target: "graphql-apis" },
    { source: "graphql", target: "grpc-services" },
    { source: "graphql", target: "vendor-apis" },
    { source: "mcp", target: "rest-apis" },
    { source: "mcp", target: "vendor-apis" },
    { source: "rest-proxy", target: "rest-apis" },
    { source: "rest-proxy", target: "vendor-apis" },
    { source: "job-reserve", target: "rest-apis" },
    { source: "job-provider", target: "firestore" },
    { source: "job-provider", target: "grpc-services" },
    { source: "job-facility", target: "grpc-services" },
    { source: "job-review", target: "grpc-services" },
    { source: "job-review", target: "firestore" },
    { source: "rest-apis", target: "postgres" },
    { source: "rest-apis", target: "firestore" },
    { source: "grpc-services", target: "postgres" },
    { source: "grpc-services", target: "firestore" },
    { source: "graphql-apis", target: "redis" },
    { source: "rest-apis", target: "mysql" }
  ]}
  height={700}
/>


---
<Badge>Era 0 · Legacy</Badge>

## The starting point: six stacks, five brands, zero shared infrastructure

The company had grown through acquisition. Each brand came with its own stack:


One brand ran **five separate Gatsby applications** — search, marketing, patient portal, scheduling, and an office directory — each independently deployed. Another had two Gatsby apps plus a third-party vendor for office listings and a different vendor for scheduling. A third operated **21 individual WordPress sites**, one per physical location. A fourth had a Next.js app written in JavaScript with no TypeScript, no linting, no tests, and no coverage — plus a separate Shopify storefront built by an outside agency. The fifth ran WordPress on managed hosting with a self-managed Salesforce instance handling scheduling, lead creation, and the patient portal.


Count that up: **30+ independent deployments** across Gatsby, WordPress, Next.js (JS-only), Shopify, Yext, Clockwise, PIM, and Salesforce. No shared components. No unified API. No design system. A performance improvement for one brand required reimplementation in four others. A WCAG accessibility fix needed to land in six different codebases.


> **The compounding cost**
>
> The fragmentation wasn't just a developer experience problem. It was a **patient experience problem**. Each brand's booking flow behaved differently. Accessibility compliance was inconsistent. SEO optimizations couldn't leverage the company's scale — 1,400 locations should generate massive local SEO authority, but 30+ separate codebases couldn't share structured data patterns, sitemap strategies, or content models.


And the clock was ticking. Gatsby's parent company had effectively ceased active development. Seven of our applications ran on a framework that was no longer receiving security patches. That was the forcing function. I've written more broadly about the strategic timing and organizational dynamics of these kinds of decisions in [Why Re-Platforming Matters](/blog/2025-09-11-why-re-platforming-matters).


---

<Badge>Era 1 · Next.js Page Router</Badge>
## Era 1: the shared-app model

The first modernization wave was driven by a simple thesis: **consolidate everything into the fewest possible applications**. Fewer apps meant less maintenance, more code reuse, and a single place to fix cross-cutting concerns.


We built two shared Next.js applications — one for digital marketing and one for the office directory — that served *all five brands* from the same codebase. Brand-specific routing and MUI (Material UI) theming made each brand look and feel distinct to the end user, while sharing 80%+ of the underlying code. Alongside these, each brand owned its own scheduler and patient portal application, because scheduling flows are fundamentally different between a dental practice, an urgent care walk-in clinic, a veterinary hospital, an aesthetic studio, and an implant consultation center.


```
Era 1: 7 applications total

SHARED
├── Digital Marketing App      # Next.js Page Router · serves all 5 brands
└── Office Directory App        # Next.js Page Router · serves all 5 brands

PER-BRAND
├── Brand A Scheduler           # dental appointments + patient portal
├── Brand B Scheduler           # implant consultations + Salesforce leads
├── Brand C Scheduler           # urgent care walk-ins + real-time wait times
├── Brand D Scheduler           # veterinary appointments × 21 hospitals
└── Brand E Scheduler           # aesthetic consultations + e-commerce upsells
```


This was a massive win. **30+ legacy deployments collapsed to 7 apps.** All running Next.js, MUI, and TypeScript on GKE (Google Kubernetes Engine). All sharing the same observability stack (Sentry for RUM, Grafana + Prometheus for metrics, Cloud Logging for structured JSON logs). All deployed through the same CI/CD pipeline with trunk-based development and semantic release.


The data layer was a **single Apollo GraphQL server** that unified access to providers, facilities, and CMS content — aggregating data from a mix of third-party RESTful and GraphQL APIs, first-party GraphQL and REST endpoints, and gRPC services. It wasn't a Federation gateway; it was a single, monolithic GraphQL service that acted as the frontend's one source of truth.


We eliminated the Gatsby EOL risk. We replaced 21 WordPress sites with a single multi-location app. We abstracted vendor dependencies (third-party scheduling and office listing APIs) behind the GraphQL layer. We brought the JavaScript-only codebase up to TypeScript with linting, tests, and coverage. We wrapped vendor APIs behind typed GraphQL resolvers so the frontend never called them directly.


For about a year, this was the right architecture.


### Then the shared app became a bottleneck


The problem wasn't code quality or performance. It was **organizational coupling**. Five brand teams were contributing to a single shared marketing application, and the friction compounded. Leading these kinds of cross-team architectural shifts without direct authority over every brand team requires a specific set of IC leadership skills — something I explored in [Leading Without Authority](/blog/2025-08-17-leading-without-authority-for-an-individual-contributor-leader).


**Deployment coupling.** A bug introduced in one brand's location pages blocked another brand's marketing release. Both brands' changes lived in the same deployable artifact, so you couldn't ship one without the other. We tried isolated test environments per brand, but the shared app's build was inherently monolithic — all brands built and deployed together.


**Merge queue congestion.** Even with trunk-based development and short-lived feature branches, five teams' pull requests competed for the same codebase. CI ran all five brands' test suites on every PR. A flaky test in one brand's Playwright suite held up everyone.


**Performance budget bleed.** One brand's marketing team added a heavy analytics integration that inflated the JavaScript bundle for all brands. Another brand's rich media carousel pulled in a library that affected every brand's Lighthouse score. Per-brand bundle analysis existed, but the shared webpack/turbopack configuration made true isolation difficult.


**Feature flag sprawl.** Because all brands shared one app, we leaned heavily on feature flags to gate brand-specific behavior. This worked at first but scaled poorly — the flag evaluation matrix became hard to reason about, and "shared" components accumulated conditional branches.


> **Lesson 1**
>
> Consolidation is the right first move when you're escaping legacy fragmentation. But **shared applications and shared packages are fundamentally different things**. A shared package is consumed at build time — each consumer gets its own copy, its own bundle, its own deploy. A shared application is a runtime coupling — all consumers live or die together.


That said, the shared-app model isn't *dead* in Era 2 — it's **scoped to where the risk profile fits**. We still run a multi-tenant marketing app for low-traffic, in-office promotional needs — think lobby screens, waiting room displays, internal marketing materials that staff access from office tablets. The deployment coupling and merge contention that plagued the consumer-facing shared app are non-issues here: the traffic is minimal, the release cadence is slow, and a brief deployment hiccup doesn't affect patient booking conversion. For this use case, sharing a single app across all five brands is the right trade-off — maximum code efficiency, minimal blast radius. The lesson isn't "never share apps." It's "don't share apps where the business impact of coupling is high."


On the technical axis, the Page Router's limitations compounded the organizational problem. `getServerSideProps` and `getStaticProps` forced an all-or-nothing SSR/SSG decision at the page level. There was no way to stream part of a page while pre-rendering the rest — which is exactly what healthcare location pages need (static address + services, dynamic availability + wait times). The architecture had hit its ceiling on both fronts.


---


## Era 1 deep dive: Brand B's backend modernization


The Era 1 story so far has focused on frontend consolidation — collapsing 30+ web apps into 7. But the modernization was never frontend-only. Brand B's migration required building an entirely new **backend service layer, async worker pipeline, and risk assessment system** from scratch. This was the first workstream that touched every layer of the platform — and the one that taught us the hardest operational lessons.


A natural question: why Brand B and not Brand A, the largest brand in the group? Brand A already has a mature backend ecosystem owned by a separate engineering team, with its own microservices architecture roadmap. From our platform team's perspective, Brand A's backend is an external dependency — we integrate with its APIs the same way we'd integrate with any third-party vendor. The frontend modernization (Era 1 → Era 2) applies to Brand A's web apps, but its backend is out of scope for this story. Brand B, by contrast, had *no* backend — just a WordPress site bolted onto Salesforce. Everything had to be built from zero.


Brand B operates a high-consideration dental implant business — patients don't book a cleaning; they book a multi-thousand-dollar consultation. The legacy stack was a WordPress marketing site tightly coupled to a self-managed Salesforce instance that handled everything: lead creation, consult slot management, and the patient portal. Every patient interaction — submitting a form, checking slot availability, confirming a consult — made synchronous round-trips to Salesforce. The result was **brutal latency**. Simple reads hit 1–2 seconds. Heavy operations — lead creation with slot reservation, multi-object queries during peak hours — routinely exceeded **10 seconds**. The booking flow didn't just feel sluggish; it felt broken. Form submissions timed out. Patients abandoned. And the tight coupling meant you couldn't fix the performance without rearchitecting the data flow entirely.


### API-first design: three services for one brand


When we migrated Brand B off WordPress, we took an **API-first approach** — designing the service contracts before building the UI. The backend decomposed into three services:


1. **Web API Service** — the synchronous, frontend-facing layer. Brand B's Scheduler and external partners connect here via REST for the booking flow: facility lookup, available consult slots, form submission, and lead status. This service owns the fast path — it reads from local caches and databases, not from Salesforce directly. It also calls the Risk Assessment Service synchronously during form submission to evaluate lead quality before accepting it.
2. **Web Worker Service** — the asynchronous processing layer. It handles two categories of work: **event-driven jobs** (lead creation, slot reservation, and Salesforce synchronization triggered by GCP Pub/Sub messages from the Web API) and **scheduled routine jobs** (nightly slot cache hydration, periodic Salesforce data reconciliation, stale lead cleanup, and reporting aggregation). It also receives **Salesforce push update events** for lead status updates and scheduling changes, and calls the Risk Assessment Service for additional fraud scoring on asynchronous workflows. This decouples patient-facing latency from Salesforce's response times and ensures background maintenance runs reliably without competing with user-facing requests.
3. **Risk Assessment Service** — evaluates incoming leads for quality and fraud risk, called synchronously by both the Web API (during form submission) and the Web Worker (during async lead processing) via REST. It scores based on phone number validity, distance between the patient's home address and the selected facility, name and email pattern analysis, and behavioral signals from the booking flow. High-risk transactions are flagged before the lead reaches Salesforce, saving the sales team time and preventing fraudulent consultations from consuming high-value appointment slots.



```mermaid
graph TD
    A["🖥️ Brand B Scheduler (Next.js)"]
    B["⚡ Web API Service<br/>slot queries · form validation<br/>facility lookup · lead status reads"]
    C["⚙️ Web Worker Service<br/>lead creation · slot reservation<br/>SFDC sync · event ingestion"]
    D["🛡️ Risk Assessment Svc<br/>phone validation · address/distance<br/>pattern analysis · fraud scoring"]
    E["☁️ Salesforce (self-managed)<br/>source of truth for leads + scheduling"]
    F["Other Partners"]
    G["📊 Marketing / Paid Ads<br/>Google Ads · Meta Ads<br/>GTM conversion events"]

    A -->|HTTP (ReST API)| B
    B -->|"Pub/Sub (async)"| C
    B -->|"HTTP (ReST API)"| D
    C -->|"HTTP (ReST API)"| D
    C -->|"SFDC API"| E
    E -->|"Push Update Events"| C
    F -->|"HTTP (ReST API)"| B
    D -->|"risk score + lead quality"| B
    A -->|"GTM conversion events"| G

    style A fill:#fef3c7,stroke:#92400e,color:#92400e
    style B fill:#fef3c7,stroke:#92400e,color:#92400e
    style C fill:#fef3c7,stroke:#92400e,color:#92400e
    style D fill:#fef3c7,stroke:#92400e,color:#92400e
    style E fill:#fde68a,stroke:#92400e,color:#92400e
    style G fill:#e0e7ff,stroke:#3730a3,color:#3730a3
```


The architecture worked as designed — the booking flow became fast because the Web API never waited on Salesforce. Slot availability read from a local cache hydrated by the Worker. Lead creation returned immediately to the patient ("We'll confirm your consult shortly") while the Worker handled the Salesforce round-trip asynchronously. The risk assessment ran in parallel with lead processing, flagging problematic submissions before they consumed sales capacity.


### The Pub/Sub dead letter lesson


Then we hit production.


GCP Pub/Sub was the backbone of the async flow — pull subscriptions for the Worker to process at its own pace, push subscriptions for Salesforce events flowing back. The Pub/Sub decoupling was the right pattern for performance, but we underestimated the operational discipline required for **dead letter queue (DLQ) management**.


Shortly after launch, a batch of malformed lead payloads — caused by an edge case in form validation — entered the Pub/Sub topic. The Worker couldn't process them, so they were negatively acknowledged and redelivered. And redelivered. And redelivered. Without a proper DLQ policy, these poison messages consumed the Worker's processing capacity, backing up the entire queue behind them. Valid lead creations and slot reservations stalled. The booking flow appeared to work (the Web API returned fast responses), but nothing was actually being confirmed on the Salesforce side. Patients received booking confirmations that never materialized into real appointments.


The outage lasted several hours before we identified the root cause. The fix was straightforward: configure a dead letter topic with a max delivery attempt threshold, then attach a single subscription that pipes every failed message into **BigQuery**. BigQuery gives us a durable, queryable record of every failure — payload, error metadata, timestamp, delivery attempt count — that we can filter, aggregate, and investigate at our own pace. A Grafana alert fires on DLQ depth so the on-call engineer knows immediately when poison messages appear. Retries are deliberate: an engineer reviews the failed payloads in BigQuery, fixes the root cause, then replays the corrected messages back into the original topic. No blind retries, no re-poisoning the queue.


> **The hard lesson**
>
> Async messaging improves latency but shifts failure modes from *visible and immediate* (a slow Salesforce call the patient can see) to *invisible and delayed* (a backed-up queue the patient can't see). The synchronous system was slow but honest — when it failed, the patient knew. The async system was fast but could silently fail. **If you decouple with messaging, your monitoring and DLQ strategy are not optional — they're load-bearing.**


### Observability: the second load-bearing investment


The DLQ incident exposed a deeper gap: our logging was inconsistent across services, and when something went wrong, correlating events across the Web API, Worker, and Risk Assessment Service required manually stitching together logs from three different sources. Structured JSON logging helped, but it wasn't enough for a distributed async system where a single patient action triggers a chain of events across multiple services and message queues.


The real fix was adopting **OpenTelemetry (OTel) end-to-end** — instrumenting every service with distributed traces, metrics, and structured logs that share a common trace context. A booking request now carries a single trace ID from the Scheduler through the Web API, into the Pub/Sub message, through the Worker's Salesforce sync, and back through the push event. When a lead fails to process, we don't grep logs — we pull up the trace in Grafana Tempo and see every span, every service boundary, every latency contribution in one waterfall view.


The OTel data feeds into **Grafana** across multiple dashboards: Pub/Sub queue depth and consumer lag, Salesforce API latency percentiles, Risk Assessment scoring distributions, lead creation success rates by facility. Alerts fire on anomalies — a sudden spike in DLQ depth, Salesforce response times exceeding thresholds, or risk assessment scores skewing abnormally high (indicating a bot attack or form abuse). The alerts are tuned to catch the *invisible and delayed* failures that the DLQ incident taught us to fear. These tools are part of a broader [Principal Engineer's toolbox](/blog/2025-10-05-the-toolbox-for-principal-software-engineers) I use to maintain clarity across teams and brands.


> **Lesson (observability)**
>
> **Structured logging is table stakes; distributed tracing is the real investment.** In a synchronous monolith, logs are usually enough — the entire request lifecycle lives in one process. In a distributed async system, you need traces that follow a request across service boundaries and message queues. OTel + Grafana gave us the ability to diagnose in minutes what previously took hours of manual log correlation. The cost of instrumenting upfront is trivial compared to the cost of debugging a production incident without it.


### The payoff: partner APIs and AI agents


The DLQ incident was painful, but the API-first approach paid dividends we hadn't originally planned for.


Because the Web API Service was designed as a clean, well-documented API boundary — not just a backend-for-frontend — it became straightforward to **expose a subset of those APIs to external partners**. Referral partners, marketing affiliates, and third-party platforms could now create leads, check consult availability, and query facility information through the same service that powered the consumer booking flow. What started as an internal performance optimization became an integration platform.


More recently, the same API surface has become the foundation for **AI agent development**. We built **MCP (Model Context Protocol) servers in Python** for both Brand A and Brand B — these give AI assistants (internal and external) structured, tool-based access to facility search, appointment availability, lead creation, and scheduling flows. The MCP servers are thin wrappers around the existing APIs; the hard work of designing clean service contracts was already done.


This is the part that's easy to miss in the microservices-vs-monolith debate: the value of API-first design isn't about how many services you deploy. It's about **having clean, well-typed boundaries that any client can consume** — whether that client is a Next.js app, a Flutter app, a partner integration, or an AI agent. The service topology behind those APIs can change (and in our case, is consolidating into a monolith) without any external consumer knowing or caring.


> **Lesson (API-first)**
>
> **API-first design outlives the architecture that implements it.** The service boundaries we drew for Brand B have been consumed by our own frontend, partner integrations, and AI agents — three use cases we didn't anticipate when designing the original booking flow. The MCP servers took days to build because the API contracts were already clean. If we'd built a tightly coupled backend-for-frontend, none of these would have been possible without a rewrite.


### Era 2 plan: consolidate back to a monolith


Here's the part that might surprise readers who expect every architecture story to end with "and then we added more microservices."


In Era 2, we're planning to **consolidate Brand B's three backend services into a single monolith**. The Web API, Web Worker, and Risk Assessment Service will merge into one deployable unit.


Why? Because the backend team is small. Three services means three deployment pipelines, three sets of health checks, three HPA configurations, and three Grafana dashboards to monitor. The Pub/Sub DLQ incident was harder to diagnose *because* the processing pipeline spanned two services. In a monolith, lead creation, risk assessment, and Salesforce sync would be function calls within one process — debuggable with a single stack trace, deployable as a single artifact, scalable as a single unit.


The async Pub/Sub pattern will remain where it's genuinely needed — Salesforce push events still arrive asynchronously by nature, and lead creation still benefits from not blocking the patient's HTTP response. But the *services* that process those messages don't need to be separate deployments. A monolith can consume Pub/Sub messages just as well as a microservice can.


> **Lesson (Brand B)**
>
> **A monolith is perfectly fine when the team is small.** The microservices-vs-monolith decision should be driven by team size and operational capacity, not by a belief that distributed systems are inherently better. Three services maintained by a small team means every person carries the cognitive load of the entire distributed system anyway — without the debuggability benefit of a single process. Consolidation isn't a step backward. It's right-sizing the architecture to the team that operates it.


---

<Badge>Era 2 · Next.js App Router</Badge>
## Era 2: per-brand apps, shared packages


The second modernization wave — now under way in 2026 — inverts the model. The thesis: **maximize code sharing at the package level, maximize autonomy at the application level**.


We started with the highest-traffic brand. Brand A's marketing app was the first to migrate from the shared Page Router app to its own standalone App Router application, absorbing the office directory as a route group. The early results validated the approach and gave us confidence to sequence the remaining four brands through the same pattern. Each brand's scheduler and patient portal will also get a v2 rewrite on the App Router. The target end state: 10 independently deployable applications.


```
Era 2: 10 per-brand apps + 1 shared internal app

PER-BRAND MARKETING (consumer-facing · office directory absorbed)
├── Brand A Marketing  ✓ LIVE  # Next.js App Router — first to migrate, validated
├── Brand B Marketing  planned # Next.js App Router
├── Brand C Marketing  planned # Next.js App Router
├── Brand D Marketing  planned # Next.js App Router
└── Brand E Marketing  planned # Next.js App Router

PER-BRAND SCHEDULER + PORTAL v2
├── Brand A Scheduler v2       # planned
├── Brand B Scheduler v2       # planned
├── Brand C Scheduler v2       # planned
├── Brand D Scheduler v2       # planned
└── Brand E Scheduler v2       # planned

SHARED (low-traffic · in-office)
└── In-Office Promotional App  # multi-tenant · lobby screens, waiting rooms, staff tablets
                               # low risk: minimal traffic, slow release cadence

SHARED MONOREPO PACKAGES (60–80% code reuse across all apps)
├── packages/ui                # design system — MUI components
├── packages/themes            # per-brand MUI createTheme() configs
├── packages/graphql-client    # Apollo Client + Codegen typed hooks
├── packages/scheduling        # shared booking primitives
├── packages/seo               # structured data, sitemaps, GEO
├── packages/analytics         # HIPAA-safe Sentry + event tracking
└── packages/shared            # Zod schemas, validators, types
```


Going from 7 to 10 deployments feels counterintuitive. We'd spent the first era consolidating; now we're splitting. But the Brand A pilot has already proven the key insight: **not all coupling is equal**. Package-level coupling is compile-time and unidirectional — each app gets its own copy of the shared code in its own bundle. Application-level coupling is runtime and bidirectional — all consumers share a single deployment artifact, a single process, a single failure domain.


With Brand A's marketing app running independently on the App Router, its team shipped three releases in the time it previously took to coordinate one shared-app release. Their Core Web Vitals improved because they control their own bundle — no other brand's dependencies inflating their JavaScript. Their CI pipeline runs only their tests, not all five brands'. The remaining four brands still run on the v1 shared marketing app, which continues to work fine — there's no urgency to migrate them all at once.


### Why this works on Kubernetes


The move from 7 to 10 apps only makes sense if additional deployments are cheap. On GKE, they are. Each app is a Helm chart with a handful of overrides: brand name, domain, theme import, resource limits, HPA thresholds. When we stood up Brand A Marketing as a standalone app, the infrastructure work took less than a day — the Helm template was already proven across the v1 apps. The CI/CD pipeline template is shared — a new app inherits linting, testing, security scanning, semantic release, and Grafana dashboard provisioning automatically. Standing up the next four brands will follow the same playbook.


Per-brand HPA (Horizontal Pod Autoscaling) means each brand scales independently based on its own traffic patterns. The largest brand's Monday morning appointment-booking spike doesn't steal resources from the smallest brand's steady-state traffic. In the shared-app model, we had to overprovision for peak across all brands simultaneously.


### Why App Router now


The v1 → v2 transition coincides with Next.js App Router reaching production stability. This wasn't coincidental — the App Router's capabilities directly address the v1 architecture's technical limitations, and Brand A's migration validated each of these in production:


**React Server Components** made marketing pages dramatically lighter. Brand A's location detail pages — name, address, services, insurance accepted, structured data, and SEO metadata — now render 80% of their content server-side with *zero client JavaScript*. Only the interactive booking widget and map embed ship as client components. On the Page Router, the entire page was a client-side React tree.


**Partial Prerendering (PPR)** solved the static-vs-dynamic dilemma that plagued our location pages. The static shell (address, services, SEO content) pre-renders at build time and serves from the CDN edge in under 50 ms. Dynamic content (real-time appointment availability) streams in via `<Suspense>` boundaries from the GKE origin. One HTTP request, instant static content, fresh dynamic data. On the Page Router, we had to choose: `getStaticProps` with stale data, or `getServerSideProps` with full latency.


**Streaming SSR** will be especially impactful for Brand B, next in the migration queue. Their Salesforce-backed operations are the slowest in the stack — simple reads at 1–2 seconds, heavy operations exceeding 10 seconds. On the Page Router, this blocks the entire page. On the App Router, the page shell streams immediately while Salesforce data fills in as it resolves. We expect perceived performance to improve dramatically based on what we've seen with Brand A's comparable patterns.


---


## The GraphQL layer: from monolith to gateway


The API layer is where the v1 → v2 evolution is most dramatic — and where we learned the most about when to consolidate and when to decompose.


### Era 1: one Apollo server doing everything


In v1, a **single Apollo GraphQL server** served as the frontend's data layer. It handled providers, facilities, and CMS content by aggregating data from a heterogeneous mix of backends: third-party RESTful APIs (vendor scheduling, office listings), third-party GraphQL APIs (CMS), first-party REST endpoints, first-party GraphQL services, and gRPC services. Every frontend query went to this one server, which resolved it by fanning out to the appropriate backend.


This worked well for the consolidation era. The frontend had **one endpoint to call, one schema to code-generate against, one place to look when a query was slow**. The single server was the simplest possible architecture that could unify 30+ legacy data sources.


But as the platform grew, the single server accumulated problems familiar to anyone who's operated a monolithic API gateway:


**Blast radius.** A slow or failing third-party scheduling API could degrade the entire GraphQL server, affecting facility lookups and CMS content — domains that had nothing to do with scheduling. One vendor's rate limit spike became everyone's latency spike.


**Deployment coupling.** A resolver change for facility data shipped in the same deployment as a resolver change for scheduling. The deploy pipeline for a CMS schema update was the same pipeline as a patient-facing booking flow change — same risk surface, same rollback scope.


**Scaling mismatch.** CMS content queries are cache-friendly and bursty (marketing page loads). Scheduling queries are uncacheable and latency-sensitive (booking flows). Provider lookups are medium-frequency with strict freshness requirements. One HPA configuration cannot serve all three traffic patterns well.


### Era 2 target: unified GraphQL gateway + service orchestration


The v2 architecture targets a **proper gateway layer** — a thin routing and orchestration tier that composes responses from domain-aligned backend services, rather than a single monolithic server that does all the resolution itself.


```mermaid
graph LR
    subgraph ERA1["Era 1 — Single Apollo Server"]
        direction LR
        A1["Next.js apps<br/>(shared)"] --> B1["Single Apollo<br/>GraphQL Server<br/>— one deploy —"]
        B1 --> C1["gRPC svc"]
        B1 --> C2["REST API"]
        B1 --> C3["3P GraphQL"]
        B1 --> C4["3P REST"]
        B1 --> C5["vendor API"]
    end

    subgraph ERA2["Era 2 — Gateway + Services"]
        direction LR
        A2["Next.js apps<br/>(per-brand)"] --> B2["GraphQL Gateway<br/>routing · composition<br/>caching · auth"]
        B2 --> D1["scheduling"]
        B2 --> D2["locations"]
        B2 --> D3["content"]
        B2 --> D4["patients"]
        B2 --> D5["leads"]
        B2 --> D6["reviews"]
        B2 --> R["Redis<br/>(Memorystore)"]
    end

    style ERA1 fill:#fef3c7,stroke:#92400e,color:#92400e
    style ERA2 fill:#d1fae5,stroke:#065f46,color:#065f46
```


The gateway handles routing, composition, caching (Redis), authentication, and rate limiting. The domain services own their data, their schemas, and their scaling. A slow Salesforce call no longer degrades facility lookups. The scheduling service scales independently during peak booking hours. Each service deploys on its own cadence.


### Redis: the caching layer that makes the gateway viable


**Google Cloud Memorystore (Redis)** is a critical piece of the v2 gateway architecture. Without caching, a composition gateway that fans out to multiple services would be *slower* than the v1 monolith — network hops multiply. With Redis, most queries never hit the downstream services at all.


The caching strategy operates at three levels:


1. **Full response cache** — keyed by a SHA-256 hash of the query and variables. Location detail queries, service listings, and office hours return in under 5 ms from Redis versus 50–200 ms from service resolution. TTLs range from 60 seconds (scheduling availability) to 5 minutes (marketing content).
2. **Entity cache** — keyed by `__typename:id`. A Location entity, once resolved, is cached for up to an hour. When a service mutation modifies an entity, it publishes an invalidation event that flushes the relevant cache entries.
3. **Automatic Persisted Queries (APQ)** — clients send a SHA-256 hash instead of the full query string. Redis stores the hash-to-query mapping. This reduces request payload size by 90%+ for complex queries, which compounds across all web apps and the Flutter mobile app hitting the same gateway.


The gateway is configured to **fail open** with a 5 ms Redis timeout — if Memorystore is unreachable, the request falls through to direct service resolution. Redis is a performance optimization, not a correctness dependency.


> **Lesson 2**
>
> Don't over-engineer the API layer in the consolidation phase. In Era 1, a single Apollo server was the right call — it unified 30+ legacy data sources behind one schema, which was the first-order problem. The decomposition into gateway + services is an Era 2 concern, driven by real operational pain (blast radius, deployment coupling, scaling mismatch) observed over a year of running the monolith. **You have to earn the right to distribute.**


---


## Theming: how MUI + Material Design bridge web and mobile


Serving five brands from shared component code requires a theming system that's expressive enough to make each brand feel distinct, but constrained enough that components never contain brand-conditional logic. MUI's `createTheme()` solved this elegantly.


```typescript
// packages/themes/brand-a.ts
export const brandATheme = createTheme({
  palette: {
    primary: { main: '#00529B', dark: '#003d75' },
    secondary: { main: '#5BC2E7' },
  },
  typography: {
    fontFamily: 'Brand Sans, sans-serif',
    h1: { fontFamily: 'Brand Display, serif' },
  },
  shape: { borderRadius: 8 },
});
```


Every component in `packages/ui/` references theme tokens — `primary.main`, `typography.h1`, `shape.borderRadius` — never hardcoded colors or font names. Each brand's app wraps its tree in a `<ThemeProvider>` with its theme. A `<BookingCard />` renders blue for Brand A, navy for Brand B, orange for Brand C — with zero conditional code.


On mobile, Flutter's `ThemeData` with `ColorScheme.fromSeed()` mirrors this pattern. Both MUI and Flutter Material Design share the same design language — the same elevation system, the same typography scale, the same component taxonomy. A designer creates one component spec; it manifests as MUI on web and Material on mobile without translation.


### Flutter flavors: the same pattern, applied to native apps


We're exploring extending the "share packages, not applications" principle to **Flutter native apps**. The first use case: Brand A is building a **check-in app for physical offices** — a tablet-based experience that replaces the vendor-provided check-in UI patients currently use when they walk into a location. Think of it as a kiosk app: patient arrives, checks in, confirms insurance, signs forms, and sits down.


The check-in flow is 80% identical across brands — patient identification, appointment lookup, form signing, queue placement. The 20% that differs is the theme (colors, typography, brand imagery), the intake forms (dental history vs. veterinary species/breed vs. aesthetic treatment goals), and the backend integration (which scheduling service to confirm arrival against).


Flutter's **flavor builds** map directly to this problem. A single Dart codebase produces brand-specific build artifacts — `flutter build apk --flavor brandA`, `flutter build apk --flavor brandB` — each with its own `ThemeData`, app icon, bundle ID, and backend configuration. The shared check-in logic (appointment lookup, form rendering, queue management) lives in common packages. The brand-specific layer is thin: a theme file, a flavor configuration, and any brand-specific intake form definitions.


```
Flutter Flavors: One Codebase, Per-Brand Builds

SHARED FLUTTER PACKAGES
├── check_in_core         # appointment lookup, queue management, form engine
├── graphql_client         # same gateway as web apps
├── design_system          # Material Design components, shared widgets
└── auth                   # biometric + staff PIN authentication

PER-BRAND FLAVORS (thin configuration layers)
├── flavor: brandA         # dental check-in — ThemeData, intake forms, app icon
├── flavor: brandB         # implant consult check-in (planned)
├── flavor: brandC         # urgent care walk-in (planned)
├── flavor: brandD         # veterinary check-in (planned)
└── flavor: brandE         # aesthetic consult check-in (planned)
```


This is the same architectural principle as the web platform: **shared packages (check-in logic, design system, GraphQL client) + owned build artifacts (per-brand flavor)**. The vendor-provided check-in UI that currently runs in each office is the mobile equivalent of the legacy WordPress sites — each brand has a different vendor's kiosk software, with no shared infrastructure and no ability to iterate on the patient experience. Replacing them with Flutter flavor builds gives us a single codebase to improve, five branded outputs, and the same GraphQL gateway backing the data.


> **Lesson 3**
>
> Choose your component library based on its theming system, not just its component catalog. For multi-brand platforms, the ability to swap visual identity through configuration — without touching component code — is the highest-leverage decision you'll make. This applies equally to web (MUI `createTheme()`) and mobile (Flutter flavors + `ThemeData`). The pattern is the same: **shared logic, branded output, configuration-driven differentiation**.


---


## The strangler fig: three generations coexisting


The routing between generations is handled by a **web-gateway — an nginx layer** that sits in front of all applications and manages route-level traffic splitting and redirects. Every incoming request hits the gateway first; it evaluates the hostname, path, and feature-flag context to determine which generation serves it. Right now, **three generations coexist simultaneously** — Brand A's marketing runs on v2, the other four brands' marketing runs on v1, and some legacy routes still haven't been fully retired:


```mermaid
graph TD
    A["🌐 Request"] --> B["CDN<br/>CloudFront → Fastly"]
    B --> C["Web-Gateway (nginx)<br/>route rules · redirects<br/>feature flag–aware proxying"]
    C -->|route = v2| D["✅ Per-brand App Router<br/>(Brand A live)"]
    C -->|route = v1| E["Shared Page Router<br/>(7 apps)"]
    C -->|route = legacy| F["Gatsby / WP<br/>SFDC / etc"]

    style D fill:#d1fae5,stroke:#065f46,color:#065f46
    style E fill:#fef3c7,stroke:#92400e,color:#92400e
    style F fill:#fee2e2,stroke:#991b1b,color:#991b1b
```


This three-generation model means there's **never a point of no return**. If a v2 brand app regresses — higher error rates, slower Core Web Vitals, lower booking conversion — the nginx routing rule flips back to v1 with a config reload. If v1 regresses, it falls back to legacy. The web-gateway also manages the hundreds of 301 redirects accumulated across three eras of URL structure changes — legacy WordPress slugs, v1 route patterns, and v2 App Router paths all resolve correctly. On GKE, every artifact promotes through a **dev → QA → preprod → production** pipeline. Each environment is a full replica of production, so issues surface before they reach patients. Rollbacks are trivial: redeploying the previous artifact takes seconds, not minutes. The web-gateway provides generation-level and route-level splitting on top of this.


For a healthcare platform serving tens of thousands of patients daily, this reversibility isn't a luxury. It's a clinical obligation. A broken booking flow means a patient doesn't get seen.


> **Lesson 4**
>
> The strangler fig pattern only works if **all generations remain fully functional** during migration. Never decommission the previous generation until the new one has been proven at 100% traffic for a meaningful window. Our rule: keep the previous generation warm for at least three months after the new one reaches full traffic. The cost of running both is trivial compared to the cost of a forced rollback to a cold system.


---


## Engineering practices that made the transitions survivable


Both transitions — legacy → v1 and v1 → v2 — were possible only because of shared engineering standards that applied **regardless of language, framework, or team**. Every service in the stack, whether it was a Python FastAPI service, a Node.js Apollo server, a C# .NET service, a Next.js app, or the Flutter mobile app, adhered to the same practices:


**Trunk-based development.** No long-lived feature branches. Work in progress goes behind feature flags, not on a branch that diverges for weeks. This meant we could migrate routes incrementally — one page type at a time, one market at a time — without maintaining parallel branches of the entire codebase.


**Semantic release.** Conventional commit messages drive automatic SemVer versioning, changelog generation, and release tagging. No human decides version numbers. This eliminated the "when do we cut a release?" question from every migration step — every merge to main is a release candidate.

**Test automation with Playwright — the gap we're closing.** This is the practice we got wrong in Era 1, and it's one of the highest-priority investments in Era 2. During the first modernization wave, we adopted Playwright for end-to-end testing but never fully integrated it into CI/CD as a gate. Tests existed but ran inconsistently — some brands had coverage, others didn't. Flaky tests were tolerated rather than fixed. The result: we shipped regressions that automated tests should have caught, and we lacked confidence in our deploy pipeline during the most critical migration moments.

In Era 2, Playwright E2E tests are a **required CI gate** — no PR merges without passing E2E for the affected brand, and no deployment promotes past QA without the full suite green. The per-brand app architecture makes this viable in a way it wasn't before: Brand A's test suite runs only against Brand A's app, not all five brands. We estimate test execution time drops by 60–70% compared to the shared-app model, since each brand runs only its own suite. Flaky tests are no longer someone else's problem — each brand team owns their suite and has both the incentive and the autonomy to keep it reliable.

The test architecture mirrors the app architecture: shared test utilities and page-object models live in `packages/test-utils`, while brand-specific test suites live alongside each app. Critical patient flows — booking an appointment, submitting a form, completing check-in — have Playwright coverage that runs on every PR and every deployment stage. If we'd had this discipline in Era 1, several production incidents that eroded team confidence in the shared-app model would never have shipped.


**JSON structured logging.** Every service emits JSON logs to Cloud Logging with a shared schema: `severity`, `timestamp`, `correlationId`, `service`, `brand`, `traceId`. When a booking flow fails during a v1 → v2 migration, we can trace the request from the CDN through the web-gateway, through the App Router, through the GraphQL layer, to the downstream service — across different languages and services — using a single correlation ID.


**HIPAA constraints as architectural guardrails.** Healthcare observability has a hard constraint: no PHI (Protected Health Information) can leak into logs, metrics, or error reports. Sentry's `sendDefaultPii: false` is the first line of defense. Server-side scrubbing filters PHI from breadcrumbs and session replays. Prometheus metrics are aggregated — no patient data in labels. This constraint, while sometimes annoying, improved our observability hygiene across the board.


---


## The pattern: what I'd tell you to do


If you're building or inheriting a multi-brand, multi-tenant platform, here's the sequence I'd recommend based on everything we learned:


1. **Consolidate first.** If you're drowning in fragmented legacy stacks, collapse them into the smallest number of applications that makes sense. The first priority is eliminating EOL risk, establishing shared infrastructure (observability, CI/CD, deployment model), and proving that shared code across brands is possible. Don't try to get the app boundaries perfect on the first pass.
2. **Start with a simple API layer, decompose when you've earned it.** A single GraphQL server that aggregates your legacy data sources is the right v1. Don't build a federated gateway or service mesh until you've felt the pain of the monolith — blast radius, deployment coupling, scaling mismatch. The decomposition into gateway + domain services is a v2 concern. Put a cache (Redis) in front of whatever you build.
3. **Observe the coupling.** Run the consolidated model for 6–12 months. Watch where the friction appears. It will be deployment coupling, merge contention, performance budget conflicts, and feature flag sprawl. These symptoms tell you where the app boundaries should actually be.
4. **Split apps, not packages.** When you split, move the shared code into packages and give each brand its own deployable application. The code reuse stays at 60–80%. The deployment coupling drops to zero. Additional deployments are cheap if your infrastructure automation is solid.
5. **Keep everything reversible.** Edge routing with feature flags at the CDN layer. Promotion pipelines (dev → QA → preprod → production) with instant rollbacks at the Kubernetes layer. Three generations coexisting at once. Never decommission until the new thing is proven. In healthcare, this isn't optional.


> **The one-line summary**
>
> **Share packages, not applications.** The first time you consolidate, you'll be tempted to share everything — it feels efficient, and it is, until organizational coupling catches up. The right architecture shares infrastructure (Kubernetes, CDN, observability), shares the API layer (GraphQL gateway, Redis), shares code (monorepo packages), and owns applications (per-brand deploys). The boundary between "shared" and "owned" should be the deployment artifact.


---


## The evolution at a glance


```mermaid
%%{init: {'theme': 'base', 'themeVariables': {'cScale0': '#fee2e2', 'cScaleLabel0': '#991b1b', 'cScale1': '#fef3c7', 'cScaleLabel1': '#92400e', 'cScale2': '#d1fae5', 'cScaleLabel2': '#065f46', 'cScalePeer0': '#fecaca', 'cScalePeer1': '#fde68a', 'cScalePeer2': '#a7f3d0'}}}%%
timeline
    title Three Eras — Full Platform Scope
    section Era 0 — Legacy
        Web Frontend : 30+ deployments
        Web Backend : Vendor APIs direct
        Workers / Jobs : None (SFDC sync)
        Mobile : Vendor kiosk UIs
        Code Sharing : None
        Deploy Coupling : Zero (siloed)
        Rendering : Static / CSR
        Infrastructure : Kinsta, Netlify, misc
    section Era 1 — Shared Apps
        Web Frontend : 7 apps (Page Router)
        Web Backend : Single Apollo GQL
        Workers / Jobs : Pub/Sub workers
        AI / Integration : API-first → partners
        Code Sharing : 80% (via shared apps)
        Deploy Coupling : High (shared apps)
        Rendering : SSR / SSG
        Infrastructure : GKE + CDN + Redis
    section Era 2 — Per-Brand Apps
        Web Frontend : 10 apps (App Router)
        Web Backend : GraphQL Gateway + svcs
        Workers / Jobs : Consolidated monolith
        Mobile : Flutter flavor builds
        AI / Integration : MCP servers (Python)
        Code Sharing : 60–80% (via packages)
        Deploy Coupling : Zero (per-brand)
        Rendering : RSC + Streaming + PPR
        Infrastructure : GKE + Fastly + Redis
```


Three years in, three architectures, one lesson: the boundary between shared and owned should be the deployment artifact. Everything below the app — packages, APIs, infrastructure — should be shared aggressively. The app itself should belong to a single team that can deploy, scale, and release it independently. This principle applies across every layer: web frontends, backend services, worker pipelines, mobile apps, and AI tooling.


We're not done — Era 2 is still rolling out. Brand A Marketing is live and proving the pattern. The remaining four brands will follow through 2026, along with scheduler v2 rewrites, backend monolith consolidation, the CloudFront-to-Fastly CDN migration, Flutter in-office check-in apps replacing vendor kiosk UIs, expanded MCP server coverage for AI agent integration, and a growing investment in AI search optimization (GEO) as AI-referred sessions grow 500%+ year over year.


But having Brand A live in production has given us something more valuable than a completed migration: **evidence**. Evidence that per-brand App Router apps are faster to develop, faster to deploy, and faster for patients — all while maintaining 60–80% code reuse through shared packages. The architectural foundation — shared packages, per-brand apps, GraphQL gateway with Redis cache, async worker pipelines, MCP servers, and GKE infrastructure — is stable enough that the remaining brand migrations are incremental rollouts, not architectural gambles.


That's the real test of an architecture: not whether it handles today's requirements, but whether it absorbs tomorrow's without breaking.


---


## What we didn't cover


This article focused on the structural evolution — how we drew the line between shared and owned across three eras. But several major workstreams deserve their own deep dives:


**CMS 1.0 → CMS 2.0.** CMS 1.0 offered over 100 distinct content models — large, purpose-built "lego pieces" that content authors could select from but not break apart or recombine. Need a new page layout? That required a new content model, which meant engineering work. CMS 2.0, powered by Contentful Studio, inverts this: it provides smaller, atomic building blocks that content authors can assemble directly into reusable patterns or compose into entirely new page layouts — without waiting for a developer to create a new model. The shift from "pick from 100 big pieces" to "build from small pieces" unlocked marketing capabilities that previously required engineering sprints: A/B testing page layouts, launching location-specific campaigns, and spinning up landing pages for new services in hours instead of weeks.


**Intelligent Scheduling v1.** We're building a machine-learning layer that analyzes historical slot usage patterns and lead quality signals to optimize appointment slot allocation. The goal: maximize slot utilization — reducing no-shows and unfilled capacity — while respecting provider work–life balance constraints like preferred hours, patient load limits, and break schedules. When a slot goes unfilled, the system adjusts future availability to shift demand toward higher-conversion time windows.


**Fraud Mitigation at Scale.** The Risk Assessment Service handles per-transaction scoring, but we're building a centralized **fraud control room** — a single dashboard that gives business operations the ability to monitor, block, and challenge suspicious activity across all brands in real time. Think of it as a booking-flow firewall: operators can flag IP ranges, throttle submission rates from specific sources, require additional verification for high-risk patterns, and review flagged transactions before they consume appointment capacity — all without deploying code.

_This blog is written in a personal capacity and reflects my own perspective on the work; it is not affiliated with or endorsed by my employer. Some details are simplified, generalized, or anonymized, and figures describe the platform at the time of writing. All trademarks belong to their respective owners._
