
Passing Data from Server Components to Client Components Without Leaking Secrets
Server Components make it easy to forget where the server ends. You query the database right inside a component, the code never ships to the browser, and it feels like everything in that file is private. Then you pass the query result to a Client Component as a prop, and every field on that object, including the password hash, the internal notes, and the Stripe customer ID, is now sitting in the page source.
This isn't a Next.js bug. It's how the boundary works: props passed to Client Components are serialized and sent to the browser so those components can render and hydrate there. The code stays on the server; the data you hand across doesn't.
This post shows exactly where data crosses into the browser, the common ways secrets leak, and a set of habits (narrow props, DTOs, a data access layer, and React's taint APIs) that make leaks hard to write by accident.
Where Props Go
When a Server Component renders a Client Component, React serializes the props into the RSC payload. On first load, that payload is embedded in the HTML as inline script data. On client navigations, it's fetched as a separate response. Either way, it's readable by anyone who opens the page.
Here's the leak in its simplest form:
// app/profile/[username]/page.tsx
import { db } from "@/lib/db";
import { ProfileCard } from "./profile-card";
export default async function ProfilePage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = await params;
const user = await db.user.findUnique({ where: { username } });
// Every column on `user` is sent to the browser
return <ProfileCard user={user} />;
}
// app/profile/[username]/profile-card.tsx
"use client";
import { useState } from "react";
export function ProfileCard({ user }: { user: any }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<h1>{user.displayName}</h1>
<button onClick={() => setExpanded(!expanded)}>More</button>
{expanded && <p>{user.bio}</p>}
</div>
);
}
ProfileCard only renders displayName and bio. It doesn't matter. The whole user object was serialized, so email, passwordHash, role, and anything else on the row are in the payload.
Compare that with a Server Component rendering the same fields directly:
export default async function ProfilePage(/* ... */) {
const user = await db.user.findUnique({ where: { username } });
return <h1>{user?.displayName}</h1>;
}
Here only the rendered <h1> text reaches the browser. The rest of the object stays on the server, because nothing passed it across. Rendering a field in a Server Component sends the output. Passing an object to a Client Component sends the object.
How to See What You're Sending
Before fixing anything, check what's leaving the server. A quick test:
- Put a recognizable canary in a sensitive field in your dev database, like
passwordHash: "CANARY_9f2c". - Load the page and view the source, or check the RSC responses in the browser's Network tab during a client navigation.
- Search for the canary.
curl -s http://localhost:3000/profile/ada | grep -o "CANARY_9f2c"
If it shows up, something is passing that field across the boundary. This is worth turning into an automated test for critical pages.
The Main Leak Vectors
Props aren't the only path. These are the places data can leave the server:
| Vector | What leaks | Fix |
|---|---|---|
| Props to Client Components | Every field of the objects you pass | Pass narrow, purpose-built objects |
| Promises passed to Client Components | Whatever the promise resolves to | Resolve to a DTO, not a raw record |
| Server Action return values | Whatever you return | Return only what the UI needs |
| Context providers | The value you give the provider | Pass a minimal object into the provider |
NEXT_PUBLIC_ environment variables | The variable's value, inlined in the bundle | Never prefix secrets |
| Modules imported by Client Components | Any code, constants, or keys in them | Keep secrets in server-only modules |
The first four are all the same problem in different shapes: serialized data crossing the boundary. Let's go through how to prevent it.
Habit 1: Narrow Props on Client Components
Start at the receiving end. A Client Component's props type is a contract for what it needs. Make it exact:
// app/profile/[username]/profile-card.tsx
"use client";
import { useState } from "react";
type ProfileCardProps = {
displayName: string;
bio: string | null;
avatarUrl: string | null;
};
export function ProfileCard({ displayName, bio, avatarUrl }: ProfileCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="flex gap-4">
{avatarUrl && (
<img src={avatarUrl} alt="" className="h-12 w-12 rounded-full" />
)}
<div>
<h1 className="text-xl font-bold">{displayName}</h1>
<button onClick={() => setExpanded((e) => !e)}>
{expanded ? "Less" : "More"}
</button>
{expanded && bio && <p>{bio}</p>}
</div>
</div>
);
}
// app/profile/[username]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { ProfileCard } from "./profile-card";
export default async function ProfilePage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = await params;
const user = await db.user.findUnique({ where: { username } });
if (!user) notFound();
return (
<ProfileCard
displayName={user.displayName}
bio={user.bio}
avatarUrl={user.avatarUrl}
/>
);
}
Now TypeScript stops you from passing the whole object, and anyone reading ProfileCard can see exactly what data reaches the browser. Avoid props like user: User where User is your database model type. That signature invites passing the full record.
Spreading is the other trap. <ProfileCard {...user} /> with a narrow props type still sends every field at runtime; TypeScript allows extra properties when spreading. Pass fields explicitly.
Habit 2: Return DTOs from a Data Access Layer
Narrow props help, but the stronger fix is to never have the sensitive data in the rendering code to begin with. The Next.js docs recommend a data access layer (DAL): a server-only module that handles queries, performs authorization, and returns Data Transfer Objects (DTOs) shaped for the UI.
// data/users.ts
import "server-only";
import { cache } from "react";
import { db } from "@/lib/db";
import { getCurrentUser } from "./auth";
export type PublicProfile = {
username: string;
displayName: string;
bio: string | null;
avatarUrl: string | null;
email: string | null; // only populated for the owner or admins
};
export const getPublicProfile = cache(
async (username: string): Promise<PublicProfile | null> => {
const [user, viewer] = await Promise.all([
db.user.findUnique({
where: { username },
select: {
id: true,
username: true,
displayName: true,
bio: true,
avatarUrl: true,
email: true,
},
}),
getCurrentUser(),
]);
if (!user) return null;
const canSeeEmail = viewer?.id === user.id || viewer?.role === "admin";
return {
username: user.username,
displayName: user.displayName,
bio: user.bio,
avatarUrl: user.avatarUrl,
email: canSeeEmail ? user.email : null,
};
},
);
A few things are happening here:
selectlimits the query. The password hash never leaves the database, so it can't leak later.- Authorization lives with the data. Whether
emailis included is decided in one place, not in every component that renders a profile. - The return type is a plain object built field by field, safe to pass to a Client Component.
cachefrom React deduplicates calls within a single request, so multiple components can callgetPublicProfilewithout extra queries.import "server-only"makes the build fail if a Client Component ever imports this module. That package has its own post: using server-only to keep sensitive code off the client.
Pages then use the DTO and pass it on without worry:
// app/profile/[username]/page.tsx
import { notFound } from "next/navigation";
import { getPublicProfile } from "@/data/users";
import { ProfileCard } from "./profile-card";
export default async function ProfilePage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = await params;
const profile = await getPublicProfile(username);
if (!profile) notFound();
return (
<ProfileCard
displayName={profile.displayName}
bio={profile.bio}
avatarUrl={profile.avatarUrl}
/>
);
}
Keep process.env access inside the DAL too. If only that layer reads secrets, the rest of your app can't leak what it never touches.
Habit 3: Treat Server Action Returns as Public
Server Actions are called from the browser, so their return values are serialized back to the browser. Returning the result of an ORM call is the action equivalent of passing a full record as props:
// app/settings/actions.ts
"use server";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/data/auth";
export async function updateDisplayName(formData: FormData) {
const viewer = await getCurrentUser();
if (!viewer) return { ok: false as const, error: "Not signed in" };
const displayName = String(formData.get("displayName") ?? "").trim();
if (displayName.length < 2) {
return { ok: false as const, error: "Name is too short" };
}
await db.user.update({
where: { id: viewer.id },
data: { displayName },
});
// Not: return db.user.update(...)
return { ok: true as const, displayName };
}
Return a small, explicit result. The same goes for errors: return a user-facing message, not the caught exception, which may include SQL or connection details.
Closures Are Encrypted, But Don't Rely on It
When you define a Server Action inline inside a Server Component, any variables it closes over are sent to the client and back when the action runs. Next.js encrypts those values with a per-build key, so they aren't readable in the browser. That's a safety net, not a design. Prefer passing an ID and looking up the data inside the action rather than capturing sensitive values in a closure.
Habit 4: Be Careful with Promises and Context
Two newer patterns move data across the boundary less visibly.
Streaming a promise. You can start a fetch in a Server Component and pass the promise to a Client Component, which reads it with use(). The resolved value is serialized just like a prop. Make sure the promise resolves to a DTO:
// app/orders/page.tsx
import { Suspense } from "react";
import { getOrderSummaries } from "@/data/orders";
import { OrderTable } from "./order-table";
export default function OrdersPage() {
const orders = getOrderSummaries(); // returns Promise<OrderSummary[]>, not raw rows
return (
<Suspense fallback={<p>Loading orders...</p>}>
<OrderTable ordersPromise={orders} />
</Suspense>
);
}
// app/orders/order-table.tsx
"use client";
import { use } from "react";
import type { OrderSummary } from "@/data/orders-types";
export function OrderTable({
ordersPromise,
}: {
ordersPromise: Promise<OrderSummary[]>;
}) {
const orders = use(ordersPromise);
return (
<table>
<tbody>
{orders.map((o) => (
<tr key={o.id}>
<td>{o.number}</td>
<td>{o.status}</td>
<td>{o.totalFormatted}</td>
</tr>
))}
</tbody>
</table>
);
}
Notice the Client Component imports the OrderSummary type from a separate types file. Type-only imports are erased at compile time, but keeping shared types out of the server-only DAL module keeps things clear and avoids tooling confusion.
Context providers. A provider is a Client Component, so its value comes from props. Passing a full session or user object into a SessionProvider from your root layout sends it to every page. Pass a minimal object: an ID, a display name, maybe a role.
Habit 5: Add Taint as a Backstop
React has two experimental APIs that make React throw if specific data tries to cross into a Client Component. Enable them in Next.js with experimental.taint:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
taint: true,
},
};
export default nextConfig;
Then mark objects or values that must never reach the client:
// data/users.ts
import "server-only";
import {
experimental_taintObjectReference,
experimental_taintUniqueValue,
} from "react";
import { db } from "@/lib/db";
export async function getUserRecord(id: string) {
const user = await db.user.findUniqueOrThrow({ where: { id } });
experimental_taintObjectReference(
"Do not pass the full user record to the client. Select fields instead.",
user,
);
experimental_taintUniqueValue(
"Do not pass the API token to the client.",
user,
user.apiToken,
);
return user;
}
If someone later writes <ProfileCard user={user} /> or passes user.apiToken as a prop, React throws with your message instead of quietly shipping the data.
Know the limits before leaning on this:
- It's experimental, and enabling the flag switches the
appdirectory to React's experimental channel. Some teams won't want that in production. - It tracks references.
{ ...user }creates a new, untainted object. - It doesn't track derived values. A new string built from
user.apiToken, such as a prefixed copy, is untainted and passes through.
Treat taint as an alarm, not a lock. DTOs and narrow props are what actually keep data on the server.
Environment Variables
Next.js only exposes environment variables prefixed with NEXT_PUBLIC_ to the browser, inlining their values into the client bundle at build time. Unprefixed variables are replaced with an empty value in client code. So:
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYis fine. It's designed to be public.NEXT_PUBLIC_STRIPE_SECRET_KEYis a leak, every time.
The less obvious risk is passing a server-only variable through props: <Map apiKey={process.env.MAPS_SERVER_KEY} />. The prefix rule doesn't help there. If a third-party key must be used in the browser, use a key that's restricted for browser use (by domain or scope), not your server key.
A Pre-Merge Checklist
When reviewing code that touches the boundary, I look for:
- Client Component props typed with database model types or
any. - Spread props (
{...record}) into Client Components. - Server Actions that
returnORM results or caught errors. - Promises passed to Client Components that resolve to raw query results.
- Providers in layouts receiving full session or user objects.
process.envread outside the data access layer.- Any
NEXT_PUBLIC_variable with "secret", "private", or "token" in its name.
Conclusion
The rule is short: anything you pass to a Client Component, return from a Server Action, or resolve in a promise sent to the client is public. Server Components keep your code private, not your props.
Make that safe by default. Give Client Components narrow, explicit props. Fetch through a server-only data access layer that selects only needed columns, checks authorization, and returns plain DTOs. Keep Server Action results small. Add taint as an extra alarm if your team is comfortable with the experimental channel. And verify with a canary that what you think stays on the server actually does. For the underlying model of what crosses the boundary and why, see the "use client" directive explained.


