Structuring monorepo for a full-stack SPA and Cloudflare Workers API
A practical guide for a SPA and a Hono Workers API in one repo. Workspace config, the shared package traps that got me, build ordering, and why I didn't bother with Turborepo.
Table of Contents
Every time I start a new full-stack project, I hit the same question on day one. Do I put the frontend and the backend in the same repo, or keep them separate?
I used to default to two repos. It felt cleaner. Then you need to share a type between them, and suddenly you’re copy-pasting interface User around, or publishing a tiny npm package nobody else will ever use.
For the kind of projects I build now, a React SPA on one side and a Hono API on Cloudflare Workers on the other, a monorepo is just the honest answer. One git clone, one pnpm install, and everything is there.
I reach for pnpm, not npm or Yarn, and I skip Turborepo on purpose. Let me walk through why.
Why pnpm
I’m not religious about package managers. I used npm for years without strong feelings about it.
pnpm changed that for me in two ways.
The first is the disk space thing, which sounds boring until you have six monorepos on your laptop. pnpm uses a content-addressable store. Every version of every package lives in one place on your disk. The node_modules tree is built with symlinks pointing at that store, and the actual files inside it are hard-linked from the store so they take no extra space. On my machine, a fresh install that would be 400MB with npm is closer to 80MB of actual disk use.
The second is workspaces. They work. npm and Yarn got there eventually, but pnpm’s implementation is the one I never have to fight. pnpm-workspace.yaml lists the folders, pnpm install at the root installs everything, and workspace:* in a package.json dependency means “use the local version from this repo, always.”
That last bit is the part I care about most. When my apps/web depends on @myapp/shared, I don’t want a published version. I want the one next to it on disk, and I want changes to propagate immediately.
There’s also the strictness. By default, pnpm only symlinks your declared dependencies into a package’s node_modules. If your apps/api imports zod but never listed it in its package.json, npm and Yarn shrug and let it work because a sibling package happens to have it hoisted. pnpm refuses. That caught real bugs for me, twice.
It’s not bulletproof if you turn on shamefully-hoist or change public-hoist-pattern, but on the defaults you get the behavior you want.
The layout I land on
Most of my repos end up looking something like this:
myapp/
├── apps/
│ ├── web/ # React SPA (Vite)
│ └── api/ # Hono on Cloudflare Workers
├── packages/
│ └── shared/ # types, zod schemas, a few utils
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.base.json
Two apps, one shared package. That’s it. I used to split shared into types, utils, and schemas, which felt tidy but meant every new constant needed a decision about where it lived. Now I just have one package and split it only when it actually gets uncomfortable.
The pnpm-workspace.yaml is three lines:
packages:
- "apps/*"
- "packages/*"
And the root package.json is mostly empty. It holds shared dev dependencies like TypeScript and maybe Prettier, plus a few convenience scripts.
{
"name": "myapp",
"private": true,
"scripts": {
"dev": "pnpm -r --parallel dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"typescript": "^5.6.0"
}
}
The -r flag means “recursive, run this in every workspace that has the script.” --parallel starts them at the same time. pnpm dev at the root now starts both the Vite dev server and wrangler dev together, which is the thing I want 95% of the time.
The shared package, and where I got bitten
This is the part I spent the most time untangling, so let me go through it slowly.
The packages/shared folder holds things both apps need. Types for API responses, zod schemas for validation, maybe a few pure helpers. Nothing runtime-heavy. Nothing app-specific.
// packages/shared/src/index.ts
import { z } from 'zod'
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.string().datetime(),
})
export type User = z.infer<typeof UserSchema>
The API uses the schema to validate request bodies. The SPA uses the type to shape its state. One source of truth, no drift.
Now for the parts that tripped me up.
The package.json shape matters more than you’d think. My first attempt looked like this:
{
"name": "@myapp/shared",
"version": "0.0.0",
"main": "src/index.ts"
}
That kind of works because Vite and Wrangler are both happy to resolve .ts files. But the moment you try to run a script directly with Node, or some other tool tries to read the package, things get weird. Different bundlers find different entry points. Some respect main, some look for module, some want exports.
What actually works reliably across Vite, Wrangler, Vitest, and tsc is the exports field:
{
"name": "@myapp/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
Pointing directly at the .ts source skips a build step entirely. The consumers (Vite and Wrangler) compile their own code anyway, so they happily compile this too. No tsc in a watch loop, no stale dist/ folders, no build ordering to worry about.
This only works because both apps compile their dependencies with a TypeScript-aware bundler. The moment you have a consumer that expects actual JavaScript, like Node running a script directly, or anything published to npm, you need a real build step in the shared package. But for an SPA + Workers API setup where the package stays internal and never gets published, you don’t.
TypeScript needs to know where to look. pnpm puts a symlink in node_modules/@myapp/shared pointing to packages/shared. Your runtime tools follow it. TypeScript sometimes does and sometimes doesn’t, depending on your tsconfig.json.
The fix is paths in the base tsconfig:
{
"compilerOptions": {
"moduleResolution": "bundler",
"paths": {
"@myapp/shared": ["./packages/shared/src/index.ts"]
}
}
}
A quick note if you’re on an older TypeScript. Before TS 6, this block needed a "baseUrl": "." alongside paths. TS 6 deprecated baseUrl (it’s scheduled to be removed in TS 7), and paths now works on its own with paths resolved relative to the tsconfig.json itself. If you’re upgrading an older repo, just delete the baseUrl line.
TS 6 also deprecated "moduleResolution": "node" in favor of "bundler" or "nodenext". For an SPA and a Workers API, "bundler" is the one you want since both Vite and Wrangler handle their own resolution.
And then each app extends it:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"types": ["vite/client"]
}
}
Without this, you get “cannot find module” errors in the editor even though the code runs fine. Frustrating, because everything looks broken but nothing actually is.
Version the dependency explicitly. In apps/web/package.json:
{
"dependencies": {
"@myapp/shared": "workspace:*"
}
}
workspace:* is the magic string. It tells pnpm “always use the local one.” If you forget this and just write "*", pnpm will try to find it on npm, fail, and break your install. Small thing. Easy to miss.
Build ordering
With the setup above, there isn’t really any.
The SPA builds itself. Vite sees the import of @myapp/shared, follows the symlink, and compiles the TypeScript as part of its own build. The Workers API does the same through Wrangler’s esbuild.
If you do split out a shared package that needs its own build (say, you add a package that publishes JS), pnpm has --filter for ordering:
pnpm --filter @myapp/shared build
pnpm --filter @myapp/api build
pnpm --filter @myapp/web build
pnpm’s recursive runs are already topologically sorted by default, so if you need strict sequential order you just cap the concurrency:
pnpm -r --workspace-concurrency=1 build
The sort comes from -r, not from --workspace-concurrency. Be aware that adding --parallel or --no-sort throws that ordering away.
But I’d push back on needing this. If you can get away with consuming .ts directly, do it. Less moving parts, less to go wrong in CI.
Why not Turborepo?
Turborepo is a solid piece of tooling. It caches task outputs, so if nothing in apps/api changed, it skips the build and reuses last time’s result. It understands dependencies between packages and runs things in the right order. For large monorepos with ten apps and thirty packages, that’s a real productivity win.
But for the kind of repo I’m describing (two apps, one shared package, maybe five minutes of full build time on a bad day), Turborepo solves a problem I don’t have.
I’d be adding:
- Another config file (
turbo.json) - A mental model of what’s cached and what isn’t
- A thing to debug when the cache gets weird
- Remote caching if I want it on CI, which means another service account
For saving maybe 30 seconds on a local build. The math doesn’t work out.
When Turborepo is the right call
I want to be fair. Turborepo is genuinely worth it when:
- You have more than three or four apps. The parallelism and dependency ordering start to matter.
- Your builds are slow. A minute or more per app. Caching pays for itself quickly.
- Your CI runs a lot. Remote caching across PR builds is where Turborepo really shines.
- You have shared packages that actually need a build step. Ordering matters more then.
If any of those hit, reach for it. You’ll feel the difference. For a two-app SPA-plus-API monorepo, pnpm’s built-in -r and --filter already do 90% of what you need.
The boring production story
This setup has survived two years of real work for me now. A few projects shipped, a few still in progress. Some observations that only show up with time:
- Onboarding is one command.
pnpm installand you’re set. No separate installs per app, no “don’t forget to run install inpackages/sharedfirst.” - Refactoring across the boundary is easy. Rename a field in the zod schema, TypeScript errors light up in both the API and the SPA. Fix them both in the same commit.
- CI is simple. A GitHub Action that runs
pnpm install && pnpm build && pnpm testat the root covers everything. No matrix, no per-app pipelines. - Deploys stay independent. The API goes to Cloudflare Workers via
wrangler deploy, the SPA goes to Cloudflare Pages (or anywhere else) via its own command. Same repo, separate deployments.
The last one is worth a second. A monorepo doesn’t mean monolithic deploy. I still ship the API and the SPA separately. The repo is just how I organize the code, not how I run it.
Wrapping up
Tooling choices should stay boring for as long as possible. pnpm workspaces are boring in the good way. They work, they don’t surprise you, and they scale up to more than you’d think without needing to bring in a build orchestrator.
If you’re starting a new SPA + Workers API project, try this shape first. One workspace, two apps, one shared package, pointing at .ts source directly. If you outgrow it, Turborepo is still there. You haven’t made anything hard to change.
Start simple. Add tools when you feel the pain, not before.