Preserving 'use client' Directives in Builds
Keep 'use client' and 'use server' at the top of emitted modules: why bundlers move or drop them, per-file output, and verifying the directive survived packing.
A component library builds, publishes, and every consumer using a server-components framework gets the same failure:
Error: useState only works in Client Components. Add the "use client" directive
at the top of the file to use it.
at Button (node_modules/@scope/ui/dist/index.mjs:214:32)
The directive is in the source. It is not in the output, because the build concatenated several modules into one file and only one of them could keep the first-statement position that makes a directive a directive.
Root Cause
"use client" and "use server" are directive prologues: string literals that carry meaning only when they appear before any other statement in a module. That definition is inherently per-module.
A bundler that merges modules destroys the boundary the directive depended on. The first module’s prologue may survive at the top of the output; every other module’s becomes a string expression in the middle of a file, semantically meaningless, and typically removed by the minifier as dead code.
The fix is therefore not a flag that “preserves directives” so much as a build shape that preserves modules — one output file per input module, each keeping its own first line.
Minimal Reproduction
// src/Button.tsx
"use client";
import { useState } from "react";
export function Button({ label }: { label: string }) {
const [pressed, setPressed] = useState(false);
return <button onClick={() => setPressed(!pressed)}>{label}</button>;
}
// tsup.config.ts — bundling into one file
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
});
npx tsup && head -3 dist/index.mjs
import { useState } from "react";
// src/Button.tsx
function Button({ label }) {
The directive is gone, and the framework has no way to know the module is a client boundary.
Why the Boundary Matters
Step-by-Step Fix
1. Emit one output file per input module
export default defineConfig({
- entry: ["src/index.ts"],
+ entry: ["src/index.ts", "src/Button.tsx", "src/Modal.tsx", "src/format.ts"],
format: ["esm"],
+ bundle: false,
+ splitting: false,
});
Where the module list grows, generate it from the filesystem rather than maintaining it by hand:
import { globSync } from "node:fs";
export default defineConfig({
entry: globSync("src/**/*.{ts,tsx}", { exclude: (f) => f.includes(".test.") }),
format: ["esm"],
bundle: false,
dts: true,
});
npx tsup && head -1 dist/Button.mjs
Expected output: "use client";
2. Keep the directive in the prologue position in source
- import { useState } from "react";
"use client";
+ import { useState } from "react";
A directive after an import is an ordinary expression statement with no effect. This is a common accidental cause, particularly after an editor auto-import inserts a line above it.
3. Give client modules their own subpath exports
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" },
"./Button": { "types": "./dist/Button.d.ts", "import": "./dist/Button.mjs", "default": "./dist/Button.mjs" },
"./Modal": { "types": "./dist/Modal.d.ts", "import": "./dist/Modal.mjs", "default": "./dist/Modal.mjs" }
}
}
A subpath per boundary lets a consumer import exactly the module they need, and makes the client-server split visible in their import statements rather than buried inside a barrel.
4. Verify against the packed tarball, not the working directory
for f in dist/Button.mjs dist/Modal.mjs; do
first=$(head -1 "$f" | tr -d '\r')
case "$first" in
'"use client";'|"'use client';") echo "ok: $f" ;;
*) echo "FAIL: $f starts with $first"; exit 1 ;;
esac
done
npm pack --dry-run | grep -E "Button\.mjs|Modal\.mjs"
HAZARD PREVENTION
Symptom: The directive is present in
dist/, and consumers still get the client-component error.Root cause: A minifier or a post-processing step reordered the file, or a license banner was inserted as a statement rather than a comment, displacing the directive from the prologue.
Fix: Disable minification for a package of this shape — it saves little for a component library and breaks directives, source maps and readability at once — and check the first line as part of the build rather than after a bug report.
Verification
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
tar -xzf "$d"/*.tgz -C "$d"
find "$d/package/dist" -name '*.mjs' -exec sh -c '
for f; do
head -1 "$f" | grep -q "use client\|use server" && echo "directive: $f"
done
' sh {} +
Expected: one directive: line per module that carries one, listed from inside the packed tarball rather than the build directory.
Edge Cases / Gotchas
- A re-exporting barrel does not inherit the directive.
export { Button } from "./Button.js"in an undirectedindex.mjsis a server module re-exporting a client one, which frameworks handle — but a consumer importing through the barrel may pull in more than they expect. .d.tsfiles do not carry directives, and do not need to. The boundary is a runtime concern; declaration output is unaffected.- Per-file output changes tree-shaking characteristics. Unbundled modules keep import statements between them, which is usually better for consumers but produces more files and more resolution work.
- CommonJS output rarely makes sense here. Server-components frameworks are ESM-first, and a CommonJS build with directives is unlikely to be consumed correctly.
- Directives are case- and quote-sensitive.
"Use client"or a template literal is not a directive; only a plain string literal in the prologue counts.
The general rule for any package participating in a server-components framework: preserve module boundaries, and check the first line of every emitted file.
Frequently Asked Questions
Why do bundlers drop the directive?
A prologue is only meaningful as a module’s first statement. Concatenation leaves room for one, so the rest become ordinary expressions and are removed as dead code.
Does the directive have to be the very first line?
It must be in the directive prologue — comments are fine above it, but a single import placed first makes it an ordinary expression with no effect.
Can a package mix client and server modules?
Yes, and that is the normal shape for a component library, as long as the build preserves per-file boundaries.
Do I need separate exports subpaths for client modules?
Not strictly, but a subpath per boundary lets consumers import exactly what they need and makes the split visible in their imports.
How do I know the directive survived publishing?
Check the first line of each module inside the packed tarball rather than in your working directory — the build can be correct while files excludes the module.
Related
- Modern Build Tools: tsup, Rollup, and esbuild — configuring per-file output in each tool.
- Subpath Exports and Deep Import Patterns — exposing one entry point per boundary.
- Eliminating Barrel File Anti-Patterns — why the barrel that re-exports client modules deserves attention too.