Monorepo wiring

pnpm workspaces, turbo, go module.

7 minmedium

Five surfaces in one repo means five build systems pretending to be one. This lesson is the plumbing β€” how pnpm, Turbo, and the Go module work together so you don't end up running every command in every folder.

pnpm-workspace.yaml β€” declare your TS packages

pnpm-workspace.yaml
packages:
- "apps/web"
- "apps/admin"
- "apps/mobile"
- "apps/desktop/frontend"
- "packages/*"

Note: desktop is special. Wails has its own Go module at apps/desktop/ AND a React frontend at apps/desktop/frontend/. Only the frontend is in the pnpm workspace.

Importing shared types

Every TS package declares @my-saas/shared as a dependency. pnpm symlinks it so changes are picked up immediately:

apps/web/package.json
{
"name": "@my-saas/web",
"dependencies": {
"@my-saas/shared": "workspace:*"
}
}

Now in any TS file:

import { Product, ProductSchema } from '@my-saas/shared'

Same import, same path, in web / admin / mobile / desktop. The shared package becomes muscle memory.

turbo.json β€” the build orchestrator

turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {},
"type-check": {}
}
}

Now pnpm dev at the root runs every package's dev script in parallel, and pnpm build builds them in the right order based on dependencies.

The Go side β€” separate modules

Go doesn't play in the pnpm workspace. There are two Go modules:

  • apps/api/go.mod β€” the API
  • apps/desktop/go.mod β€” the Wails desktop wrapper

They're independent on purpose β€” the API ships as a Docker image; desktop ships as a native binary. Pinning their Go deps separately keeps them clean.

Why not one big go.mod? The API and the desktop wrapper need different deps β€” the API uses Gin + GORM; desktop uses Wails. Sharing a go.mod would force you to vendor all of Wails into your server image. Separate modules keep each binary small.

Running it all from the root

# Boots web + admin + mobile + desktop frontend β€” Turbo handles parallelism
pnpm dev
# API runs separately (Turbo doesn't manage Go)
make api # or: cd apps/api && go run cmd/server/main.go
# Or use the included Procfile + overmind:
overmind start

The Procfile that ships with the scaffold defines every service. Install overmind once and you boot the whole monorepo with one command.

The .env split

One .env at the root holds shared values (DATABASE_URL, JWT_SECRET). Each app reads what it needs. Mobile is special β€” it can't read root .env files at runtime, so its values are baked at build time via EXPO_PUBLIC_* vars.

.env
# Shared by API and frontends
DATABASE_URL=postgres://...
JWT_SECRET=...
# Frontend-side overrides
NEXT_PUBLIC_API_URL=http://localhost:8080
EXPO_PUBLIC_API_URL=http://192.168.1.10:8080

Quick check

You add a new field to packages/shared/types/product.ts. What needs to happen for web / admin / mobile / desktop to see it?

Try it

Prove the workspace works:

  1. Edit packages/shared/types/user.ts β€” add nickname: string to the User type.
  2. Save. In the web app, type user. in any tsx file β€” autocomplete should show nickname.
  3. Same check in admin, mobile, desktop. All four see it immediately.
  4. Revert the change (we'll add fields properly via grit sync in the next chapter).

What's next

Chapter 2 β€” Shared Types Everywhere. We stop hand-editing the shared package and use grit sync to generate it from Go.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β€” suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub