Skip to content
← ALL WRITING

2026-04-23 / 12 MIN READ

PHI boundaries across the Next.js App Router's four surfaces

Three phi boundaries next.js app router patterns covering server components, client components, server actions, and route handlers, with the code that enforces them.

The Next.js App Router is four surfaces in a trench coat: server components, client components, server actions, and route handlers. Each surface has its own data visibility rules, and a HIPAA-regulated app has to draw a PHI boundary across all four consistently. Miss one surface and PHI lands in the one place the code reviewer forgot to check. I have walked into the same three boundary failures on several regulated Next.js engagements, and the fixes are small when caught early and expensive when caught late.

This post is the boundary-drawing companion to the solo HIPAA Next.js primitives post. That one covered the what; this one covers the where across the App Router's specific surfaces on Next.js 14 and 15. The patterns hold on Next.js 16 with the rename of middleware to proxy, which I cover in the FAQ.

The pattern: four surfaces, one boundary

The mental model I keep coming back to is a directed graph with four nodes. A server component runs on the server and can read PHI freely from your database. A client component runs in the browser and must never receive PHI unless the member is authenticated and the PHI belongs to them. A server action is an RPC endpoint that runs on the server but is invoked from client code. A route handler is an HTTP endpoint under app/api/* that responds to any caller that can produce a valid session.

App Router surfaces — where PHI can flowclick a node
Edges for Server Component
  • Server ComponentClient Component: props serialize to client: filter PHI fields first
  • Server ComponentServer Action: both server-only: PHI may flow under auth
  • Server ComponentRoute Handler: server-to-server fetch with auth cookie
Four App Router surfaces and the PHI-safe vs PHI-leaking edges between them.

The edges matter more than the nodes. Server component to client component: props serialize across this edge, so PHI in the props reaches the browser. Server action returning to the client: the return value crosses the boundary, so PHI in the return must be filtered. Route handler responding to a client fetch: the response body crosses the boundary, same filter required. The three unsafe edges in the diagram above are where the leaks happen. The three safe edges are server-to-server transitions; PHI can flow freely under authentication on those.

The boundary rule is simple to state: PHI can flow between any two server-side nodes freely, but every edge that terminates in the client must pass through a filter that removes PHI fields not authorized for that specific member. That rule covers 90% of the patterns. The other 10% is nuances around useFormState, streaming, and server-component children that render into client components, each covered below.

Instance 1: server components leaking PHI into the client bundle

The first instance is the one that looks safe and isn't. A server component fetches a full member record from Postgres and passes it as a prop to a client component that renders only the member's name. In Next.js, props to client components are serialized and sent to the browser. The member's name reaches the UI. The rest of the record, including diagnosis codes, medication history, and billing details, reaches the browser too, even though the client component never uses it.

The fix is to filter in the server component, not in the client. Build a view object on the server that has only the fields the client needs, pass that view object as the prop.

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

export default async function ProfilePage() {
  const session = await currentMember();
  const member = await db.member.findUnique({
    where: { id: session.memberId },
  });

  // Build a client-safe view. Explicit allowlist, not exclude-list.
  const view = {
    displayName: member.preferredName ?? member.firstName,
    memberSince: member.createdAt.toISOString(),
  };

  return <ProfileHeader view={view} />;
}
// ./profile-header.tsx — client component
"use client";
import type { ProfileView } from "./types";

export function ProfileHeader({ view }: { view: ProfileView }) {
  return <header>Hi, {view.displayName}</header>;
}

Two patterns to internalize. First, define the view type explicitly. TypeScript will refuse to pass an unexpected field through it. Second, use an allowlist, not an exclude-list. An exclude-list breaks silently the moment the member type grows a new field; an allowlist breaks loudly at the type level.

Instance 2: server actions returning PHI to the client without redaction

The second instance is the rise of server actions. A server action is an async function marked with "use server" that runs on the server but is invocable from a client component, usually via a form submission or a button click. The return value of the action is serialized back to the client.

The trap: server actions often return the object they just operated on for convenience. In a CRUD app, that is fine. In a regulated app, the returned object may contain fields that the client never needed to see. Here is a PHI-leaking server action and the fix.

// Leaky version
"use server";

export async function updateMemberPreferences(
  memberId: string,
  prefs: PrefsInput
) {
  const updated = await db.member.update({
    where: { id: memberId },
    data: { preferences: prefs },
  });
  return updated; // returns the whole member record to the client
}
// Fixed version
"use server";

export async function updateMemberPreferences(
  memberId: string,
  prefs: PrefsInput
): Promise<{ ok: true; updatedAt: string }> {
  const session = await currentMember();
  if (session.memberId !== memberId) {
    throw new Error("unauthorized");
  }
  const updated = await db.member.update({
    where: { id: memberId },
    data: { preferences: prefs },
  });
  return { ok: true, updatedAt: updated.updatedAt.toISOString() };
}

Three things changed. The return type is explicit: the client gets an acknowledgement, not the object. The server re-verifies authorization inside the action rather than trusting the member ID passed from the client. The action uses the actual session to check. Never trust a memberId argument from the client; always derive identity from the session cookie on the server.

React 19 introduces useActionState (formerly useFormState) which wires an action's return value directly into component state. The same boundary rule applies: the return value is serialized to the client; PHI belongs filtered.

Instance 3: route handlers as the "public API" that isn't

The third instance is route handlers under app/api/*. These are HTTP endpoints that respond to any authenticated caller. The common failure mode: the handler returns a rich JSON payload for convenience, without realizing that the payload is now a public JSON API documented by whatever client code fetches it. If that JSON contains PHI fields not needed by the current caller, the endpoint has violated minimum-necessary.

The fix is to treat every route handler as an external API contract. Define the response shape explicitly, filter at the boundary, and serve only what the caller needs.

// app/api/members/[id]/dashboard/route.ts
import { NextResponse } from "next/server";
import { currentMember } from "@/lib/auth";
import { db } from "@/lib/db";

type DashboardResponse = {
  displayName: string;
  upcomingAppointmentCount: number;
  lastActivityAt: string | null;
};

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params; // Next.js 15+: async params
  const session = await currentMember();
  if (session.memberId !== id) {
    return NextResponse.json({ error: "forbidden" }, { status: 403 });
  }

  const record = await db.member.findUnique({ where: { id } });
  const appts = await db.appointment.count({
    where: { memberId: id, scheduledAt: { gte: new Date() } },
  });

  const body: DashboardResponse = {
    displayName: record.preferredName ?? record.firstName,
    upcomingAppointmentCount: appts,
    lastActivityAt: record.lastActivityAt?.toISOString() ?? null,
  };

  return NextResponse.json(body);
}

The response type is the contract. The handler is responsible for shaping the response to match the contract, not for dumping the database row. The type ensures a new field on the member record does not silently leak through. The authorization check sits inside the handler, never trusting the path parameter alone.

The same discipline applies to error responses. Error bodies are JSON too; they can leak PHI if they echo the request data back. The logging-without-leaking-PHI patterns post in this cluster covers the error-ID indirection that keeps error responses free of PHI.

PHI can flow between any two server-side nodes freely, but every edge that terminates in the client must pass through a filter.

What the pattern tells us

The App Router gave us more boundaries to draw, not fewer. The old Pages Router had a cleaner split because data fetching happened in a handful of well-known places (getServerSideProps, API routes). The App Router lets PHI live anywhere on the server, which is a gift for architecture and a risk for compliance. The discipline is to treat every serialization point (server-to-client prop, server action return, route handler response) as a compliance checkpoint with an explicit typed contract.

The second insight is that types are load-bearing. TypeScript is the only enforcement mechanism that scales. A view type with an explicit allowlist of fields will catch a bad prop at compile time; a runtime filter can be bypassed. If your regulated Next.js codebase relies on manual filtering without types, the next new hire's first PR will undo it.

The third is that authentication belongs inside every surface, not at the perimeter. Middleware (proxy in Next.js 16) can gate routes, but it does not stop a logged-in member A from requesting member B's data. Each server action and route handler must re-verify that the session matches the data being returned. The append-only audit log patterns post captures the "who read what" trail that makes this verifiable after the fact.

How to spot the gap early

Four checks surface the boundary failures before they ship.

Pick any client component in your app and trace its props back to the server component that renders it. If the prop type is a database row, the boundary is missing. Replace with an explicit view type.

Read every "use server" function in the codebase and look at the return type. If any return type is inferred as a full database entity, add an explicit return type and strip PHI.

Open each file in app/api/*/route.ts and check for an explicit response type. Any handler that returns NextResponse.json(record) with no shape enforcement is a boundary leak waiting to happen.

Grep for "use server" and check that every such function re-derives the member identity from the session. Functions that trust a member ID argument from the client are accepting an impersonation risk in addition to the PHI risk.

If all four pass, the boundary is clean. If any fail, the fix is local and TypeScript-enforced, which is the best kind of fix. The cluster's regulated DTC healthcare engineering hub covers the architectural context; the healthcare AWS environment case study captures an adjacent infrastructure failure pattern at the silent failure case study for teams operating on cloud infrastructure alongside the App Router.

FAQ

Does Next.js's 'server-only' package prevent client leakage?

The server-only package throws at build time if a module is imported into a client component. It prevents entire modules from crossing the boundary. It does not prevent prop-level PHI leaks, because props are values, not modules. Use server-only to guard database clients, server-side env vars, and auth helpers. Use view types and explicit return types to guard props and return values.

How does this change in Next.js 16 with the middleware-to-proxy rename?

Next.js 16 renames middleware to proxy, and params, searchParams, cookies(), headers(), and draftMode() became async. The boundary model is the same; the syntax changes. Proxy (formerly middleware) still runs before the route and is a good place for auth gating and IP-based restrictions, but it does not replace the per-handler authorization checks described above. Treat proxy as a coarse filter, per-handler checks as fine filters.

What about streaming responses from server components?

Streaming is a rendering optimization, not a data-exposure change. The same boundary rules apply: the props and content that reach the browser must be filtered. Suspense boundaries do not relax the PHI rule. If anything, they make it more important to review because each streamed chunk is a separate serialization point.

Can I use React 19's useActionState with server actions in a regulated app?

Yes, with the same filter on the return value. useActionState wires the action's return into local component state, which is client state. Whatever the action returns is now in the client bundle. Keep the return type minimal (success flags, pseudonymous IDs, timestamps) and do not return database entities.

Do server components automatically strip sensitive fields from props?

No. Next.js serializes whatever you pass as a prop to a client component, field by field. There is no built-in filter. If you pass a Postgres row, the whole row serializes to the browser. The explicit view-type pattern is the enforcement mechanism.

What about third-party client-side libraries that receive member data as props?

The same rule applies. Any data passed to a third-party client component (analytics SDK, chat widget, error tracker) crosses the boundary into code you do not control. For a regulated app, pass only the minimum the third-party library needs, and confirm the library's data handling is BAA-covered if PHI is involved at all. The safer default is to pass pseudonymous identifiers and keep the PHI server-side.

Sources and specifics

  • Next.js 15 App Router surfaces: Server Components, Client Components, Server Actions, Route Handlers; documented in the Next.js official docs at nextjs.org/docs.
  • Next.js 16 changes: middleware renamed to proxy; params, searchParams, cookies(), headers(), draftMode() are async; verified against local docs bundle as of April 2026.
  • HIPAA Security Rule references: 45 CFR 164.312(a) access control, 45 CFR 164.502(b) minimum necessary.
  • Code examples target Next.js 14 or 15 with TypeScript strict mode; syntax is forward-compatible with Next.js 16 with the noted async changes.
  • Patterns observed across multiple regulated Next.js engagements, 2024-2026; no client-identifying infrastructure details, record counts, or internal class names are included.
  • Nothing in this post is legal advice. Engage privacy counsel and a qualified compliance reviewer before applying these patterns in production.

// 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