open atlas
↑ Back to track
React patterns, senior RXP · 10 · 03

server-only and data flowing down

The server/client boundary is also a security boundary: mark secret and data-access modules with import "server-only" so a client import fails the build, fetch on the server, and pass only plain serializable data down to interactive client leaves.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

A Server Component can await db.query(...), read process.env.STRIPE_SECRET_KEY, and call an internal service with an admin token — and none of that code ships to the browser. That is the whole point of the server/client boundary: some of your tree runs where the secrets live, and the rest runs on a stranger’s laptop.

The danger is that the boundary is enforced by a single directive ("use client") and a few import rules, and one careless import drags a database driver — or a private key — straight into the client bundle. This lesson is about treating that boundary as what it really is: a security boundary, with a build-time guard (import "server-only") so a violation fails CI instead of leaking a credential to every visitor.

Goal

After this lesson you can mark a secret or data-access module with import "server-only" so any client-side import of it fails at build time; structure a feature as a Server Component that fetches data and passes plain, serializable data down to an interactive client child; explain why the server/client split is a security boundary and not just a performance one; and recognize the two failure modes that leak credentials — a NEXT_PUBLIC_-prefixed secret, and a direct db/secret import reaching a client component.

1

import "server-only" turns a leak into a build error — adopt it for every module that touches a secret or a data source. The server-only package exports nothing useful; its job is to fail the bundler if the module ends up in a client graph. Put it at the top of anything that reads env secrets, opens a DB connection, or holds a private key.

// lib/db.ts
import "server-only"; // ← if a client component imports this, the build fails

import { Pool } from "pg";

// Never reachable from the browser bundle: the import above guarantees it.
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function getUser(id: string) {
  const { rows } = await pool.query(
    "select id, name, plan from users where id = $1",
    [id],
  );
  return rows[0] ?? null;
}

Without the guard, this module works until the day someone imports getUser (or a helper that transitively pulls it in) from a "use client" file. Then the pg driver, the connection string, and the query all get bundled and served. server-only makes that mistake un-shippable.

2

Fetch on the server; the Server Component is the data boundary. A Server Component is async, runs only on the server, and never re-renders on the client — so it is exactly where data access belongs. It calls the server-only module directly and produces the markup for the page.

// app/users/[id]/page.tsx  — a Server Component (no "use client")
import { getUser } from "~/lib/db";
import { PlanBadge } from "./PlanBadge"; // a client leaf, below

export default async function UserPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const user = await getUser(id); // runs on the server, with the secret
  if (!user) return <p>Not found.</p>;

  // Only the fields the UI needs cross the boundary — not the whole row,
  // not the connection, not the token.
  return (
    <main>
      <h1>{user.name}</h1>
      <PlanBadge name={user.name} plan={user.plan} />
    </main>
  );
}

The Server Component holds the dangerous capabilities (the pool, the env var) and hands the client only what it must render. Nothing about getUser’s internals is observable from the browser.

3

Pass plain, serializable data down — props are the boundary’s API. Everything you give a client child is serialized and sent over the wire. So pass primitives, plain objects, and arrays — not class instances, not the DB client, not a function that closes over a secret. The client component is a leaf that receives finished data and adds interactivity.

// app/users/[id]/PlanBadge.tsx
"use client";

import { useState } from "react";

// Receives only what it renders — already fetched, already trimmed.
export function PlanBadge({ name, plan }: { name: string; plan: string }) {
  const [open, setOpen] = useState(false);
  return (
    <button onClick={() => setOpen((v) => !v)}>
      {plan}
      {open && <span> — {name} is on the {plan} plan</span>}
    </button>
  );
}

The mental model: secrets and access stay up at the server root; plain data flows down to interactive leaves. A client component is allowed to be interactive precisely because it never had a capability worth protecting — it only ever saw the trimmed result.

4

The failure mode: a NEXT_PUBLIC_ secret or a direct db import in a client component — a real credential exposure. Two specific mistakes leak production secrets to every visitor, and both are easy to make:

// FAILURE 1 — the prefix that ships the secret to the browser.
// Anything NEXT_PUBLIC_* is INLINED into client JS at build time.
"use client";
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!); // 🔴 now public
// "It worked" because the value was reachable — that's the bug. View source
// reveals it. The fix is NEVER prefix a secret with NEXT_PUBLIC_.

// FAILURE 2 — pulling a server module into a client graph.
"use client";
import { getUser } from "~/lib/db"; // 🔴 drags pg + DATABASE_URL into the bundle
//   — if lib/db.ts has `import "server-only"`, this line fails the BUILD instead
//   of silently shipping the driver and connection string. That guard is the point.

NEXT_PUBLIC_ is for values that are meant to be public (a published API base URL, an analytics key designed for client use). A secret key with that prefix is not “configured” — it is published. And server-only exists precisely so FAILURE 2 is a red CI line, not a postmortem. When not to fetch on the server at all: data that is per-interaction and client-owned (a debounced search the user types) still belongs in the client — but it should call a route handler / server action, never the DB directly.

Worked example

A user-settings panel: pull the secret out of the client, push the data down. The before is a single "use client" component that fetches with a key inlined into the bundle — it renders, it “works”, and it ships a live Stripe key to every browser.

// BEFORE — secret in the client, db logic in the client. 🔴
"use client";
import { useEffect, useState } from "react";
import Stripe from "stripe";

const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!); // inlined!

export function Billing({ userId }: { userId: string }) {
  const [sub, setSub] = useState<any>(null);
  useEffect(() => {
    stripe.subscriptions.list({ customer: userId }).then((r) => setSub(r.data[0]));
  }, [userId]);
  return <p>Plan: {sub?.status ?? "…"}</p>;
}

The after splits along the boundary. A server-only module owns the key, a Server Component fetches with it, and a tiny client leaf receives plain fields and adds the interactive bit.

// lib/billing.ts
import "server-only";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // no NEXT_PUBLIC_

export async function getPlan(userId: string) {
  const { data } = await stripe.subscriptions.list({ customer: userId });
  return { status: data[0]?.status ?? "none" }; // plain object, nothing secret
}

// app/billing/page.tsx — Server Component
import { getPlan } from "~/lib/billing";
import { PlanToggle } from "./PlanToggle";
export default async function Billing({ userId }: { userId: string }) {
  const plan = await getPlan(userId);          // secret stays on the server
  return <PlanToggle status={plan.status} />;  // only "active"/"none" crosses
}

// app/billing/PlanToggle.tsx — client leaf
("use client");
import { useState } from "react";
export function PlanToggle({ status }: { status: string }) {
  const [show, setShow] = useState(false);
  return <button onClick={() => setShow((v) => !v)}>{show ? status : "details"}</button>;
}

What changed isn’t structure for its own sake: the Stripe key now exists only in server memory, the server-only import makes a regression a build failure, and the browser receives a single string ("active"). Same UI, but the credential never left the server.

Why this works

Why is import "server-only" worth a dependency when “just don’t import it in the client” is the rule anyway? Because the rule is invisible and transitive. A util like formatPlan imports getPlan, a client component imports formatPlan for one helper, and now the DB driver is in the bundle — no one wrote import { getUser } in a "use client" file, so review misses it. server-only makes the graph the enforcer: if a server-only module is reachable from any client entry, the bundler stops. It converts a discipline you have to remember on every PR into an invariant the build checks for free.

Common mistake

Treating NEXT_PUBLIC_ as “the way to configure the frontend” and reaching for it whenever a client component needs a value. The prefix has exactly one meaning: inline this into the client bundle and serve it to everyone. That is correct for a public API base URL and catastrophic for a secret. The tell is asking “does the client need to send this to a third party?” — if yes (a Stripe secret key, a DB password, an admin token), it can never be NEXT_PUBLIC_; the request must originate on the server. A client may hold the publishable key, never the secret one.

Check yourself
Quiz

A client component needs to show a user's subscription status. A teammate adds STRIPE_SECRET_KEY as NEXT_PUBLIC_STRIPE_SECRET_KEY so the client can call Stripe directly, and it works in dev. Why is this a security failure, and what's the correct shape?

Recap

The server/client boundary is a security boundary, not just a performance one: above it run the secrets and data access, below it runs untrusted-environment UI. Mark every secret or data-access module with import "server-only" so a client import fails the build instead of silently bundling a driver or a key. Do the fetching in the Server Component, where the secret lives, and pass only plain, serializable data down to interactive client leaves — props are the boundary’s API, so never send the DB client, a function closing over a secret, or the whole row. The two failure modes both leak real credentials: a NEXT_PUBLIC_-prefixed secret (inlined into client JS for everyone to read) and a direct db/secret import in a client component (which server-only turns into a red build line). The senior reflex: keep capabilities up, let data flow down, and let the build — not code review — be the thing that catches the leak.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.