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

Bundled versus per-file output Bundling three modules into one file leaves room for only one directive prologue, so the other two are lost. Emitting one file per module keeps each directive in the position that gives it meaning. A directive belongs to a module, not to a package bundled into one file "use client" — only one survives Button, Modal, useTheme merged the framework sees one module with the wrong boundary one file per module Button.mjs — "use client" intact Modal.mjs — "use client" intact format.mjs — server-safe, no directive every boundary preserved

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

Two checks that must both pass One check confirms the directive is the first line of the emitted module. The other confirms that module is present in the packed tarball, since a correct build can still be excluded by the files array. A correct build can still ship a broken package check 1: the first line head -1 dist/Button.mjs must be the directive catches bundling and ordering check 2: it ships npm pack --dry-run the module must be listed catches the files array
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 undirected index.mjs is 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.ts files 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.



↑ Back to Modern Build Tools: tsup, Rollup, and esbuild