Monorepo Setup
Objectives
By the end of this chapter, you should be able to:
- Explain why SKYWATCH is three packages instead of one Next.js app with an API folder
- Set up an npm workspaces monorepo:
apps/web,apps/server,packages/shared - Get a shared, buildless TypeScript package working across both apps
๐ก Why this matters: Everything you build from here on lives in one of these three packages, and the shared package is how the frontend and backend stay in sync on types without duplicating them by hand.
Why a Monorepo, and Why These Three Packages
SKYWATCH has a background process (the poller) that never stops running, a WebSocket server, and a frontend โ three different runtime concerns that don’t belong in one Next.js process. But the frontend and backend agree on a lot of shapes: what an aircraft’s live state looks like, what a watch region is, what a WebSocket message looks like. Duplicating those types in two places is how a frontend and backend quietly drift out of sync โ the frontend expects altBaroFt and the backend renamed it to altitudeFt six weeks ago and nobody’s TypeScript compiler caught it because there were two separate, hand-copied type definitions.
packages/shared solves this with zero build step: it’s not compiled to JavaScript and published anywhere, its package.json just points main/types/exports straight at the TypeScript source, and each consumer’s tsconfig.json (or, for the web app, package.json workspace resolution) reads that source directly. A change to a shared type is instantly visible to both tsc invocations โ there’s no “remember to rebuild shared first” step to forget.
npm workspaces (not Turborepo, Nx, or pnpm workspaces) is intentionally the simplest tool that solves the actual problem here: three packages, no need for a build cache or task orchestration graph. If this project grew to a dozen packages with expensive builds, that calculus would change โ but reach for the simpler tool until the project actually needs the more complex one.
Root Skeleton
mkdir skywatch && cd skywatch
mkdir -p apps/web apps/server packages/shared/src
Root package.json
{
"name": "skywatch",
"version": "0.1.0",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "concurrently -n server,web -c auto \"npm run dev -w @skywatch/server\" \"npm run dev -w @skywatch/web\"",
"dev:web": "npm run dev -w @skywatch/web",
"dev:server": "npm run dev -w @skywatch/server",
"db:up": "docker compose up -d postgres",
"db:down": "docker compose down",
"db:migrate": "npm run db:migrate -w @skywatch/server",
"db:seed:airports": "npm run db:seed:airports -w @skywatch/server",
"typecheck": "npm run typecheck -w @skywatch/shared --if-present && npm run typecheck -w @skywatch/server && npm run typecheck -w @skywatch/web --if-present",
"build": "npm run typecheck && npm run build -w @skywatch/web",
"lint": "npm run lint -w @skywatch/web && npm run lint -w @skywatch/server"
},
"devDependencies": {
"concurrently": "^10.0.5"
}
}
Two things worth noticing before you type this in. First, typecheck runs the three packages in dependency order โ shared first, since if a shared type is broken, you want that error before wading through a hundred unrelated errors in the two consumers. Second, build runs typecheck first and then next build โ a type error should fail the build before Next.js spends 30 seconds bundling code that’s wrong anyway.
Run npm install once, at the root, for the whole workspace.
packages/shared
// packages/shared/package.json
{
"name": "@skywatch/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
}
}
// packages/shared/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
main/types/exports all pointing at raw .ts source is the buildless part. noEmit: true is deliberate too โ this package is never compiled on its own, only type-checked; its source is consumed directly by whichever bundler (Next.js’s, or tsx for the server) is building the thing that imports it.
Start it with one placeholder export so both consumers have something real to import in the next module:
// packages/shared/src/index.ts
export * from "./aircraft.js";
// packages/shared/src/aircraft.ts
export interface AircraftState {
hex: string;
flight?: string;
lat?: number;
lon?: number;
}
(Yes, .js extensions in a .ts file’s import โ that’s Node ESM’s moduleResolution: "Bundler"/NodeNext convention: imports reference the emitted file extension, not the source one, even though nothing here ever actually emits anything. It’s easy to typo as .ts out of habit; both will typecheck under Bundler resolution today, but only .js matches what real Node ESM resolution expects, so use .js consistently.)
apps/server โ Package and tsconfig Only for Now
// apps/server/package.json
{
"name": "@skywatch/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc -p tsconfig.json",
"start": "tsx src/index.ts"
},
"dependencies": {
"@skywatch/shared": "*"
},
"devDependencies": {
"@types/node": "^20",
"tsx": "^4.19.2",
"typescript": "^5"
}
}
// apps/server/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@skywatch/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"]
}
The paths mapping is what lets tsc (and your editor) resolve @skywatch/shared straight to source for type-checking purposes; at runtime, tsx resolves the same specifier through the npm workspace symlink in node_modules (created by npm install because of "@skywatch/shared": "*" in dependencies) โ same destination, two different mechanisms for two different tools, both landing on the same source file.
"@skywatch/shared": "*" as the version means “whatever version of the workspace package is present” โ npm workspaces resolves this to a symlink rather than fetching anything from a registry.
apps/web โ Bootstrap with create-next-app, Then Wire It In
cd apps/web
npx create-next-app@latest . --typescript --tailwind --app --no-src-dir=false --import-alias "@/*"
Add the shared package as a dependency the same way:
// apps/web/package.json (add to dependencies)
"@skywatch/shared": "*"
Run npm install at the root again to link it. create-next-app already set up @/* โ ./src/* in apps/web/tsconfig.json; @skywatch/shared resolves separately, through the workspace symlink, needing no additional paths entry on the web side (unlike the server, Next.js’s bundler resolves workspace packages through node_modules directly rather than needing an explicit TS paths alias โ the server needed one because tsx type-checks against tsconfig.json paths in a way Next’s bundler doesn’t require).
Try It
- From the repo root, run
npm run typecheck. It should succeed (trivially โ there’s almost nothing to check yet) across all three packages. - Temporarily add
import type { AircraftState } from "@skywatch/shared";to a file inapps/server/srcand confirm your editor resolves it with no red squiggle. - Do the same in a file in
apps/web/src, confirming it resolves there too. - Delete both temporary imports once confirmed โ Modules 02โ06 will use this import for real.
Recap
- SKYWATCH is three packages โ
apps/web,apps/server,packages/sharedโ because the poller, WebSocket server, and frontend are different runtime concerns that don’t belong in one process. packages/sharedis buildless:package.json’smain/types/exportspoint straight at.tssource, so both consumers always see the latest shared types with no rebuild step to forget.- npm workspaces is the simplest tool that fits three packages with no expensive builds โ reach for Turborepo/Nx only once the project actually needs a build cache or task graph.
- The root
typecheckscript runssharedfirst, thenserverandweb, so a broken shared type surfaces before either consumer’s unrelated errors do;buildalways runstypecheckbeforenext build. - Imports into the shared package use
.jsextensions, even in.tsfiles โ that’s the Node ESM/Bundlerresolution convention for the emitted file extension, not a typo.
Next lesson: modeling the database schema with Drizzle and running your first migration.