Tsup Configuration Patterns
Overview
This monorepo uses different tsup bundling strategies depending on package type. The key pattern is source-first exports with forced bundling for Node.js apps.
Architecture Summary
┌─────────────────────────────────────────────────────────────┐
│ Source-First Pattern │
├─────────────────────────────────────────────────────────────┤
│ Packages: exports.import → ./src/*.ts │
│ ├── Enables instant hot-reload in development │
│ └── Types resolve from source (no .d.ts needed) │
│ │
│ Node.js Apps: noExternal + bundle: true │
│ ├── Forces @repo/* packages to be inlined │
│ └── Bundled output is self-contained │
│ │
│ Next.js Apps: transpilePackages │
│ └── Compiles TypeScript directly from source │
└─────────────────────────────────────────────────────────────┘
The Problem: Node.js ESM Module Resolution
When using ESM ("type": "module"), Node.js requires explicit file extensions for relative imports:
// ❌ FAILS in Node ESM (ERR_MODULE_NOT_FOUND)
import { something } from "./helper";
// ✅ WORKS in Node ESM
import { something } from "./helper.js";
Additionally, Node.js cannot load .ts files directly. If package exports point to .ts files and the bundler doesn't inline them, Node.js fails with ERR_UNKNOWN_FILE_EXTENSION.
Solution: noExternal for Node.js Apps
The current approach uses skipNodeModulesBundle: true to externalize all node_modules (including @repo/*). This requires packages to export compiled JS from dist/:
// apps/*/tsup.config.ts (video-processor, sync-worker) — as of 2026-03
export default defineConfig((options) => ({
entry: ["src/index.ts"],
format: ["esm"],
bundle: true, // Bundle app's own code to resolve relative imports
skipNodeModulesBundle: true, // Externalize all node_modules (including @repo/*)
// @repo/* packages export compiled JS from dist/, so they work as external modules.
}));
Why skipNodeModulesBundle works:
@repo/*packages build theirdist/via tsup and export compiled JS (not.tssource)skipNodeModulesBundlekeeps them as external — Node.js resolves fromnode_modules- No inlining needed as long as packages build correctly
- Produces smaller bundles than the
noExternalinlining approach
Historical note: An alternative approach uses
noExternal: [/^@repo\/.*/]to force-inline all@repo/*packages into the bundle. This creates a larger self-contained bundle but doesn't require packages to have pre-compileddist/. Both work; the current codebases useskipNodeModulesBundle.
Package Configuration
Packages use bundling with explicit entry points to handle internal relative imports:
// packages/*/tsup.config.ts
export default defineConfig((options) => ({
entry: [
"src/index.ts",
"src/server/env.ts",
"src/server/service.ts",
// ... explicit list matching package.json exports
],
format: ["esm"],
dts: false, // Types resolve from source via exports.types
// NO bundle: false - bundling handles internal relative imports
}));
Why bundling for packages:
- Packages contain internal relative imports like
from "./env" - Without bundling, these compile to extensionless imports that Node ESM rejects
- Bundling inlines internal imports → Node never sees extensionless paths
Docker Build Pattern
Use Turborepo's turbo prune --docker for efficient multi-stage Docker builds:
FROM node:22-slim AS base
RUN npm install -g pnpm turbo
FROM base AS pruner
WORKDIR /app
COPY . .
RUN turbo prune <app-name> --docker
FROM base AS installer
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=<app-name>
FROM node:22-slim AS runner
WORKDIR /app
COPY --from=installer /app .
CMD ["node", "apps/<app-name>/dist/index.js"]
Benefits over manual COPY:
- Automatically includes all needed packages
- Better Docker layer caching
- Smaller build context
- Future-proof as dependencies change
Verification
Automated Verification
Run the ESM import verification script after any bundling changes:
pnpm verify:esm-imports
This script:
- Runs
node dist/index.jsfor each Node.js app - Verifies modules load correctly (expects env validation errors, not module errors)
- Catches
ERR_MODULE_NOT_FOUNDorERR_UNKNOWN_FILE_EXTENSIONbefore deployment
Expected output:
✅ video-processor: Modules loaded correctly
✅ sync-worker: Modules loaded correctly
CI Integration:
This verification runs automatically in .github/workflows/ci-merge-group.yml after builds complete.
Manual Testing
-
Build locally:
pnpm turbo build --filter=video-processor -
Test app startup:
node apps/video-processor/dist/index.jsShould fail with "ENVIRONMENT: Required" (modules loaded) not module resolution errors.
-
Test in Docker:
docker build -t test -f apps/video-processor/Dockerfile . docker run test
Configuration Summary
| Type | bundle | skipNodeModulesBundle | dts | Pattern |
|---|---|---|---|---|
| Packages | true (default) | - | false | Explicit entry points |
| Node.js Apps | true | true | false | Externalize all node_modules; @repo/* serves compiled dist/ |
| Next.js Apps | N/A | N/A | N/A | Uses transpilePackages |
Common Mistakes
❌ Mistake 1: Forgetting skipNodeModulesBundle in Node.js apps
// ❌ DON'T DO THIS
export default defineConfig({
bundle: true,
// Missing skipNodeModulesBundle! Node.js app imports @repo/* from source (.ts) at runtime.
});
Result: ERR_UNKNOWN_FILE_EXTENSION because @repo/* packages export .ts source paths and Node.js can't load them directly. Fix: add skipNodeModulesBundle: true (requires packages to have compiled dist/) or noExternal: [/^@repo\/.*/] (inlines @repo/* into bundle).
❌ Mistake 2: Using bundle: false for packages with relative imports
// ❌ DON'T DO THIS
export default defineConfig({
entry: ["src/**/*.{ts,tsx}"],
bundle: false, // This breaks relative imports!
});
Result: ERR_MODULE_NOT_FOUND because internal relative imports don't have .js extensions.
References
- Node.js ESM Resolution
- Tsup Bundling Documentation
- Turborepo Docker Guide
- Verification script:
packages/ci-scripts/src/verify-esm-imports.ts