Scaffolding the Monorepo with AI
Objectives
By the end of this chapter, you should be able to:
- Write a prompt that gets an AI coding assistant to scaffold a real npm workspaces monorepo, not a plausible-looking one
- Recognize when an AI assistant defaults to a build step
packages/shareddoes not need, and steer it back to buildless - Catch a subtle but real AI mistake in ESM import extensions, and the one-line follow-up prompt that fixes it
- Confirm the AI’s output actually type-checks across all three packages before treating the step as done
💡 Why this matters: An AI assistant will scaffold a monorepo in seconds, and it will look right. The only way to tell whether it is right is to already know what right looks like, and to check the specific places assistants tend to guess wrong. This lesson is the template for every lesson after it: prompt, output, review.
Know the Shape Before You Prompt It
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 still need to agree on shapes, like what an aircraft’s live state looks like, so packages/shared exists to hold those types once instead of letting them drift out of sync in two hand-copied places.
You need this rationale before you prompt anything, not after, because an AI assistant asked to “set up a monorepo” has no way to know why you want one, and might reasonably suggest one Next.js app with an API folder instead. If it does, that is not the AI being wrong. That is the AI correctly pointing out you didn’t tell it the actual constraint yet.
The Prompt
What It Built
An AI assistant given that prompt, on a good day, produces something close to this. Read it the way you’d read a pull request, not the way you’d read a tutorial.
// package.json (root)
{
"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",
"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"
}
}
// 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"]
}
// 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;
}
// 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"]
}
apps/web gets bootstrapped with create-next-app, then @skywatch/shared added to its package.json dependencies the same way as the server. Next.js resolves the workspace package through the node_modules symlink directly, so unlike the server it needs no explicit paths entry.
Review This
Three things worth checking by hand, because they’re exactly the kind of detail a confident AI summary glosses over.
Is packages/shared actually buildless, or did it sneak in a build step? Some assistants default to a tsup or tsc --build setup for a shared package, because that’s the more common pattern in their training data, and it works, right up until you change a type in shared and the app you’re testing against is still running the stale compiled output. Check package.json’s main/types/exports fields point at ./src/..., not ./dist/.... If they point at dist, the follow-up prompt is short: “packages/shared should be buildless, no dist folder. Point main/types/exports at the TypeScript source directly and remove the build step.”
Did it use .js extensions in the shared package’s own imports? Look at index.ts: export * from "./aircraft.js", not "./aircraft.ts". That’s not a typo, it’s Node ESM’s Bundler/NodeNext resolution convention: imports reference the file extension that would be emitted, even though this package never actually emits anything. Both extensions happen to type-check today under moduleResolution: "Bundler", which is exactly why this mistake is easy to miss. It’s also exactly the kind of thing an AI assistant gets right about half the time, because both look equally plausible to something pattern-matching on TypeScript source rather than reasoning about Node’s actual resolution algorithm.
Does typecheck actually run in dependency order? Open the root package.json and confirm shared runs before server and web in the &&-chained script, not after. If a broken shared type were to ship, you want that error first, not buried under a hundred unrelated errors in both consumers.
None of these three are things npm install && npm run typecheck will catch on a fresh scaffold, because there’s almost nothing to type-check yet. They only bite you two or three modules from now, once real code depends on the choice being right. Catching them here, while the fix is a one-line prompt instead of a mid-project refactor, is the entire point of reviewing scaffolding output instead of just running it.
Try It
- Run the prompt above against your AI coding assistant of choice, in an empty repository.
- Before running anything, read every file it produced against the three checks above.
- Run
npm installat the root, thennpm run typecheck. It should succeed, trivially, since there’s almost nothing to check yet. - Temporarily add
import type { AircraftState } from "@skywatch/shared";to a file inapps/server/src, and confirm your editor resolves it with no red squiggle. Do the same inapps/web/src. - Delete both temporary imports once confirmed. Modules 2 through 6 use this import for real.
Recap
- A monorepo prompt needs the reason for the split (three runtime concerns, shared types), not just the shape, or the AI has no basis for the decisions underneath it.
- The three checks that matter here: buildless
packages/shared(nodist), correct.jsextensions in shared’s own imports, and dependency-orderedtypecheck. All three type-check fine even when wrong, which is exactly why they need a human read, not just a green build. - This prompt, output, review shape is the template for the rest of the course. Every later lesson follows it.
Next lesson: prompting the Postgres schema into existence, and verifying the database-level invariants actually landed.