Skip to the solution
MediumFrontend10 min read

How do you keep secrets and server code out of the client bundle in Next.js (server-only, taint, Data Access Layer)?

282 views

The problem

Question presented to candidate: "A security review found our Stripe secret key in the HTML of one page. In a Next.js App Router app, how can secrets and server-only code leak to the browser, and what layers would you put in place so it cannot happen again?"

What a strong answer should cover:

  • The leak paths: props passed from a Server Component to a Client Component are serialized into the RSC payload and HTML; modules imported by a Client Component are bundled for the browser; a Client Component also renders on the server during prerendering, where process.env holds real values.
  • Environment variables: only NEXT_PUBLIC_ values are inlined into the browser bundle; everything else is server-only, but a Client Component that reads one can still put it into server-rendered HTML.
  • import "server-only" turns "a Client Component imported this module" into a build error.
  • A Data Access Layer: the only code that touches the database and secrets, does the authorization, and returns minimal DTOs instead of whole rows.
  • React taint APIs (experimental.taint): experimental_taintObjectReference and experimental_taintUniqueValue make React throw if a marked object or value reaches a Client Component; they are a backstop, not the main defence (copies and derived values escape).

Clarifying questions expected:

  • "How did the key get there: a prop, a client import or an env var read in a client file?" β€” each path has a different fix.
  • "Is there a shared lib/ imported by both server and client code?" β€” the main place server-only belongs.

Code / implementation expected: Yes β€” a server-only DAL that returns a DTO, and a taint example.

  • nextjs
  • security
  • server-components
  • data-access-layer

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read Β· 8 sections

Read the code

Data Access Layer with server-only, DTOs and taint
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    taint: true, // enables React experimental_taint* APIs (and the React experimental channel for app/)
  },
};
export default nextConfig;

// lib/dal.ts
import "server-only";
import { experimental_taintObjectReference, experimental_taintUniqueValue } from "react";
import { queryUser } from "./db";

// Data Access Layer: the only place that touches the DB and process.env.
export async function getUserRow(id: string) {
  const row = await queryUser(id);
  experimental_taintObjectReference("Do not pass a whole user row to the client; pick fields in a DTO.", row);
  experimental_taintUniqueValue("Do not pass the session token to the client.", row, row.sessionToken);
  return row;
}

export type UserDTO = { name: string };
export async function getUserDTO(id: string): Promise<UserDTO> {
  const row = await getUserRow(id);
  return { name: row.name }; // only what the UI needs
}

export function getApiKey() {
  const key = process.env.API_SECRET_KEY!;
  experimental_taintUniqueValue("Do not pass the API key to the client.", globalThis, key);
  return key;
}

// app/dto/page.tsx
import { connection } from "next/server";
import { getUserDTO } from "@/lib/dal";
import Show from "../show";
export default async function Page() {
  await connection();
  const user = await getUserDTO("u1");
  return <Show user={user} />;
}
The mistakes the lab reproduced
// app/pay-button.tsx (build error: server-only module in a Client Component)
"use client";
import { chargeCard } from "@/lib/billing"; // mistake: server module imported into a Client Component

export default function PayButton() {
  return <button onClick={() => chargeCard(500)}>Pay</button>;
}

// app/leak/page.tsx (untainted secret passed as a prop: leaks)
import { connection } from "next/server";
import Show from "../show";
export default async function Page() {
  await connection();
  // Untainted secret passed as a prop: nothing stops this.
  return <Show apiKey={process.env.API_SECRET_KEY} />;
}

// app/env-client/env-client.tsx (secret read in a Client Component: in the SSR HTML)
"use client";
export default function EnvClient() {
  return (
    <p>
      public={process.env.NEXT_PUBLIC_ANALYTICS_ID ?? "undefined"} secret={process.env.API_SECRET_KEY ?? "undefined"}
    </p>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 82 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextMedium

next.config `reactCompiler` β€” should you enable the React Compiler?

Continue