Protected health information can cross a boundary through a prop, an action result, an API response, a cache, or a log. Start by listing the data that each caller needs and the systems allowed to handle it. Running code on the server is only part of that design.
The regulated platform case study gives the broader delivery context. This guide focuses on the application contracts and the checks to run before deploying them.
The surfaces to review
A Server Component can read data on the server and still expose it through rendered HTML or props serialized for a Client Component. Construct a minimal view before either output leaves the data access layer. Authentication needs a resource specific authorization check beside the query.
Treat Server Actions and Route Handlers as callable endpoints. Validate inputs, verify the caller, authorize the operation, and shape the result. A hidden button and an opaque action identifier cannot replace those checks.
Review caches by lifetime and storage boundary. React request memoization, persistent data caching, and a browser or CDN response cache are different mechanisms. Keep a member record outside shared caches unless a reviewed design establishes identity isolation, authorization, retention, invalidation, and the provider scope.
Logs and monitoring are additional recipients of data. Exclude sensitive payloads at the source and review capture defaults, traces, error objects, and query parameters. The production logging guide covers that part of the boundary.
Runtime selection determines which dependencies and platform behavior are available. Review the actual crypto library, database driver, secret handling, regions, and hosting terms. A Node setting can be required by a dependency without being a legal safeguard on its own.
Verification before release
Use fixtures for two different members. Verify that each can read and update only the intended resources, including calls made directly to the endpoints. Test the unauthenticated and denied paths as well as the successful UI flow.
Inspect serialized props, HTML, response JSON, logs, and cache behavior using those fixtures. A type declaration can document the intended result while the runtime object still contains extra fields. The test should inspect the actual output.
Keep the authorization and DTO logic close to the data access path so a new component does not need to recreate the same boundary. Review a schema change for its effect on every serialization and logging point.
Explicit response shapes at the boundary
Filter data on the server before serialization. A TypeScript type helps reviewers see the intended contract, but it does not remove extra properties at runtime. Construct the response from allowed fields and authorize the caller against the specific resource before reading it.
import "server-only";
type ProfileView = {
displayName: string;
memberSince: string;
};
export async function getProfileView(): Promise<ProfileView> {
const session = await currentMember();
if (!session) throw new Error("unauthorized");
const member = await db.member.findUnique({
where: { id: session.memberId },
select: { preferredName: true, firstName: true, createdAt: true },
});
if (!member) throw new Error("not found");
return {
displayName: member.preferredName ?? member.firstName,
memberSince: member.createdAt.toISOString(),
};
}
The database selection and constructed object both narrow the data. Passing a full row with as ProfileView would leave the extra fields on the object. The Next.js data security guide recommends a server data access layer, authorization checks, and minimal data transfer objects.
For a mutation, validate the input and return an acknowledgement. Derive the member identity from the verified session instead of accepting it as proof from the browser.
"use server";
export async function updateMemberPreferences(
input: unknown,
): Promise<{ ok: true; updatedAt: string }> {
const session = await currentMember();
if (!session) throw new Error("unauthorized");
const preferences = preferencesSchema.parse(input);
const updated = await db.member.update({
where: { id: session.memberId },
data: { preferences },
select: { updatedAt: true },
});
return { ok: true, updatedAt: updated.updatedAt.toISOString() };
}
These examples assume application implementations of currentMember, db, and preferencesSchema. An administrator acting on a different member needs an explicit authorization check for that resource and action.
A route handler has the same obligations. Await route parameters in current App Router code, authenticate, authorize the requested member, and construct the JSON response. Apply an appropriate response cache policy and review intermediary caching. Server execution alone does not establish the data handling boundary.
A route response can use the same explicit contract. This example assumes the same application session and database helpers as the snippets above.
import { NextResponse } from "next/server";
type DashboardResponse = {
displayName: string;
upcomingAppointmentCount: number;
lastActivityAt: string | null;
};
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params;
const session = await currentMember();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
if (session.memberId !== id) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
const member = await db.member.findUnique({
where: { id },
select: { preferredName: true, firstName: true, lastActivityAt: true },
});
if (!member) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
const upcomingAppointmentCount = await db.appointment.count({
where: { memberId: id, scheduledAt: { gte: new Date() } },
});
const body: DashboardResponse = {
displayName: member.preferredName ?? member.firstName,
upcomingAppointmentCount,
lastActivityAt: member.lastActivityAt?.toISOString() ?? null,
};
return NextResponse.json(body, {
headers: { "Cache-Control": "private, no-store" },
});
}
Current use cache cannot directly read cookies() or headers() inside its scope. Request scoped memoization and persistent caching are different mechanisms. Keep member data out of shared caches by default; review any exception against its identity keys, authorization, storage provider, expiry, and invalidation behavior. The current cache directive reference documents the request API restriction.
Runtime selection is a dependency and deployment decision. Node APIs require compatible runtime support. An edge runtime is not a legal category, and selecting Node does not make a deployment compliant. Check the hosting agreement, services, regions, logs, and data flows with the implementation's qualified reviewer.
