Internal Packages: Standard Turborepo Pattern
Core Requirements
This monorepo follows the standard Turborepo "Compiled Packages" pattern to satisfy three essential requirements:
- Next.js apps work - Import internal packages seamlessly with full type safety
- Docker builds work - Node.js apps run without TypeScript loaders or bundling complexity
- Hot reload works - Changes to packages reflect immediately in dev mode
Core Principle
Stay as close as possible to Turborepo defaults. No custom bundling tricks.
We use the official Turborepo pattern documented at: https://turborepo.org/docs/core-concepts/internal-packages
The Pattern
Package Configuration
Packages export compiled JavaScript from dist/, with types pointing to source:
{
"name": "@repo/core",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./dist/index.js"
},
"./server/health": {
"types": "./src/server/health.ts",
"default": "./dist/server/health.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
}
}
Key points:
types→ source files (for IDE and type checking)default→ compiled JavaScript (for runtime)devscript usestsup --watchfor hot reload
Next.js Apps
Next.js apps use transpilePackages to handle internal packages:
// next.config.js
const nextConfig = {
transpilePackages: ['@repo/core', '@repo/storage', '@repo/ui'],
};
This tells Next.js to compile the packages during its build process, even though they export compiled JS. This is the standard Turborepo approach.
Node.js Apps
Node.js apps import compiled JavaScript directly. No bundling needed:
// apps/video-processor/src/index.ts
import { healthChecker } from "@repo/core/server/health";
// ↑ Resolves to packages/core/dist/server/health.js at runtime
tsup config for Node.js apps:
// apps/video-processor/tsup.config.ts
export default defineConfig({
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/*)
dts: false,
outDir: "dist",
});
Why this pattern?
bundle: true→ Resolves app's relative imports (./env,./logger)skipNodeModulesBundle: true→ Packages load fromnode_modules/@repo/*/dist/*.js(compiled JS)- No TypeScript runtime needed - everything is JavaScript
Turbo.json Configuration (Critical for Hot Reload)
The dev task depends on ^build to ensure packages are built before dev starts:
{
"tasks": {
"dev": {
"dependsOn": ["^build"], // Build packages first, then run dev
"persistent": true,
"cache": false
}
}
}
Why ^build dependency:
- Ensures
dist/folders are fresh before Next.js/Turbopack starts - Prevents stale compiled code issues (e.g., leftover debug instrumentation)
- Persistent tasks CAN depend on non-persistent tasks (Turbo 2.x limitation is only persistent→persistent)
- Turbo caches builds, so subsequent dev starts are instant
How Hot Reload Works
pnpm dev │ ▼ ┌─────────────────────────────────────────────┐ │ Phase 1: Build (sequential, cached) │ │ • @repo/core: tsup → dist/ │ │ • @repo/storage: tsup → dist/ │ │ • @repo/app-video-flow: tsup → dist/ │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ Phase 2: Dev (parallel, persistent) │ │ • packages run: tsup --watch │ │ • apps run: next dev --turbo │ └─────────────────────────────────────────────┘ │ ▼ During development: • Developer changes packages/core/src/health.ts • tsup --watch rebuilds → dist/server/health.js • Turbopack detects dist/ change via symlink • Hot reload triggers automatically
Why this two-phase approach works:
^buildguarantees freshdist/before any dev task starts- Packages run
tsup --watchin parallel with the app for ongoing changes - Apps import from
dist/, so they see the updated compiled output - Turbopack detects file changes via pnpm symlinks
No magic. No bundling tricks. Standard Turborepo.
Docker Builds
Docker builds use turbo prune to create minimal workspaces:
# Prune workspace
FROM base AS pruner
COPY . .
RUN turbo prune video-processor --docker
# Install and build (same stage for symlinks)
FROM base AS installer
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=video-processor
# Runner
FROM node:20-slim AS runner
COPY --from=installer /app .
CMD ["node", "apps/video-processor/dist/index.js"]
Why this works:
- Packages build to
dist/before app builds - App imports resolve to compiled
dist/*.jsfiles - No TypeScript runtime needed - pure JavaScript
Comparison: Why Not Just-in-Time?
Turborepo also supports "Just-in-Time" packages that export source TypeScript:
// Just-in-Time pattern (NOT what we use)
{ "exports": { "./button": "./src/button.tsx" } }
Why we don't use it:
- ✅ Works great for Next.js (
transpilePackages) - ❌ Doesn't work for Node.js apps - no bundler to compile TS
- ❌ Requires complex bundling configs (
noExternal, etc.)
Standard "Compiled Packages" pattern:
- ✅ Works for Next.js
- ✅ Works for Node.js
- ✅ Works in Docker
- ✅ Hot reload via
tsup --watch - ✅ No bundling complexity
Package Checklist
When creating a new internal package:
- ✅ Export compiled JS:
"default": "./dist/index.js" - ✅ Export types from source:
"types": "./src/index.ts" - ✅ Add
devscript:"dev": "tsup --watch" - ✅ Add
buildscript:"build": "tsup" - ✅ Configure tsup to output to
dist/