Skip to content

Instantly share code, notes, and snippets.

@mekanics
Last active March 5, 2026 10:50
Show Gist options
  • Select an option

  • Save mekanics/d9a9e137050b643208eb73db3704ea0a to your computer and use it in GitHub Desktop.

Select an option

Save mekanics/d9a9e137050b643208eb73db3704ea0a to your computer and use it in GitHub Desktop.
Turborepo: apps vs packages - Decision Guide

Turborepo: apps/ vs packages/ — Decision Guide

Context

All applications in this monorepo are static React apps deployed to S3. There are no server-side runtimes. The deployment target is the key distinguishing factor.

The Core Rule

apps/ packages/
Produces a dist/ uploaded to S3
Produces a dist/ published to npm
You import it at build-time
Has its own dev server / port
Consumed at runtime ❌ (build-time only)

One-liner: If it gets uploaded to S3 → app. If it gets published to npm → package.


Standard Structure

apps/
  web/                    # Main React app → deployed to S3
  admin/                  # Back-office React app → deployed to S3
  storybook/              # Component explorer → deployed to S3

packages/
  ui/                     # Shared component library
  utils/                  # Shared helpers & types
  config-typescript/      # Shared tsconfig.json
  config-eslint/          # Shared ESLint config

Special Cases

Embeddable app (published to npm AND deployed standalone to S3)

Split into two: a package for the embeddable logic and an app as the standalone shell.

apps/
  my-app/                 # Standalone → built and uploaded to S3
  my-app-dev/             # Dev harness for local iteration (never published or deployed)

packages/
  my-app-core/            # Published to npm, consumed by external apps at build-time

apps/my-app is a thin wrapper that imports from packages/my-app-core. This ensures the embedded and standalone versions always run the same code.


Micro-frontends (Module Federation)

MFE remotes go in apps/ because each remote produces its own remoteEntry.js that is uploaded to S3 and consumed at runtime by the shell — not at build-time like an npm package.

apps/
  shell/                  # Host app → uploaded to S3, consumes remotes at runtime
  mfe-dashboard/          # Remote → uploaded to S3, exposes ./Dashboard
  mfe-settings/           # Remote → uploaded to S3, exposes ./Settings
  mfe-auth/               # Remote → uploaded to S3, exposes ./AuthProvider

packages/
  ui/                     # Shared components (build-time, no federation config)
  utils/                  # Shared helpers
  mfe-types/              # Shared TypeScript contracts between MFEs

Shared TypeScript types and contracts between MFEs always live in packages/mfe-types — imported at build-time, keeping runtime coupling minimal.

Architecture overview

┌──────────────────────────────────────────┐
│  apps/shell  (host, S3)                  │
│  ┌─────────────┐  ┌────────────────────┐ │
│  │ mfe-dashboard│  │   mfe-settings    │ │  ← apps/ (each on S3, runtime remotes)
│  └─────────────┘  └────────────────────┘ │
│           ↑ all consume ↑                │
├──────────────────────────────────────────┤
│  packages/ui, utils, mfe-types           │  ← packages/ (build-time deps, npm only)
└──────────────────────────────────────────┘

Decision Checklist

When unsure, ask:

  1. Does it produce a dist/ that gets uploaded to S3? → app
  2. Does it produce a dist/ that gets published to npm? → package
  3. Does it have a ModuleFederationPlugin config or its own remoteEntry.js? → app
  4. Is it consumed only at build-time (tsconfig, eslint, types)? → package
  5. Does it do both (deploy to S3 AND publish to npm)? → split into app + package
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment