Gå till innehåll
·29 min läsning

Turborepo-monorepos: Upplägget som faktiskt skalar

Hur jag strukturerar Turborepo-monorepos för flera appar som delar paket. Workspace-konfiguration, pipeline-cachning, delade TypeScript-configs och misstagen jag gjorde första månaden.

Dela:X / TwitterLinkedIn

Jag tillbringade de första tre åren av min karriär med att kopiera hjälpfunktioner mellan repositories. Ingen överdrift. Jag hade en formatDate.ts som levde i sju olika projekt, var och en med lite olika buggar. När jag fixade tidszonproblemet i ett glömde jag bort de andra sex. Till slut skickade en användare i Australien in ett ärende och jag upptäckte att fixen aldrig nått produktion i just den appen.

Monorepot löste detta. Inte för att det är trendigt, inte för att Google gör det, utan för att jag tröttnade på att vara ett mänskligt paketregister. Ett repository, delad kod, atomära ändringar över varje app som konsumerar den.

Men monorepos har sina egna fellägen. Jag har provat tre olika verktyg, slösat veckor på trasig cachning, kämpat mot cirkulära beroendefel vid midnatt och lärt mig att "bara lägg allt i ett repo" är ungefär lika användbart som råd kan bli utan detaljerna.

Det här är Turborepo-upplägget jag faktiskt använder. Det kör fyra produktionsappar med tolv delade paket. Byggen tar under 90 sekunder tack vare fjärrcachning. Nya utvecklare kan klona och köra pnpm dev och ha allt fungerande på under två minuter. Det tog mig ungefär en månad av misstag att komma hit.

Varför monorepo överhuvudtaget#

Låt oss vara ärliga om avvägningarna. Ett monorepo är inte gratis. Du byter ut en uppsättning problem mot en annan, och du behöver veta vad du köper.

Vad du får#

Koddelning utan publicering. Det här är den stora grejen. Du skriver ett React-komponentbibliotek i packages/ui. Din webbapp, din admin-dashboard och din dokumentationssajt importerar alla från det. När du ändrar en knappkomponent får varje app den omedelbart. Ingen versionsbumpning, ingen npm publish, inget "vilken version kör produktion igen?"

typescript
// In apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";

Dessa importer ser ut som externa paket. De resolvas genom workspace-beroenden. Men de pekar på källkod i samma repository.

Atomära tvärpaketändringar. Tänk dig att du behöver lägga till en isLoading-prop till din delade Button-komponent. I en polyrepo-värld skulle du ändra komponentbiblioteket, publicera en ny version, uppdatera versionen i varje konsumerande app och sedan öppna PR:ar i varje repo. Det är tre till fem PR:ar för en prop.

I ett monorepo är det en commit:

feat: add isLoading prop to Button component

- packages/ui/src/Button.tsx (add prop, render spinner)
- apps/web/src/pages/checkout.tsx (use isLoading)
- apps/admin/src/pages/users.tsx (use isLoading)

En PR. En granskning. En merge. Allt förblir synkroniserat eftersom det fysiskt inte kan drifta isär.

Enhetliga verktyg. En ESLint-konfiguration. En Prettier-konfiguration. En TypeScript-baskonfiguration. En CI-pipeline. När du uppgraderar ESLint uppgraderar du det överallt på en eftermiddag, inte över tre sprintar i åtta repos.

Vad du betalar#

Komplexitet vid roten. Din package.json i roten blir infrastruktur. Din CI-pipeline behöver förstå vilka paket som ändrats. Din IDE kan kämpa med det rena antalet filer. Git-operationer saktar ner allt eftersom repot växer (men det tar år för de flesta team).

CI-tid kan svälla. Om du bygger allt vid varje commit slösar du enorma mängder beräkningskraft. Du behöver ett verktyg som förstår beroendegrafen och bara bygger det som ändrats. Det är hela anledningen till att Turborepo existerar.

Onboarding-friktion. Nya utvecklare behöver förstå workspaces, hoisting, interna paket, byggpipelinen. Det är inte bara "klona och kör." Nåja, det ska det vara till slut, men att komma dit kräver medveten ansträngning.

Den ärliga bedömningen: om du har en app utan delad kod är ett monorepo overhead utan nytta. Om du har två eller fler appar som delar något — komponenter, hjälpfunktioner, typer, konfigurationer — betalar monorepot sig självt inom den första månaden.

Turborepo vs Nx vs Lerna#

Jag har använt alla tre. Här är den korta versionen.

Lerna var det ursprungliga monorepo-verktyget för JavaScript. Det hanterar versionering och publicering. Men det förstår inte byggpipelines eller cachning. Det övergavs, återupplivades av Nx, och känns nu mer som ett kompatibilitetslager än ett fristående verktyg. Om du behöver publicera paket till npm kan Lerna + Nx hantera det. Men för interna monorepos där du bara delar kod mellan dina egna appar är det mer ceremoni än du behöver.

Nx är kraftfullt. Riktigt kraftfullt. Det har generatorer, plugins för varje ramverk, en visuell beroendesgraf, distribuerad uppgiftskörning. Det har också en inlärningskurva som ser ut som en klippvägg. Jag har sett team lägga två veckor på att bara konfigurera Nx innan de skriver någon produktkod. Om du är på ett företag med 50+ utvecklare och hundratals paket är Nx förmodligen rätt val. För mina användningsfall är det en bulldozer när jag behöver en spade.

Turborepo gör tre saker bra: det förstår din beroendesgraf, det cachar byggutdata och det kör uppgifter parallellt. Det är allt. Hela konfigurationen är en turbo.json-fil. Du kan läsa hela filen på två minuter. Det genererar inte kod, det har inte plugins, det försöker inte ersätta ditt byggverktyg. Det är en uppgiftskörare som är väldigt, väldigt bra på att veta vad som behöver köras och vad som kan hoppas över.

Jag valde Turborepo för att jag kunde förklara hela upplägget för en ny teammedlem på 15 minuter. Med Nx tog samma samtal en timme och de hade fortfarande frågor.

Feature           | Turborepo     | Nx              | Lerna
------------------+---------------+-----------------+----------------
Config complexity | Low (1 file)  | High (multiple) | Medium
Learning curve    | ~1 day        | ~1 week         | ~2 days
Build caching     | Yes (remote)  | Yes (remote)    | No (native)
Task orchestration| Yes           | Yes             | Basic
Code generation   | No            | Yes (extensive) | No
Framework plugins | No            | Yes             | No
Best for          | Small-medium  | Large orgs      | Publishing

Repositorystruktur#

Här är det faktiska katalogträdet. Inte ett "komma igång"-exempel — det här är hur ett produktionsmonorepo ser ut efter sex månader.

acme-monorepo/
├── apps/
│   ├── web/                    # Main customer-facing app
│   │   ├── src/
│   │   ├── public/
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── admin/                  # Internal admin dashboard
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── docs/                   # Documentation site
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── api/                    # Express/Fastify API server
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── ui/                     # Shared React component library
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   ├── Button.tsx
│   │   │   │   ├── Input.tsx
│   │   │   │   ├── Modal.tsx
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── utils/                  # Shared utility functions
│   │   ├── src/
│   │   │   ├── date.ts
│   │   │   ├── string.ts
│   │   │   ├── validation.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── types/                  # Shared TypeScript types
│   │   ├── src/
│   │   │   ├── user.ts
│   │   │   ├── api.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── config-typescript/      # Shared tsconfig presets
│   │   ├── base.json
│   │   ├── nextjs.json
│   │   ├── node.json
│   │   └── package.json
│   ├── config-eslint/          # Shared ESLint configurations
│   │   ├── base.js
│   │   ├── next.js
│   │   ├── node.js
│   │   └── package.json
│   └── config-tailwind/        # Shared Tailwind presets
│       ├── tailwind.config.ts
│       └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json

Uppdelningen apps/ vs packages/#

Det här är den grundläggande organiseringsprincipen. apps/ innehåller deploybara saker — webbappar, API:er, allt med ett dev- eller start-kommando. packages/ innehåller bibliotek — kod som existerar för att konsumeras av appar eller andra paket.

Regeln är enkel: appar konsumerar paket. Paket importerar aldrig från appar. Paket kan importera från andra paket.

Om ett paket börjar importera från en app har du beroendeangrafen baklänges och Turborepo fångar det som en cykel.

Workspace-konfiguration#

Rot-filen pnpm-workspace.yaml berättar för pnpm var paket finns:

yaml
packages:
  - "apps/*"
  - "packages/*"

Det är hela filen. pnpm skannar de katalogerna, läser varje package.json och skapar en workspace-karta. När apps/web deklarerar "@acme/ui": "workspace:*" som ett beroende, länkar pnpm det till det lokala packages/ui istället för att leta i npm-registret.

Rot-package.json#

json
{
  "name": "acme-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test",
    "clean": "turbo clean",
    "format": "prettier --write \"**/*.{ts,tsx,md,json}\""
  },
  "devDependencies": {
    "prettier": "^3.4.2",
    "turbo": "^2.4.4",
    "typescript": "^5.7.3"
  },
  "packageManager": "pnpm@9.15.4",
  "engines": {
    "node": ">=20"
  }
}

Lägg märke till: rot-package.json har inga runtime-beroenden. Det är ren orkestrering. turbo är uppgiftsköraren, prettier hanterar formatering (eftersom det är det enda verktyget som inte behöver per-paketkonfiguration), och typescript hissas så att varje paket använder samma version.

Namnkonventioner#

Varje paket får ett scopat namn: @acme/ui, @acme/utils, @acme/config-typescript. Scopet förhindrar kollisioner med npm-paket och gör det omedelbart tydligt i varje importfras om du använder intern eller extern kod.

typescript
// External package - from npm
import { clsx } from "clsx";
 
// Internal package - from our monorepo
import { Button } from "@acme/ui";

Jag prefixar konfigurationspaket med config- för att gruppera dem visuellt: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Vissa team använder @acme/tsconfig, @acme/eslint-config. Båda fungerar. Var bara konsekvent.

turbo.json Pipeline-konfiguration#

Det är här Turborepo gör sig förtjänt av sin plats. turbo.json-filen definierar din uppgiftspipeline — vilka uppgifter som finns, vad de beror på och vad de producerar.

json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "**/.env.*local"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "test": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": ["src/**", "test/**", "**/*.test.ts", "**/*.test.tsx"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    },
    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    }
  }
}

Låt mig bryta ner varje fält eftersom det här är där de flesta blir förvirrade.

dependsOn och ^-notationen#

"dependsOn": ["^build"] betyder: "Innan du kör den här uppgiften i paket X, kör först build i varje paket som X beror på."

Symbolen ^ betyder "uppströms beroenden." Utan den skulle "dependsOn": ["build"] betyda "kör build i samma paket först" — användbart om din test-uppgift behöver ett build först inom samma paket.

Här är ett konkret exempel. Din beroendesgraf ser ut så här:

apps/web → @acme/ui → @acme/utils
                    → @acme/types

När du kör turbo build löser Turborepo ut grafen:

  1. Bygg @acme/types (inga beroenden)
  2. Bygg @acme/utils (inga beroenden)
  3. Bygg @acme/ui (beror på types och utils — väntar på dem)
  4. Bygg apps/web (beror på ui — väntar på den)

Steg 1 och 2 körs parallellt. Steg 3 väntar på båda. Steg 4 väntar på 3. Turborepo räknar ut detta från dina package.json-beroendedeklarationer. Du behöver inte ange ordningen manuellt.

outputs#

Det här är kritiskt för cachning. När Turborepo cachar ett bygge lagrar det filerna som listas i outputs. När det får en cache-träff återställer det dessa filer. Om du glömmer att lista en utdatakatalog kommer cachen att "lyckas" men dina filer kommer inte att dyka upp. Det här var mitt största misstag under första veckan — jag missade .next/** och kunde inte förstå varför min Next.js-app alltid byggdes om.

json
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]

Undantaget !.next/cache/** är viktigt. Next.js har sin egen cache inuti .next/cache/. Du vill inte lagra en cache av en cache — det blåser upp din fjärrcachelagring och hjälper inte.

inputs#

Som standard hashar Turborepo varje fil i ett paket för att avgöra om det ändrats. Fältet inputs begränsar det. Om bara filer i src/ påverkar byggutdatan bör en ändring av README.md inte invalidera cachen.

json
"inputs": ["src/**", "package.json", "tsconfig.json"]

Var försiktig med detta. Om du lägger till en konfigurationsfil som påverkar ditt bygge (säg postcss.config.js) och inte inkluderar den i inputs, får du inaktuellt cachad utdata. Vid tveksamhet, lämna inputs ospecificerat och låt Turborepo hasha allt.

globalDependencies#

Filer som listas här invaliderar cachen för varje paket när de ändras. Miljöfiler är det klassiska exemplet — om din .env.local ändras behöver allt som kan läsa från process.env byggas om.

json
"globalDependencies": ["**/.env.*local"]

Jag lägger också till tsconfig.json på rotnivå här eftersom min bas TypeScript-konfiguration påverkar alla paket:

json
"globalDependencies": [
  "**/.env.*local",
  "tsconfig.json"
]

persistent och dev#

dev-uppgiften har "cache": false och "persistent": true. Att cacha en dev-server är meningslöst — det är en långkörande process. Flaggan persistent berättar för Turborepo att denna uppgift inte avslutas, så den ska inte vänta på den innan andra uppgifter körs.

När du kör turbo dev startar Turborepo alla dev-servrar parallellt — din Next.js-app, din API-server, din dokumentationssajt — allt i en terminal med sammanvävd utdata. Varje rad har paketnamnet som prefix så du kan skilja dem åt.

Delade paket i detalj#

packages/ui — Komponentbiblioteket#

Det här är paketet varje team bygger först. Delade React-komponenter som används i alla frontend-appar.

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    },
    "./styles.css": "./src/styles.css"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts --external react",
    "dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch",
    "clean": "rm -rf dist",
    "lint": "eslint src/",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "tsup": "^8.3.6",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.7.3"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  }
}

Några saker att notera:

"version": "0.0.0" — Interna paket behöver inga riktiga versioner. Protokollet workspace:* talar om för pnpm att alltid använda den lokala kopian. Versionsnummer är irrelevanta.

"private": true — Det här paketet kommer aldrig att publiceras på npm. Om du någonsin vill publicera det, ta bort den här flaggan och sätt upp en ordentlig versioneringsstrategi.

Fältet exports — Det här är det moderna sättet att definiera paketets ingångspunkter. Det ersätter fälten main, module och types. Exporten "." är standardimportsökvägen. Du kan lägga till undersökvägexportar för granulära importer:

json
{
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    },
    "./Button": {
      "types": "./src/components/Button.tsx",
      "default": "./src/components/Button.tsx"
    },
    "./styles.css": "./src/styles.css"
  }
}

Viktigt beslut: bygga eller inte? Vissa team hoppar över byggsteget helt för interna paket. Istället för att kompilera med tsup pekar de exports direkt på TypeScript-källkoden och låter den konsumerande appens bundler (Next.js, Vite) hantera kompileringen. Det här kallas "interna paket" i Turborepos terminologi och det är enklare:

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  }
}

Inget byggsteg. Ingen dist-mapp. Den konsumerande appens next.config.ts behöver transpilePackages:

typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
};
 
export default nextConfig;

Jag använder no-build-metoden för de flesta interna paket. Det är snabbare under utveckling och en sak mindre som kan gå sönder. Undantaget är paket som behöver stödja icke-TypeScript-konsumenter eller har komplexa byggkrav.

packages/utils — Delade hjälpfunktioner#

json
{
  "name": "@acme/utils",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./date": "./src/date.ts",
    "./string": "./src/string.ts",
    "./validation": "./src/validation.ts"
  },
  "dependencies": {
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Undersökvägexporterna låter konsumerande appar importera bara det de behöver:

typescript
// Only imports date utilities, not the entire package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
 
// Imports everything (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";

Barrel-exporten i src/index.ts reexporterar allt:

typescript
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";

En varningens ord om barrel-exporter: de är bekväma men de kan döda tree-shaking i vissa bundlers. Om apps/web importerar en funktion från @acme/utils kan en naiv bundler inkludera allt. Next.js hanterar det här bra med sin inbyggda tree-shaking, men om du märker problem med bundlestorlek är undersökvägexportmönstret säkrare.

packages/types — Delade TypeScript-typer#

json
{
  "name": "@acme/types",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./api": "./src/api.ts",
    "./user": "./src/user.ts"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Det här paketet har noll runtime-beroenden. Det innehåller bara TypeScript-typer och gränssnitt. Det behöver aldrig byggas eftersom typer raderas vid kompilering.

typescript
// packages/types/src/user.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: "admin" | "user" | "viewer";
  createdAt: Date;
  updatedAt: Date;
}
 
export interface CreateUserInput {
  email: string;
  name: string;
  role?: User["role"];
}
 
export interface UserFilters {
  role?: User["role"];
  search?: string;
  page?: number;
  limit?: number;
}
typescript
// packages/types/src/api.ts
export interface ApiResponse<T> {
  data: T;
  meta?: {
    page: number;
    limit: number;
    total: number;
  };
}
 
export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}
 
export type ApiResult<T> =
  | { success: true; data: T }
  | { success: false; error: ApiError };

packages/config-typescript — Delade TSConfigs#

Den här är bedrägligt viktig. TypeScript-konfiguration i ett monorepo är där saker blir krångliga snabbt.

json
{
  "name": "@acme/config-typescript",
  "version": "0.0.0",
  "private": true,
  "files": [
    "base.json",
    "nextjs.json",
    "node.json",
    "react-library.json"
  ]
}

Baskonfigurationen som alla paket utökar:

json
// packages/config-typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true
  },
  "exclude": ["node_modules"]
}

Next.js-specifik konfiguration:

json
// packages/config-typescript/nextjs.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "noEmit": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      { "name": "next" }
    ]
  }
}

Node.js-konfigurationen för backend-paket:

json
// packages/config-typescript/node.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Nu utökar varje app eller paket lämplig preset:

json
// apps/web/tsconfig.json
{
  "extends": "@acme/config-typescript/nextjs",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
json
// apps/api/tsconfig.json
{
  "extends": "@acme/config-typescript/node",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
json
// packages/ui/tsconfig.json
{
  "extends": "@acme/config-typescript/base",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Sökvägaliaset @/*#

Varje app får samma @/*-sökvägalias som pekar på sin egen src/-katalog. Det innebär att @/components/Header i apps/web resolvas till apps/web/src/components/Header, och @/components/Header i apps/admin resolvas till apps/admin/src/components/Header.

Dessa är applokala sökvägar. De korsar inte paketgränser. För tvärpaketimporter använder du alltid paketnamnet: @acme/ui, inte @/../../packages/ui. Om du någonsin ser en relativ import som går uppåt förbi paketets rot (../../packages/something) är det en kodlukt. Använd workspace-beroendet istället.

composite och projektreferenser#

TypeScript-projektreferenser (composite: true) tillåter tsc att bygga paket inkrementellt och förstå tvärberoenden mellan projekt. Det här är valfritt med Turborepo — Turborepo hanterar byggorkestreringen själv — men det kan snabba upp typkontroll i din IDE.

json
// packages/ui/tsconfig.json (with composite)
{
  "extends": "@acme/config-typescript/base",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "references": [
    { "path": "../types" },
    { "path": "../utils" }
  ]
}

Jag ska vara ärlig: projektreferenser lägger till komplexitet och jag har tagit bort dem från de flesta av mina uppsättningar. Turborepos --filter och dependsOn hanterar byggordningen redan. Jag lägger bara till composite: true när IDE-prestandan blir ett problem (vanligtvis vid 15+ paket).

packages/config-eslint — Delad linting#

javascript
// packages/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
 
export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "error",
        { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }
      ],
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/consistent-type-imports": [
        "error",
        { prefer: "type-imports" }
      ],
    },
  }
);
javascript
// packages/config-eslint/next.js
import baseConfig from "./base.js";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
 
export default [
  ...baseConfig,
  {
    plugins: {
      "@next/next": nextPlugin,
      "react": reactPlugin,
      "react-hooks": hooksPlugin,
    },
    rules: {
      ...nextPlugin.configs.recommended.rules,
      ...nextPlugin.configs["core-web-vitals"].rules,
      "react-hooks/exhaustive-deps": "warn",
    },
  },
];

Varje app refererar till sin konfiguration:

javascript
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
 
export default [
  ...nextConfig,
  {
    ignores: [".next/"],
  },
];

Fjärrcachning#

Lokal cachning är trevligt — det förhindrar dig från att bygga om oförändrade paket under utveckling. Men fjärrcachning är omvälvande. Det innebär att din CI-server drar nytta av dina lokala byggen och vice versa.

Hur det fungerar#

När Turborepo kör en uppgift beräknar det en hash baserad på:

  • Källfilerna (innehållshash)
  • Uppgiftens inputs-konfiguration
  • Miljövariabler
  • Hasharna för uppströms beroenden

Om hashen matchar ett tidigare cachat resultat återställer Turborepo outputs från cachen istället för att köra uppgiften. Med fjärrcachning lagras dessa cachade utdata på en delad plats som vilken maskin som helst — din laptop, din kollegas laptop, CI — kan komma åt.

Vercel Remote Cache#

Det enklaste upplägget. Vercel underhåller cacheinfrastrukturen gratis (med begränsningar):

bash
# Login to Vercel (one-time setup)
npx turbo login
 
# Link your repo to a Vercel project
npx turbo link

Det var allt. Turborepo kommer nu att pusha och hämta cacheartefakter från Vercels servrar. I CI sätter du miljövariabler:

yaml
# .github/workflows/ci.yml
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Generera token från Vercel-dashboarden under dina team-inställningar. TURBO_TEAM är ditt team-slug.

Självhostad fjärrcache#

Om du inte kan använda Vercel (luftgappade miljöer, krav på dataresidensering) kan du självhosta. Turborepos fjärrcache-API är väldokumenterat och det finns flera öppen källkod-implementationer:

  • ducktors/turborepo-remote-cache — En Node.js-server som lagrar artefakter i S3, GCS eller lokalt filsystem
  • fox1t/turborepo-remote-cache — Liknande, med stöd för Azure Blob
bash
# docker-compose.yml for self-hosted cache
services:
  turbo-cache:
    image: ducktors/turborepo-remote-cache:latest
    ports:
      - "3000:3000"
    environment:
      TURBO_TOKEN: "your-secret-token"
      STORAGE_PROVIDER: "s3"
      S3_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
      S3_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
      S3_REGION: "eu-central-1"
      S3_ENDPOINT: "https://s3.eu-central-1.amazonaws.com"
      S3_BUCKET: "turbo-cache"

Sedan konfigurerar du ditt monorepo att använda den:

json
// .turbo/config.json
{
  "teamId": "team_acme",
  "apiUrl": "https://turbo-cache.internal.acme.com"
}

Cache-träfffrekvens#

Enligt min erfarenhet uppnår ett välkonfigurerat Turborepo 80-95% cache-träfffrekvens i CI. Det innebär att bara 5-20% av uppgifterna faktiskt körs i en given CI-pipeline. Resten återställs från cachen på sekunder.

Nycklarna till hög cache-träfffrekvens:

  1. Begränsa dina inputs. Om bara src/ påverkar bygget, låt inte en README-ändring invalidera cachen.
  2. Lägg inte volatil data i globalDependencies. En .env-fil som ändras vid varje deploy dödar din cache.
  3. Pinpointera dina beroenden. Ändringar i lockfilen invaliderar allt nedströms.
  4. Håll paketen små och fokuserade. Ett gigantiskt packages/utils med 200 filer kommer att få sin cache invaliderad ofta eftersom varje ändring i valfri hjälpfunktion knäcker hela paketet.

CI/CD med Turborepo#

Här är GitHub Actions-arbetsflödet jag använder. Det har förfinats under dussintals iterationer.

yaml
# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
 
jobs:
  ci:
    name: Build, Lint, Test
    runs-on: ubuntu-latest
    timeout-minutes: 15
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 2   # Need parent commit for change detection
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Build
        run: pnpm turbo build
 
      - name: Lint
        run: pnpm turbo lint
 
      - name: Type Check
        run: pnpm turbo type-check
 
      - name: Test
        run: pnpm turbo test

Flaggan --filter#

Flaggan --filter är hur du kör uppgifter för specifika paket eller baserat på vad som ändrats. Det här är väsentligt för effektiv CI:

bash
# Build only the web app and its dependencies
pnpm turbo build --filter=apps/web...
 
# Build only packages that changed since main
pnpm turbo build --filter=...[main]
 
# Build only @acme/ui and everything that depends on it
pnpm turbo build --filter=...@acme/ui
 
# Build only packages in the packages/ directory
pnpm turbo build --filter="./packages/*"

Trepunktssyntaxen är viktig:

  • --filter=@acme/ui... — Paketet och alla dess beroenden (saker det behöver)
  • --filter=...@acme/ui — Paketet och alla dess beroende (saker som behöver det)
  • --filter=@acme/ui — Bara paketet självt

Detektering av påverkade paket#

För pull requests vill du förmodligen inte bygga allt. Du vill bygga bara det som påverkas av ändringarna:

yaml
# Only build/test what changed compared to main
- name: Build affected
  run: pnpm turbo build --filter="...[origin/main]"
 
- name: Test affected
  run: pnpm turbo test --filter="...[origin/main]"

Syntaxen [origin/main] talar om för Turborepo att jämföra den aktuella commiten med origin/main och bara köra uppgifter för paket som har ändringar. Kombinerat med fjärrcachning avslutas de flesta PR-pipelines på under 2 minuter även för stora monorepos.

Matrisstrategi för deployment#

Om varje app deployas oberoende, använd en matrisstrategi:

yaml
deploy:
  needs: ci
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main'
  strategy:
    matrix:
      app: [web, admin, docs, api]
  steps:
    - name: Checkout
      uses: actions/checkout@v4
 
    - name: Setup pnpm
      uses: pnpm/action-setup@v4
 
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: "pnpm"
 
    - name: Install dependencies
      run: pnpm install --frozen-lockfile
 
    - name: Build
      run: pnpm turbo build --filter=apps/${{ matrix.app }}...
 
    - name: Deploy ${{ matrix.app }}
      run: ./scripts/deploy.sh ${{ matrix.app }}
      env:
        DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Varje matrisjobb bygger en app och dess beroenden. Tack vare fjärrcachning byggs de delade paketen en gång och cachas — varje efterföljande matrisjobb återställer dem från cachen.

Vanliga fallgropar#

Jag ägnar ett helt avsnitt åt det här eftersom jag ramlade i varenda en av dessa under min första månad. Att lära sig från dokumentation är effektivt. Att lära sig av mina misstag är gratis.

1. Cirkulära beroenden#

Beroendegrafen måste vara en DAG (riktad acyklisk graf). Om @acme/ui importerar från @acme/utils och @acme/utils importerar från @acme/ui har du en cykel. Turborepo klagar och vägrar bygga.

Lösningen är nästan alltid att extrahera den delade koden till ett tredje paket:

BEFORE (broken):
@acme/ui → @acme/utils → @acme/ui  ← cycle!

AFTER (fixed):
@acme/ui     → @acme/types
@acme/utils  → @acme/types

Flytta de delade typerna eller konstanterna till @acme/types och låt båda paketen bero på det.

En annan vanlig orsak: du lägger en React-hook i @acme/utils som importerar en komponent från @acme/ui. Hooks som beror på UI-komponenter hör hemma i @acme/ui, inte i @acme/utils. Hjälpfunktioner bör vara ramverksagnostiska när det är möjligt.

2. Saknade outputs som invaliderar cache#

Det här är den mest frustrerande buggen. Allt verkar fungera lokalt. CI-byggen passerar. Men varje bygge tar full tid eftersom ingenting cachas.

Scenario: ditt paket bygger till build/ istället för dist/, men din turbo.json listar bara dist/** i outputs:

json
// turbo.json
"build": {
  "outputs": ["dist/**"]  // but your package builds to build/
}

Turborepo cachar uppgiften (eftersom den kördes framgångsrikt) men lagrar inga utdatafiler. Nästa gång får den en cache-"träff" och återställer ingenting. Den konsumerande appen hittar inte byggutdatan och misslyckas, eller — värre — använder tyst inaktuella filer.

Lösning: granska varje pakets byggutdatakatalog och se till att turbo.json täcker dem alla:

json
"build": {
  "outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}

Eller använd per-paket-överskrivningar i turbo.json:

json
{
  "tasks": {
    "build": {
      "outputs": ["dist/**"]
    }
  },
  // Per-package overrides
  "packages": {
    "apps/web": {
      "build": {
        "outputs": [".next/**", "!.next/cache/**"]
      }
    },
    "apps/api": {
      "build": {
        "outputs": ["build/**"]
      }
    }
  }
}

3. Dev-serverkoordinering#

Att köra turbo dev startar alla dev-servrar samtidigt. Det här fungerar bra tills dina appar försöker använda samma port. Explicit porttilldelning är obligatorisk:

json
// apps/web/package.json
"scripts": {
  "dev": "next dev --port 3000"
}
 
// apps/admin/package.json
"scripts": {
  "dev": "next dev --port 3001"
}
 
// apps/docs/package.json
"scripts": {
  "dev": "next dev --port 3002"
}
 
// apps/api/package.json
"scripts": {
  "dev": "tsx watch src/index.ts"  // Uses port 4000 from config
}

Ett annat problem: hot module replacement för delade paket. När du redigerar en fil i packages/ui behöver ändringen propageras till den körande Next.js dev-servern. Om du använder "interna paket"-metoden (inget byggsteg, direkta TypeScript-importer) fungerar det automatiskt — Next.js bevakar källfilerna genom de symlänkade node_modules.

Om ditt paket har ett byggsteg behöver paketets dev-skript köras i bevakningsläge:

json
// packages/ui/package.json
"scripts": {
  "dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}

Turborepo kör alla dev-uppgifter parallellt, så paketet byggs om vid ändring och den konsumerande appen tar upp den nya utdatan.

4. Versionsdrift mellan paket#

Det här är subtilt och farligt. Om apps/web använder React 19 och apps/admin av misstag använder React 18 kanske du inte märker det förrän en komponent från @acme/ui beter sig annorlunda i varje app.

Lösning: hantera delade beroenden på rotnivå. pnpms fält overrides i rot-package.json tvingar en enda version överallt:

json
// Root package.json
{
  "pnpm": {
    "overrides": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0",
      "typescript": "^5.7.3"
    }
  }
}

Kör pnpm ls react --recursive regelbundet för att verifiera att allt använder samma version.

5. Miljövariabler i delade paket#

Delade paket bör inte läsa process.env direkt. Om @acme/utils läser process.env.API_URL skapar det en osynlig koppling till den konsumerande appens miljö. Skicka istället konfiguration explicit:

typescript
// BAD: packages/utils/src/api.ts
const API_URL = process.env.API_URL;  // What env? Which app?
 
export function fetchData(path: string) {
  return fetch(`${API_URL}${path}`);
}
 
// GOOD: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
  return {
    fetch: (path: string) => fetch(`${baseUrl}${path}`),
  };
}

Den konsumerande appen tillhandahåller konfigurationen:

typescript
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
 
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);

Det här håller paket rena och testbara.

6. Spökberoenden#

pnpm är strikt med beroenden som standard — det hissar inte paket på det sätt som npm gör. Det här är en funktion, inte en bugg. Det innebär att om @acme/ui importerar clsx men inte listar det i sin package.json, kastar pnpm ett fel. npm skulle tyst resolva det från en förälder node_modules.

Deklarera alltid varje import i det konsumerande paketets package.json. Förlita dig inte längre på hoisting.

7. IDE-prestandaförsämring#

Med 15+ paket kan din TypeScript-språkserver börja kämpa. Några tips:

  • Lägg till "exclude": ["node_modules", "**/dist/**"] i din rot-tsconfig.json
  • Använd VS Codes "Files: Exclude" för att dölja mapparna dist/, .next/ och .turbo/
  • Överväg "disableSourceOfProjectReferenceRedirect": true i tsconfig om Go to Definition skickar dig till .d.ts-filer djupt i node_modules

Startstrukturen: Allt ihop#

Låt mig sätta ihop allt. Här är varje fil du behöver för att starta upp ett Turborepo-monorepo med två Next.js-appar och tre delade paket.

Roten#

json
// package.json
{
  "name": "acme-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test",
    "type-check": "turbo type-check",
    "clean": "turbo clean",
    "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
    "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
  },
  "devDependencies": {
    "prettier": "^3.4.2",
    "turbo": "^2.4.4",
    "typescript": "^5.7.3"
  },
  "packageManager": "pnpm@9.15.4",
  "engines": {
    "node": ">=20"
  },
  "pnpm": {
    "overrides": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0"
    }
  }
}
yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
json
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "**/.env.*local",
    "tsconfig.json"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "test": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": [
        "src/**",
        "test/**",
        "**/*.test.ts",
        "**/*.test.tsx",
        "vitest.config.ts"
      ]
    },
    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    }
  }
}
json
// tsconfig.json (root — for IDE only, not used by builds)
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}

apps/web#

json
// apps/web/package.json
{
  "name": "web",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3000",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "clean": "rm -rf .next"
  },
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*",
    "next": "^15.2.1",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "@types/node": "^22.13.5",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "typescript": "^5.7.3",
    "vitest": "^3.0.7"
  }
}
typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
  reactStrictMode: true,
};
 
export default nextConfig;
json
// apps/web/tsconfig.json
{
  "extends": "@acme/config-typescript/nextjs",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

apps/admin#

json
// apps/admin/package.json
{
  "name": "admin",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3001",
    "build": "next build",
    "start": "next start --port 3001",
    "lint": "next lint",
    "type-check": "tsc --noEmit",
    "clean": "rm -rf .next"
  },
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*",
    "next": "^15.2.1",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "@types/node": "^22.13.5",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "typescript": "^5.7.3"
  }
}

packages/ui#

json
// packages/ui/package.json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "lint": "eslint src/",
    "type-check": "tsc --noEmit",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@acme/utils": "workspace:*",
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "@acme/config-eslint": "workspace:*",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.7.3"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  }
}
typescript
// packages/ui/src/index.ts
export { Button } from "./components/Button";
export { Input } from "./components/Input";
export { Modal } from "./components/Modal";
export { Card } from "./components/Card";
export { Badge } from "./components/Badge";
 
// Re-export types
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";
typescript
// packages/ui/src/components/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "@acme/utils";
 
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive";
  size?: "sm" | "md" | "lg";
  isLoading?: boolean;
}
 
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = "primary", size = "md", isLoading, children, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={cn(
          "inline-flex items-center justify-center rounded-lg font-medium transition-colors",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
          "disabled:pointer-events-none disabled:opacity-50",
          {
            "bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
            "bg-gray-100 text-gray-900 hover:bg-gray-200": variant === "secondary",
            "border border-gray-300 bg-transparent hover:bg-gray-100": variant === "outline",
            "bg-transparent hover:bg-gray-100": variant === "ghost",
            "bg-red-600 text-white hover:bg-red-700": variant === "destructive",
          },
          {
            "h-8 px-3 text-sm": size === "sm",
            "h-10 px-4 text-sm": size === "md",
            "h-12 px-6 text-base": size === "lg",
          },
          className
        )}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading ? (
          <>
            <svg
              className="mr-2 h-4 w-4 animate-spin"
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              aria-hidden="true"
            >
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            {children}
          </>
        ) : (
          children
        )}
      </button>
    );
  }
);
 
Button.displayName = "Button";

packages/utils#

json
// packages/utils/package.json
{
  "name": "@acme/utils",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./date": "./src/date.ts",
    "./string": "./src/string.ts",
    "./cn": "./src/cn.ts"
  },
  "scripts": {
    "lint": "eslint src/",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "clsx": "^2.1.1",
    "tailwind-merge": "^3.0.1"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3",
    "vitest": "^3.0.7"
  }
}
typescript
// packages/utils/src/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
typescript
// packages/utils/src/date.ts
export function formatRelativeDate(date: Date): string {
  const now = new Date();
  const diffMs = now.getTime() - date.getTime();
  const diffSeconds = Math.floor(diffMs / 1000);
  const diffMinutes = Math.floor(diffSeconds / 60);
  const diffHours = Math.floor(diffMinutes / 60);
  const diffDays = Math.floor(diffHours / 24);
 
  if (diffSeconds < 60) return "just now";
  if (diffMinutes < 60) return `${diffMinutes}m ago`;
  if (diffHours < 24) return `${diffHours}h ago`;
  if (diffDays < 7) return `${diffDays}d ago`;
 
  return date.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
  });
}
 
export function formatDate(date: Date, locale = "en-US"): string {
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(date);
}
typescript
// packages/utils/src/string.ts
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, "")
    .replace(/[\s_]+/g, "-")
    .replace(/-+/g, "-");
}
 
export function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength).trimEnd() + "...";
}
 
export function capitalize(text: string): string {
  return text.charAt(0).toUpperCase() + text.slice(1);
}
typescript
// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";

packages/types#

json
// packages/types/package.json
{
  "name": "@acme/types",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./user": "./src/user.ts",
    "./api": "./src/api.ts"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

.gitignore#

# Dependencies
node_modules/

# Build outputs
dist/
build/
.next/
out/

# Turborepo
.turbo/

# Environment
.env
.env.local
.env.*.local

# IDE
.vscode/settings.json
.idea/

# OS
.DS_Store
Thumbs.db

Komma igång från noll#

Om du börjar från grunden, här är den exakta sekvensen av kommandon:

bash
# Create the repo
mkdir acme-monorepo && cd acme-monorepo
git init
 
# Initialize the root package
pnpm init
 
# Create the workspace file
echo 'packages:\n  - "apps/*"\n  - "packages/*"' > pnpm-workspace.yaml
 
# Install Turborepo
pnpm add -D turbo -w
 
# Create directories
mkdir -p apps/web apps/admin
mkdir -p packages/ui/src packages/utils/src packages/types/src
mkdir -p packages/config-typescript packages/config-eslint
 
# Initialize apps (from their directories)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
 
# Add workspace dependencies
cd packages/ui && pnpm add @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
cd apps/web && pnpm add @acme/ui@workspace:* @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
 
# Install everything
pnpm install
 
# Verify it works
pnpm turbo build
pnpm turbo dev

Det första pnpm turbo build kommer att vara långsamt — allt byggs från grunden. Det andra bör vara nästan omedelbart om ingenting ändrats. Det är cachen som fungerar.

Skala bortom grunderna#

När du kommit förbi den inledande uppsättningen dyker några mönster upp allt eftersom monorepot växer.

Paketgeneratorer#

Efter ditt tionde paket blir det tråkigt att skapa ett nytt genom att kopiera och redigera. Skapa ett skript:

typescript
// scripts/create-package.ts
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
 
const name = process.argv[2];
if (!name) {
  console.error("Usage: tsx scripts/create-package.ts <package-name>");
  process.exit(1);
}
 
const dir = join(process.cwd(), "packages", name);
mkdirSync(join(dir, "src"), { recursive: true });
 
writeFileSync(
  join(dir, "package.json"),
  JSON.stringify(
    {
      name: `@acme/${name}`,
      version: "0.0.0",
      private: true,
      exports: { ".": "./src/index.ts" },
      scripts: {
        lint: "eslint src/",
        "type-check": "tsc --noEmit",
      },
      devDependencies: {
        "@acme/config-typescript": "workspace:*",
        typescript: "^5.7.3",
      },
    },
    null,
    2
  )
);
 
writeFileSync(
  join(dir, "tsconfig.json"),
  JSON.stringify(
    {
      extends: "@acme/config-typescript/base",
      compilerOptions: {
        outDir: "./dist",
        rootDir: "./src",
      },
      include: ["src"],
      exclude: ["node_modules", "dist"],
    },
    null,
    2
  )
);
 
writeFileSync(join(dir, "src", "index.ts"), "// Entry point\n");
 
console.log(`Created packages/${name}`);
console.log("Run: pnpm install");
bash
npx tsx scripts/create-package.ts email
# Creates packages/email with all the boilerplate

Visualisering av workspace-beroendegrafen#

Turborepo har ett inbyggt grafkommando:

bash
pnpm turbo build --graph=graph.svg

Det här genererar en SVG av din beroendesgraf. Jag kör det här innan större refaktoreringar för att förstå sprängradien av en ändring. Om att röra @acme/types bygger om 12 paket kanske det är dags att dela upp det i @acme/types-user och @acme/types-billing.

Beskärning för Docker#

När du deployar en enda app från monorepot vill du inte kopiera hela repot till din Docker-image. Turborepo har ett prune-kommando:

bash
pnpm turbo prune --scope=apps/web --docker

Det här genererar en out/-katalog som bara innehåller filerna som behövs för apps/web och dess beroenden. Din Dockerfile använder denna beskurna utdata:

dockerfile
FROM node:20-alpine AS base
 
FROM base AS builder
WORKDIR /app
COPY out/json/ .
COPY out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable pnpm && pnpm install --frozen-lockfile
 
COPY out/full/ .
RUN pnpm turbo build --filter=web...
 
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
 
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
 
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

Docker-imagen innehåller bara webbappen och dess beroenden. Ingen admin-app, ingen API-server, inga oanvända paket. Imagestorlekar förblir rimliga även när monorepot växer.

En månad in: Vad jag skulle göra annorlunda#

Om jag började om, med vetskapen jag har nu:

  1. Börja med interna paket (inget byggsteg). Jag slösade två veckor på att konfigurera tsup-byggen för paket som bara mina Next.js-appar konsumerade. transpilePackages i Next.js-konfigurationen är enklare och snabbare. Lägg bara till ett byggsteg när du behöver ett.

  2. Håll antalet i packages/ lågt initialt. Jag delade upp för aggressivt tidigt. packages/utils, packages/types och packages/ui räcker för att börja. Du kan alltid dela upp senare när ett paket blir ohanterligt. Att dela upp i förtid innebär fler package.json-filer att underhålla och fler kanter i din beroendesgraf.

  3. Sätt upp fjärrcachning dag ett. npx turbo login && npx turbo link tar 30 sekunder. Tidsbesparing på byggen under bara den första veckan motiverar det.

  4. Dokumentera workspace-kommandona. Nya utvecklare vet inte att pnpm --filter @acme/ui add lodash installerar till ett specifikt paket, eller att pnpm turbo build --filter=apps/web... bara bygger det som behövs. En enkel "Monorepo-fusklapp"-sektion i din bidragsguide sparar alla tid.

  5. Upprätthåll beroendeförriktningen från dag ett. Om du tillåter ens en import från en app till ett paket eroderar gränsen snabbt. Lägg till en lint-regel eller en CI-kontroll. Riktningen är apps -> packages -> packages. Aldrig tvärtom.

Monorepot är inte målet. Målet är att leverera funktioner utan att kämpa mot din egen kodbas. Turborepo är det lättaste verktyget jag hittat för att få det att fungera. Det gör uppgiftsgrafen, det gör cachningen, och det håller sig ur vägen för allt annat.

Börja enkelt. Lägg till komplexitet när repot kräver det. Och pinpointera dina portar.

Relaterade inlägg