Skip to Content
FrontendPackage Runtime Boundaries

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.json

Entry Points

Each entry file re-exports only its own folder. The directive must be the first line of the file.

// 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 folderCannot 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> build

Import the package from both a client component and a server component in Next.js to verify no runtime boundary errors occur.

Last updated on