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.
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.
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.
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.
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.
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.
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.
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.
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?
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.