Lesson 71 of 81
Passing a whole user record from a Server Component to a Client Component is one careless refactor away from leaking a password hash to the browser. React's experimental taint APIs make that mistake throw instead of ship.
12 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
In the App Router, any value you pass from a Server Component into a Client Component gets serialized and sent to the browser as part of the page's data. It's easy to fetch a full database row — including columns that were never meant to leave the server, like a password hash or an internal API key — and pass the whole object down "just to read one field," accidentally shipping the rest of it to every visitor's browser.
React's experimental taint APIs, exported from react (experimental_taintObjectReference and experimental_taintUniqueValue), let you mark a specific object or string value as forbidden to serialize across the server-client boundary. Once tainted, passing that exact value into a Client Component throws a runtime error instead of silently shipping it. This is a backstop, not your primary defense — the first line of defense is still to never fetch more than you need and to shape your data-access functions to return only public fields.
// lib/get-user.ts
import { experimental_taintObjectReference as taintObjectReference } from "react";
export async function getUser(id: string) {
const user = await db.user.findUnique({ where: { id } });
taintObjectReference(
"Do not pass the full user object to a Client Component — pick the fields you need instead.",
user
);
return user;
}
// app/profile/avatar.tsx
"use client";
export function Avatar({ user }: { user: { name: string; avatarUrl: string } }) {
return <img src={user.avatarUrl} alt={user.name} />;
}
// app/profile/page.tsx — the mistake this catches
import { getUser } from "@/lib/get-user";
import { Avatar } from "./avatar";
export default async function ProfilePage() {
const user = await getUser("u_123");
return <Avatar user={user} />; // throws: user was tainted
}
Because user — the exact object reference returned by getUser — was tainted, passing it into <Avatar> (a Client Component) throws immediately, with the message you wrote. The fix is to pass only what the client actually needs: <Avatar user={{ name: user.name, avatarUrl: user.avatarUrl }} /> builds a new object that was never tainted, so it passes through fine.
taintObjectReference marks one specific object reference. Spreading it ({ ...user }) or destructuring individual fields into a new object produces values that were never tainted — the API catches "pass this exact object," not "pass anything derived from it." You still have to be deliberate about what you copy out.Everything about what can and can't cross from a Server Component to a Client Component is normally an unenforced discipline — you're trusted to only pass down what's safe. Taint APIs turn one specific class of that discipline into a runtime check, the same way TypeScript turns "please pass the right shape" into a compile-time check for types — except this one is checking a security property at runtime, not a type at build time, because the danger here is a serialized value leaving the server, which no type system can see.