Type something to search...
Passing Data from Server Components to Client Components Without Leaking Secrets

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:

  1. Put a recognizable canary in a sensitive field in your dev database, like passwordHash: "CANARY_9f2c".
  2. Load the page and view the source, or check the RSC responses in the browser's Network tab during a client navigation.
  3. 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:

VectorWhat leaksFix
Props to Client ComponentsEvery field of the objects you passPass narrow, purpose-built objects
Promises passed to Client ComponentsWhatever the promise resolves toResolve to a DTO, not a raw record
Server Action return valuesWhatever you returnReturn only what the UI needs
Context providersThe value you give the providerPass a minimal object into the provider
NEXT_PUBLIC_ environment variablesThe variable's value, inlined in the bundleNever prefix secrets
Modules imported by Client ComponentsAny code, constants, or keys in themKeep 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:

  • select limits the query. The password hash never leaves the database, so it can't leak later.
  • Authorization lives with the data. Whether email is 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.
  • cache from React deduplicates calls within a single request, so multiple components can call getPublicProfile without 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 app directory 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_KEY is fine. It's designed to be public.
  • NEXT_PUBLIC_STRIPE_SECRET_KEY is 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 return ORM 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.env read 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.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading