Package Runtime Boundaries
When a shared package is consumed by Next.js, mixing browser-only code (React hooks, window) with server-only code (next/headers, filesystem, secrets) in the same entry point causes runtime crashes that TypeScript cannot catch at build time.
This guide defines how we split packages into shared, client, and server entry points to enforce safe boundaries.
Why This Matters
Next.js uses React Server Components by default. When a server component imports a package that re-exports a "use client" hook alongside a server utility, the bundler cannot tree-shake correctly and you get errors like:
Error: `useHook` is not allowed in Server Components.or:
Error: `next/headers` can only be used in a Server Component.These errors only appear at runtime, not during type-checking or build. The split strategy catches them at the import boundary instead.
Decision Tree
Use this to classify every module in a package:
If a package has no client or server code, it only needs the default entry point (.). Skip the rest of this guide.
Folder Structure
Every package with client or server code follows this layout:
my-package/
βββ src/
β βββ shared/ # Directive-free β runs everywhere
β β βββ types.ts
β β βββ utils.ts
β βββ client/ # Browser & React only
β β βββ hooks.ts
β β βββ widget.tsx
β βββ server/ # Node.js & Next.js server only
β β βββ locale-cookie.ts
β βββ index.ts # Re-exports shared/*
β βββ client.ts # Re-exports client/* (with directive)
β βββ server.ts # Re-exports server/* (with directive)
βββ tsdown.config.ts
βββ package.jsonEntry Points
Each entry file re-exports only its own folder. The directive must be the first line of the file.
shared (index.ts)
// src/index.ts β no directive needed
export * from "./shared/types.js"
export * from "./shared/utils.js"No directive β shared code is environment-agnostic.
The directive ("use client" / "use server") must be at the top of the entry file. It does not reliably survive bundling if it only appears in a nested module.
Package Exports
Use explicit exports in package.json so consumers cannot accidentally import the wrong entry:
{
"exports": {
".": {
"types": "./build/index.d.ts",
"import": "./build/index.js"
},
"./client": {
"types": "./build/client.d.ts",
"import": "./build/client.js"
},
"./server": {
"types": "./build/server.d.ts",
"import": "./build/server.js"
},
"react-server": "./build/server.js"
}
}The react-server condition lets the React Server Components bundler resolve the correct entry automatically.
Build Config (tsdown)
Each package uses a local tsdown.config.ts with one entry per boundary:
// tsdown.config.ts
import { defineConfig } from "tsdown"
export default defineConfig({
sourcemap: true,
clean: true,
entry: {
index: "src/index.ts",
client: "src/client.ts",
server: "src/server.ts",
},
dts: {
compilerOptions: { composite: false },
},
platform: "neutral",
format: ["cjs", "esm"],
outExtensions: ({ format }) => ({
js: format === "cjs" ? ".cjs" : ".js",
dts: ".d.ts",
}),
outDir: "build",
})Omit client or server from entry if the package doesnβt need them.
Biome Enforcement
We enforce boundary rules via Biomeβs noRestrictedImports with scoped overrides:
| Source folder | Cannot import from |
|---|---|
src/shared/** | client/*, server/* |
src/client/** | server/* |
src/server/** | client/* |
This turns runtime boundary violations into lint errors that are caught before code is committed.
Setting Up a New Package
Classify your modules
Walk through the decision tree for every module in the package. Move each file into the matching shared/, client/, or server/ folder.
Create entry files
Add index.ts, client.ts, and/or server.ts at the src/ root. Each file re-exports only its corresponding folder. Add the directive ("use client" / "use server") as the first line of client and server entries.
Configure package.json exports
Add explicit exports for ., ./client, and ./server. Include the react-server condition if the package has a server entry point.
Set up tsdown
Create a tsdown.config.ts with entries matching your entry files. Omit any entries you donβt need.
Build and verify
pnpm --filter <pkg> buildImport the package from both a client component and a server component in Next.js to verify no runtime boundary errors occur.