Skip to content
bizurk
← ALL WRITING

2026-05-22 / 19 MIN READ

HIPAA Next.js App Router: where PHI leaks across primitives

A pattern survey of how PHI leaks across Next.js App Router primitives (cache, server components, edge runtime, client bundles) and the fixes that hold up.

A regulated Next.js app has six surfaces where protected health information can land in the wrong place, not four. Server components, client components, server actions, and route handlers are the obvious quartet that every healthcare write-up covers. The two that get missed are the request cache layer and the runtime selector at the top of every route. I have shipped one full HIPAA-regulated member platform from scratch and audited three other regulated Next.js builds, and the request cache is where the most recent leaks I have seen actually showed up.

This article is the survey across all six surfaces. It pairs with the deeper boundary write-up across four surfaces for the server-component-to-client edge cases, the redaction patterns I wrote up separately for the application log layer, and the regulated DTC architecture survey for the broader pattern of bolting commerce onto a clinical backbone. If you are debugging a specific leak, those sibling posts go deeper. If you are designing a new build, start here.

The pattern: every Next.js App Router primitive has a PHI default that hurts you

Every primitive in the App Router has a default behavior that makes sense for a non-regulated app and quietly fails in a regulated one. Server components default to passing props to client components without any filter; in a member-facing UI, that prop edge is where unfiltered records land in the browser. Cache functions default to keying on their arguments; the moment a member context is implicit (read from cookies, not passed in), two members can share a cache entry. The edge runtime defaults to a low-latency execution profile that limits which crypto libraries you can import; in a HIPAA app, that constraint pushes developers toward fragile workarounds.

The rule I keep coming back to is one sentence. PHI flows freely between server-side primitives under authentication; every other edge needs a filter or a deny. Server-to-server is safe under auth, regardless of whether you are crossing from a server component into a server action or from a route handler into a database query. Server-to-client is unsafe by default. Cache-to-anything is unsafe by default unless the cache key is member-scoped. Edge-runtime-to-PHI is unsafe by default, full stop.

The six surfaces in this article are server components, client components, server actions, route handlers, the request cache layer (use cache, cacheLife, cacheTag, updateTag), and the proxy (renamed from middleware in Next.js 16). The runtime selector at the top of each route is the seventh thing to track, but it is a configuration knob rather than a primitive, so I cover it inside the route-handler and server-component sections instead of as its own surface.

Single fractured glass fragment isolated on a dark studio backdrop, jagged edge under cold electric-blue rim, hot-pink dispersion bleeding through the broken face.
// the fragment · jagged edge under twin lights

Instance 1: server components quietly serializing PHI into the client bundle

The first instance is the one most engineers know to look for and miss anyway because the cache layer hides it. A server component that fetches a member record and passes the whole record as a prop to a client component will serialize that record across the boundary, and the unused fields end up in the browser bundle. The fix is a view-shape allowlist on the server before the prop reaches the client component. That much is well-documented.

The wrinkle is what happens when you wrap that server component in a cache. Next.js 16's cache components let you mark a function with use cache, set a cacheLife profile, and tag it for invalidation. The intent is performance: the cached function executes once per cache key and the result is reused. In a non-regulated app this is fine. In a regulated app, the cached value sits in a per-deployment cache, and if the cache key does not include the member identifier, two different members can hit the same cached function and see each other's data.

// app/(member)/profile/page.tsx
import { db } from "@/lib/db";
import { currentMember } from "@/lib/auth";
import { ProfileHeader } from "./profile-header";

// Cached layout shell. No PHI inside this function.
async function getProfileChrome() {
  "use cache";
  cacheLife("hours");
  return {
    layoutVersion: "2026-05",
    productSku: "default",
  };
}

// Uncached PHI fetch. Member-scoped, never inside use cache.
async function getMemberView(memberId: string) {
  const member = await db.member.findUnique({ where: { id: memberId } });
  return {
    displayName: member.preferredName ?? member.firstName,
    memberSince: member.createdAt.toISOString(),
  };
}

export default async function ProfilePage() {
  const session = await currentMember();
  const chrome = await getProfileChrome();
  const view = await getMemberView(session.memberId);
  return <ProfileHeader chrome={chrome} view={view} />;
}

The pattern is to split cached and uncached. The cached function returns a non-PHI shape (layout version, plan tier, feature flags). The PHI fetch sits outside use cache and runs per request under the resolved member context. Anything that needs invalidation when a member's record changes uses a member-scoped cacheTag, not a global one.

Instance 2: the edge runtime that looks safer and is the wrong place for PHI

The second instance shows up when an engineer reaches for the edge runtime to get globally distributed compute. The advertised benefit is low latency. The hidden cost in a regulated build is that the edge runtime restricts which crypto and database libraries you can import, and PHI decryption almost always wants the full Node.js standard library plus a properly initialized KMS client.

The anti-pattern looks like a route handler with export const runtime = "edge" at the top, and somewhere inside the handler a call into a PHI-decrypt function. Either the function fails at deploy time when a Node-only dependency does not bundle, or the developer reaches for a polyfill that bypasses the secure crypto path. The polyfill case is the one that scares me. It looks like it works. The audit log entries land. The unit tests pass. The crypto is wrong in a way that surfaces during a privacy review nine months later.

The fix is explicit. Every route that touches PHI sets export const runtime = "nodejs" and export const dynamic = "force-dynamic". The proxy (the file at the root of app/ that used to be called middleware.ts) handles non-PHI checks: geo, bot detection, rate limiting, an opaque session-token signature check. The PHI lookup happens in the route handler or server action under Node, never at the edge.

// app/api/member/records/route.ts
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

import { NextResponse } from "next/server";
import { currentMember } from "@/lib/auth";
import { decryptMemberRecord } from "@/lib/phi";

export async function GET() {
  const session = await currentMember();
  const record = await decryptMemberRecord(session.memberId);
  return NextResponse.json(toClientView(record));
}

The dynamic = "force-dynamic" line is doing two jobs. First, it tells Next.js not to attempt static generation, which in a Cache Components project would otherwise wrap the handler in cached behavior. Second, it documents the intent for the next reviewer: this route is never to be cached, statically or otherwise. I treat that line as a comment that the framework actually enforces.

Ultra-wide distant shot of a solitary translucent monolith on a dim plain, vanishing horizon, electric-blue atmospheric haze, hot-pink rim on the far edge.
// the distant monolith · single form on vanishing plain

Instance 3: the request cache and tag invalidation across members

The third instance is the one I see most often in 2026, because Cache Components shipped in Next.js 16 and most teams are still feeling out the edges. The leak pattern is straightforward to describe and easy to introduce: a cached function is keyed by something that does not uniquely identify the member, and two members hitting the same path share a cache entry.

// Anti-pattern: not safe for PHI
async function getMemberDashboard() {
  "use cache";
  cacheLife("minutes");
  cacheTag("dashboard");
  const session = await currentMember(); // closure read; not in cache key
  return await db.member.findUnique({ where: { id: session.memberId } });
}

The problem is that the cache key for getMemberDashboard is essentially the function identity plus its arguments, and the function takes no arguments. The await currentMember() call reads the request context inside the function body, but that context does not feed the cache key. The first member to call the function caches their record under the key. Every subsequent member calls the same key and gets the first member's record. This is not a hypothetical. I have seen this exact shape in code review.

The fix is twofold. Pass the member identifier in as an argument so it participates in the cache key, and use a member-scoped cacheTag so invalidation can target a single member.

// Safer pattern when caching is genuinely needed
async function getMemberDashboard(memberId: string) {
  "use cache";
  cacheLife("minutes");
  cacheTag(`member:${memberId}:dashboard`);
  return await db.member.findUnique({ where: { id: memberId } });
}

// On any PHI write that affects this member:
import { updateTag } from "next/cache";
await updateTag(`member:${memberId}:dashboard`);

The honest truth is that in a regulated build, I usually skip caching the PHI fetch entirely. The latency savings are modest, the failure mode of getting the cache key wrong is severe, and the audit story is cleaner when every PHI read goes through the database under the resolved member context. Cache the chrome, cache the layout, cache the static parts of the response. Refetch the PHI per request.

Instance 4: server actions returning rich error objects that include PHI

The fourth instance shows up in the server-action layer and almost never gets caught in code review because the leak is conditional on errors. A server action is an RPC endpoint. Its return value crosses the server-to-client boundary, including its thrown errors. When an action throws an exception, Next.js serializes the error and returns it to the calling client component. If your team has a habit of including the failing record in error messages for debugging, that record reaches the browser the first time the action throws.

// app/(member)/actions.ts
"use server";

import { db } from "@/lib/db";
import { logToBaaSink } from "@/lib/observability";

export async function updateMemberPreferences(memberId: string, prefs: PrefsInput) {
  try {
    await db.member.update({
      where: { id: memberId },
      data: { preferences: prefs },
    });
    return { ok: true as const };
  } catch (err) {
    const errorId = crypto.randomUUID();
    await logToBaaSink({
      errorId,
      memberId,
      operation: "updateMemberPreferences",
      cause: err,
    });
    return { ok: false as const, errorId };
  }
}

The pattern is to convert internal errors to opaque IDs at the action boundary. The full context (the member identifier, the failing record, the stack trace) goes to a sink that has a business associate agreement on file. The client receives the ID and a generic message, and a support engineer can look up the rich context by the ID when a member opens a ticket. The redaction patterns in the application-log post in this cluster cover the same idea applied to the broader observability stack.

The reason this pattern is important even if your error messages look benign is that exceptions thrown from libraries can include arbitrary record fragments. A Postgres unique-constraint violation often quotes the conflicting value. An ORM-level error can include the failing input. A TLS error from an external API call can include the request body. None of those are predictable at the time you write the action. The opaque-ID pattern handles all of them by default.

Macro close-up of a chipped glass corner on a dark studio backdrop, refractive bands of cold blue splitting through the chip into hot-pink across the inner face.
// the corner · refractive split through the chip

Instance 5: the proxy reading auth cookies on every request

The fifth instance is the proxy file. Next.js 16 renamed middleware.ts to proxy.ts, and the rename is a good prompt to revisit what belongs there. The temptation is to put PHI-touching logic in the proxy because it runs on every request, and the developer wants a check at the entry point. The cost is that the proxy runs at the edge by default, has request-level latency amplification, and shares the constraints I described in the runtime section.

The pattern that holds up is to keep the proxy stateless to PHI. The proxy validates the session token signature, checks expiry, possibly redirects unauthenticated requests, and writes a single header with the resolved opaque session identifier. Routes downstream do the database lookup under that resolved context. The proxy itself never touches a member record.

// app/proxy.ts (formerly middleware.ts in Next.js < 16)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifySessionToken } from "@/lib/auth-edge";

export async function proxy(req: NextRequest) {
  const token = req.cookies.get("session")?.value;
  if (!token) return NextResponse.redirect(new URL("/login", req.url));

  const claims = await verifySessionToken(token);
  if (!claims) return NextResponse.redirect(new URL("/login", req.url));

  const res = NextResponse.next();
  res.headers.set("x-session-id", claims.sid);
  return res;
}

export const config = {
  matcher: ["/(member)/:path*"],
};

The session token rotation pattern that pairs with this proxy is the topic of the rotation post in the cluster. The short version is that the proxy should validate against a current and a previous key during a rotation window so a key rotation does not log every member out simultaneously. That is a separate concern from the boundary question, but it lives in the same file and is worth reading next if you are designing the auth path from scratch.

PHI flows freely between server-side primitives under authentication; every other edge needs a filter or a deny.

Instance 6: third-party integrations that re-import PHI through the back door

The sixth surface is the integration layer with external systems. A regulated DTC build often connects to an EHR-adjacent system, an insurance eligibility service, or a clinical CRM. The integration patterns I have used at scale are described in the broader integration write-up for clinical CRMs, and the boundary failure I want to flag here is specific to the App Router.

A route handler that proxies a request from a client component to an external healthcare system will receive whatever the upstream system sends back, including fields the client never needs. The fix is the same shape as the server-component-to-client filter: define a client-safe view in the route handler, transform the upstream response into the view shape, return only the view. Treat the route-handler-to-client edge as a public API even when both ends are inside your own app.

// app/api/clinical/summary/route.ts
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

import { NextResponse } from "next/server";
import { currentMember } from "@/lib/auth";
import { fetchUpstreamSummary } from "@/lib/clinical";

export async function GET() {
  const session = await currentMember();
  const upstream = await fetchUpstreamSummary(session.memberId);

  // Allowlist transformation. Upstream may include diagnosis codes,
  // billing detail, and provider notes. Client only needs status + date.
  const view = {
    status: upstream.summary?.status ?? "pending",
    asOf: upstream.summary?.lastUpdated ?? null,
  };

  return NextResponse.json(view);
}

The instance to watch for in code review is a route handler that returns the raw upstream payload with NextResponse.json(upstream). It looks lazy. It is also the most common way I see PHI cross from a covered upstream into the client bundle of an app that did not intend to receive it.

Ultra-wide backlit silhouette of a tall translucent slab against a deep electric-blue dusk sky, hot-pink magic-hour band burning low along the horizon.
// the silhouette · slab against deep dusk sky

How to spot the leaks early in any Next.js codebase

When I take a look at an existing regulated Next.js codebase, there are four files I read first. Each one tells me whether the boundary is being respected.

The first is the runtime configuration on every route. I run git grep "runtime" src/app and look for any PHI-adjacent route that exports runtime = "edge". If I find one, that is the first fix. The second pass is to look for routes that should have dynamic = "force-dynamic" and do not, because Cache Components will otherwise treat them as cacheable.

The second is a search for cache primitives. I grep for use cache, cacheLife, cacheTag, revalidateTag, and updateTag, and read each match. Every cached function that touches member-scoped data needs a member identifier in its arguments and a member-scoped cacheTag. Functions that close over an implicit member context fail this check.

The third is the proxy.ts file. The rule is one read of session token signature, one redirect on failure, one header write on success. Anything else (a database query, a PHI lookup, a third-party API call) belongs in a route or a server action.

The fourth is every server action's catch block. If the catch rethrows the original error, or returns the error message verbatim, that is a fix. The opaque-ID pattern is small enough to apply incrementally; a team can fix the highest-risk actions first and migrate the rest.

Each of these checks takes a few minutes per file. The total audit on a moderately sized regulated app runs an afternoon. Catching a leak in week one of an engagement is a small fix, while catching the same leak in month nine, after a privacy review surfaces it, can be a multi-quarter rebuild. The math here is asymmetric and worth paying.

Does use cache with cacheLife set to private make a function safe for PHI?

No. cacheLife controls how long an entry lives, not who can see it. Two members hitting the same cache key still share the entry regardless of the lifetime. Safety comes from the cache key itself, which has to include the member identifier as an explicit argument to the cached function. If your function reads the member from cookies inside the body, the cache key does not see the member, and the cache will hit across members.

Can I just disable the App Router cache layer entirely in a regulated app?

You can, and for the PHI-bearing routes I think you should. Set dynamic = "force-dynamic" on every PHI route and avoid use cache in PHI-fetching functions. Cache the layout chrome, the marketing surfaces, and any non-member-scoped reference data. The latency win from caching a PHI fetch is usually small and the failure mode is severe; I treat that tradeoff as a one-way door.

What about Next.js 16 view transitions - do they touch PHI?

View transitions are a rendering primitive that animates between pages, not a data primitive. They do not move data across boundaries beyond what the underlying server components and client components were already going to send. The boundary work is the same with or without view transitions enabled. The thing to watch is that you do not pass a richer prop shape to a transition snapshot than you would to a normal render; the snapshot serializes the same way.

Does Vercel's BAA cover all of this automatically?

Vercel will sign a business associate agreement for the hosting layer, which covers the platform itself. It does not cover what your code does on top of the platform. The BAA is a foundation, not a guarantee. The patterns in this article are about what your code does inside the runtime; the platform BAA only matters if your code does not leak the data before it leaves the request boundary.

How is this different from the PHI-boundaries-across-four-surfaces article in the same cluster?

The four-surfaces article goes deep on the server-component-to-client edge: how to filter props, how to design view types, how to think about server actions and route handlers as RPCs that need response shaping. This article surveys six surfaces at the cluster-hub level and includes the cache layer and the proxy as first-class boundaries. Read that one for the deep boundary mechanics; read this one for the broader inventory of where to look.

Sources and specifics

  • The patterns described run on Next.js 16 with the App Router, async cookies() / headers() / params, the proxy.ts file (formerly middleware.ts), and Cache Components opt-in via experimental.cacheComponents or the equivalent stable flag.
  • The PHI surfaces inventory covers six primitives: server components, client components, server actions, route handlers, the request cache layer (use cache, cacheLife, cacheTag, updateTag), and the proxy. The runtime selector at the top of each route is a configuration knob covered inside the route-handler section rather than as its own surface.
  • The anti-patterns and the fixes were tested in production on a regulated DTC healthcare member platform during 2024 and 2025.
  • The deny-list (edge runtime for PHI, use cache of PHI-bearing functions, third-party <script> tags in PHI routes) reflects one operator's GC-wince posture, not a published regulatory standard. The Security Rule at 45 CFR Part 164 Subpart C does not name Next.js primitives; the mapping from regulation to framework primitive is editorial.
  • The opaque error-ID pattern uses a pseudonymous identifier under the HIPAA definition, not a Safe Harbor de-identifier. It is suitable for cross-system error correlation, not for de-identification of a dataset under 164.514.

// related

Let us talk

If something in here connected, feel free to reach out. No pitch deck, no intake form. Just a direct conversation.

>Get in touch

Tell me what you’re trying to ship.

Send a quick message and I read it within a day, or talk to AI Michael first if you want to feel out your project before you write to me.