# Multi-Entry Package Architecture

Single-entry packages are easy to start with. Put everything in `src/index.ts`, export the public API, and move on. That works until one part of the package belongs on the server and another part belongs in the browser.

At that point, the package has more than one audience. Pretending it has one entry point makes the consumer pay for dependencies they did not ask for.

## The Smell

Imagine a markdown package with two jobs. It parses Markdown on the server, then renders the parsed tree in the browser.

```ts {% path="markdown/src/index.ts" %}
export { Markdown, MarkdownParseError } from "./server/index.js";
export { MarkdownView } from "./client/index.js";
```

This looks convenient. It is also hiding a boundary.

The parser might depend on file-system APIs, syntax highlighting, YAML parsing, or a Markdown compiler. The renderer might only need React and a serializable tree. If a browser route imports `MarkdownView`, the bundler still has to reason about the server side of the package.

Tree shaking can help, but I do not like relying on tree shaking to enforce architecture. The package already knows the boundary. The `exports` map should say it out loud.

## Split by Runtime

A better shape is to expose the server and client APIs separately.

```json {% path="markdown/package.json" %}
{
	"name": "@acme/markdown",
	"type": "module",
	"exports": {
		"./server": "./src/server/index.ts",
		"./client": "./src/client/index.tsx",
		"./styles/light.css": "./styles/light.css",
		"./styles/dark.css": "./styles/dark.css"
	}
}
```

Now the import describes the runtime.

```ts
import { Markdown } from "@acme/markdown/server";
import { MarkdownView } from "@acme/markdown/client";
import "@acme/markdown/styles/light.css";
```

That is not just nicer syntax. It gives the bundler a smaller dependency graph, and it gives the reader a clearer contract.

## Keep the Contract Shared

Splitting entry points does not mean the two sides are unrelated. They still need a shared data contract.

```ts {% path="markdown/src/shared.ts" %}
export interface ParsedMarkdown {
	body: unknown;
	frontmatter: Record<string, unknown>;
}
```

The server produces that shape.

```ts {% path="markdown/src/server/index.ts" %}
import type { ParsedMarkdown } from "../shared.js";

export class Markdown {
	parse(source: string): ParsedMarkdown {
		let frontmatter = readFrontmatter(source);
		let body = parseMarkdown(source);

		return { body, frontmatter };
	}
}
```

The client consumes it.

```tsx {% path="markdown/src/client/index.tsx" %}
import type { ParsedMarkdown } from "../shared.js";

export interface MarkdownViewProps {
	content: ParsedMarkdown["body"];
}

export function MarkdownView({ content }: MarkdownViewProps) {
	return <article>{renderMarkdown(content)}</article>;
}
```

The important part is that `shared.ts` stays boring. It should contain types or small runtime values that are safe in every environment. If shared code starts importing Node APIs or React components, the boundary is leaking again.

## Use the Entry Points from the App

In an app, the server route imports the parser from the server entry point.

```tsx {% path="app/routes/articles.$slug.tsx" %}
import { Markdown } from "@acme/markdown/server";
import { MarkdownView } from "@acme/markdown/client";

import type { Route } from "./+types/articles.$slug";

let markdown = new Markdown();

export async function loader({ params }: Route.LoaderArgs) {
	let source = await readArticle(params.slug);
	return markdown.parse(source);
}

export default function Article({ loaderData }: Route.ComponentProps) {
	return <MarkdownView content={loaderData.body} />;
}
```

The route module can mention both entry points because it runs through a framework compiler that understands server and client boundaries. The package still benefits from telling the compiler which side is which.

This is also why I prefer explicit entry points over one large `index.ts`. If something accidentally imports the parser from client-only code, the import path makes the mistake obvious during review.

## Static Assets Are Entry Points Too

JavaScript is not the only thing a package can export. CSS often deserves its own entry point.

```json {% path="markdown/package.json" %}
{
	"exports": {
		"./styles/light.css": "./styles/light.css",
		"./styles/dark.css": "./styles/dark.css"
	}
}
```

Consumers can opt in where it makes sense.

```css {% path="app/styles.css" %}
@import "@acme/markdown/styles/light.css";

@media (prefers-color-scheme: dark) {
	@import "@acme/markdown/styles/dark.css";
}
```

This is better than making a component import global CSS as a side effect. The app decides how styles enter the page.

## Do Not Split Everything

Multiple entry points are not a sign of maturity. They are a tool for packages with real boundaries.

A small package with one runtime and one dependency graph is fine with a single entry point.

```json {% path="result/package.json" %}
{
	"name": "@acme/result",
	"type": "module",
	"exports": {
		".": "./src/index.ts"
	}
}
```

Adding `./server`, `./client`, `./utils`, and `./types` to a package that does one thing only creates more API surface to maintain.

Split when the split protects something: a runtime boundary, a large dependency, a framework-specific adapter, or a static asset that consumers should choose explicitly.

## Conclusion

Multi-entry package architecture is mostly about honesty. If a package has server code, client code, and optional styles, the import paths should reflect that.

The trade-off is a little more design work in `package.json` and a larger public API surface. The payoff is clearer boundaries, smaller dependency graphs, and fewer accidental imports across runtimes. If you are organizing several packages together, this fits naturally with [a monorepo built around shared packages](/articles/structuring-a-bun-monorepo-with-shared-packages).
