All docs/Monorepo Patterns

docs/architecture/internal-packages-standard-pattern.md

Internal Packages: Standard Turborepo Pattern

Core Requirements

This monorepo follows the standard Turborepo "Compiled Packages" pattern to satisfy three essential requirements:

  1. Next.js apps work - Import internal packages seamlessly with full type safety
  2. Docker builds work - Node.js apps run without TypeScript loaders or bundling complexity
  3. 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)
  • dev script uses tsup --watch for 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 from node_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:

  • ^build guarantees fresh dist/ before any dev task starts
  • Packages run tsup --watch in 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/*.js files
  • 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:

  1. ✅ Export compiled JS: "default": "./dist/index.js"
  2. ✅ Export types from source: "types": "./src/index.ts"
  3. ✅ Add dev script: "dev": "tsup --watch"
  4. ✅ Add build script: "build": "tsup"
  5. ✅ Configure tsup to output to dist/

References