CodingNic

Project Setup & Data Layer

Scaffolding the Monorepo with AI

Project Setup & Data Layer 40 min read

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/shared does 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

code
I'm building SKYWATCH, a real-time ADS-B flight tracker. It has three distinct runtime pieces: a background poller that runs continuously, a WebSocket server, and a Next.js frontend. They don't belong in one process, but the frontend and backend need to share TypeScript types (aircraft state, watch regions, WebSocket message shapes) without duplicating them by hand. Set up an npm workspaces monorepo with three packages: - apps/web (Next.js, TypeScript, Tailwind, App Router) - apps/server (a plain Node/TypeScript backend, no framework yet) - packages/shared (the shared types package) Requirements: - packages/shared must be buildless: no compile step, no dist folder. Its package.json should point main/types/exports straight at the TypeScript source, so both consumers always see the latest types with nothing to rebuild. - Use plain npm workspaces, not Turborepo, Nx, or pnpm. This is three packages with cheap builds, not a dozen packages that need a task graph. - Root package.json needs a typecheck script that checks shared first, then server and web, so a broken shared type surfaces before unrelated errors in either consumer. - Give packages/shared one real starting export: an AircraftState interface with hex, flight, lat, lon. Show me the full file tree and every config file's contents.

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.

json
// 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"
  }
}
json
// 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"
  }
}
json
// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
ts
// packages/shared/src/index.ts
export * from "./aircraft.js";
ts
// packages/shared/src/aircraft.ts
export interface AircraftState {
  hex: string;
  flight?: string;
  lat?: number;
  lon?: number;
}
json
// 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"
  }
}
json
// 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

  1. Run the prompt above against your AI coding assistant of choice, in an empty repository.
  2. Before running anything, read every file it produced against the three checks above.
  3. Run npm install at the root, then npm run typecheck. It should succeed, trivially, since there’s almost nothing to check yet.
  4. Temporarily add import type { AircraftState } from "@skywatch/shared"; to a file in apps/server/src, and confirm your editor resolves it with no red squiggle. Do the same in apps/web/src.
  5. 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 (no dist), correct .js extensions in shared’s own imports, and dependency-ordered typecheck. 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.