How We Structure Full-Stack Monorepos for Client Projects
Every client project we take on starts with a question: how do we organize the code so that a small team can move fast without tripping over itself? After shipping over a dozen products, we have landed on a monorepo structure powered by Turborepo that has held up across projects as different as LancerSpace (a freelancer workspace with CRM, proposals, invoices, and project management) and Trackelio (a customer feedback platform with public boards, voting, and internal triage).
This post walks through our actual structure, the decisions behind it, and the config files that make it work.
Why Monorepos in the First Place
The alternative is multiple repositories. We have done that. It works fine until you need to share types between your API and your frontend, or you want a single CI pipeline that tests everything before deploy, or you realize your design tokens live in a Figma export that three repos all copy-paste differently.
A monorepo solves these problems by colocating code that ships together. For a typical client project we have at minimum a web frontend, a backend or edge functions layer, and shared code (types, validation schemas, utilities). Putting those in one repository means:
- Shared TypeScript types are imported, not duplicated.
- One pull request can update an API endpoint and the frontend that calls it.
- CI runs once, tests everything, and deploys atomically.
- Onboarding a new developer means cloning one repo.
We are not building Google-scale infrastructure. We are a team of four senior engineers building products for startups. Monorepos at our scale are about developer ergonomics, not organizational politics.

The Top-Level Structure
Here is the directory layout we start with on every new project:
project-root/
apps/
web/ # SvelteKit or Astro frontend
api/ # Supabase Edge Functions or standalone API
mobile/ # Flutter app (when applicable)
packages/
shared/ # Shared types, validation, constants
ui/ # Shared UI components (when we have multiple web apps)
db/ # Database types, migrations, seed scripts
config/ # Shared ESLint, Prettier, TypeScript configs
turbo.json
package.json
pnpm-workspace.yaml
The apps/ directory holds deployable applications. The packages/ directory holds internal libraries that apps import. This separation is not arbitrary. It maps to a real distinction: apps have entry points and deploy targets. Packages are dependencies that never deploy on their own.
For LancerSpace, the apps/ directory contained a SvelteKit web app and a set of Supabase Edge Functions. The packages/shared directory held TypeScript types for proposals, invoices, and client records that both the frontend forms and the API validation needed to agree on.
For Trackelio, we had a similar split but with an additional packages/email package that contained email templates shared between the API (for sending) and the web app (for previewing).
Workspace Configuration
We use pnpm workspaces. The pnpm-workspace.yaml file is simple:
packages:
- "apps/*"
- "packages/*"
The root package.json defines workspace-level scripts and shared dev dependencies:
{
"name": "project-root",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"check": "turbo check",
"test": "turbo test",
"db:migrate": "pnpm --filter @project/db migrate",
"db:seed": "pnpm --filter @project/db seed",
"db:types": "pnpm --filter @project/db generate"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.7.0"
},
"packageManager": "pnpm@9.15.0"
}
Each package has its own package.json with a scoped name. For example, packages/shared/package.json:
{
"name": "@project/shared",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"check": "tsc --noEmit",
"lint": "eslint src/"
},
"devDependencies": {
"@project/config": "workspace:*",
"typescript": "^5.7.0"
}
}
The workspace:* protocol tells pnpm to resolve @project/config from the local workspace, not from npm. This is how packages reference each other without publishing anything.
Turborepo Configuration
Turborepo handles task orchestration. It understands the dependency graph between packages and runs tasks in the right order with aggressive caching. Our turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".svelte-kit/**", "dist/**", ".astro/**"]
},
"dev": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
"check": {
"dependsOn": ["^build"]
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"db:generate": {
"cache": false
}
}
}
The dependsOn: ["^build"] syntax means “before building this package, build all of its workspace dependencies first.” So if apps/web depends on @project/shared, Turborepo builds shared before web automatically.
The outputs array tells Turborepo what to cache. On subsequent runs, if the inputs have not changed, Turborepo skips the build entirely and restores from cache. This cuts our CI times roughly in half on unchanged packages.

The Shared Package
The most important package in our monorepos is packages/shared. It typically contains:
packages/shared/src/
types/
user.ts
organization.ts
billing.ts
schemas/
user.schema.ts
organization.schema.ts
constants/
plans.ts
permissions.ts
utils/
format.ts
dates.ts
index.ts
We use Zod for validation schemas that serve double duty as runtime validators and TypeScript type sources:
// packages/shared/src/schemas/feedback.schema.ts
import { z } from "zod";
export const createFeedbackSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).optional(),
category: z.enum(["bug", "feature", "improvement"]),
boardId: z.string().uuid(),
});
export type CreateFeedbackInput = z.infer<typeof createFeedbackSchema>;
This schema is imported by the API to validate incoming requests and by the frontend to validate form inputs. One definition, two consumers, zero drift. We wrote more about this approach in our post on API design patterns.
The Database Package
For projects using Supabase, the packages/db package holds generated types and migration helpers:
// packages/db/src/types.ts
// Auto-generated by Supabase CLI: supabase gen types typescript
export type Database = {
public: {
Tables: {
feedback: {
Row: {
id: string;
title: string;
description: string | null;
category: "bug" | "feature" | "improvement";
board_id: string;
created_at: string;
votes_count: number;
};
Insert: Omit<Database["public"]["Tables"]["feedback"]["Row"], "id" | "created_at" | "votes_count">;
Update: Partial<Database["public"]["Tables"]["feedback"]["Insert"]>;
};
};
};
};
We generate these types from the live database schema using the Supabase CLI, then commit them. The generation script lives in the root package.json as db:types. This keeps our TypeScript types perfectly aligned with the actual database columns. We dig deeper into our Supabase setup in Building Real-Time Features with Supabase.
Deploy Pipelines
Each app in apps/ has its own deploy target. Our typical setup:
- Web frontend: Deployed to Vercel or Cloudflare Pages. The build command is
turbo build --filter=webwhich buildsweband all its dependencies. - API / Edge Functions: Deployed via Supabase CLI or to Cloudflare Workers. The build command is
turbo build --filter=api. - Mobile: Built locally or via CI with Flutter. The shared types are consumed through a code generation step.
Our CI pipeline in GitHub Actions looks roughly like this:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm check
- run: pnpm lint
- run: pnpm test
- run: pnpm build
Turborepo’s caching means that if a PR only touches apps/web, the packages/shared build step is a cache hit. The entire pipeline finishes in under two minutes for most changes.

For deploy, we use Vercel’s built-in monorepo support. Vercel detects the root turbo.json and runs the filtered build. We set the root directory to apps/web in the Vercel dashboard and it handles the rest.
Conventions That Keep It Clean
Structure is not enough. We enforce a few conventions:
No cross-app imports. Apps never import from other apps. If apps/web and apps/api both need something, it goes in a package. This rule is easy to enforce because TypeScript path resolution will simply fail if you try to import from a sibling app directory.
Packages export through an index. Every package has an src/index.ts barrel file. External consumers import from the package name, never from internal paths. This lets us refactor internals without breaking consumers.
Consistent naming. All packages use the @project/ scope. All scripts follow the same names: dev, build, check, lint, test. When you switch between projects, muscle memory works.
Config sharing. ESLint, Prettier, and TypeScript configs live in packages/config and are extended by each app. No config drift between apps in the same project.
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}
Each app extends this:
{
"extends": "@project/config/tsconfig.base.json",
"compilerOptions": {
"paths": {
"$lib/*": ["./src/lib/*"]
}
},
"include": ["src"]
}
When We Do Not Use a Monorepo
Not every project warrants this setup. If a client needs a single-page marketing site or a simple landing page, we use a standalone Astro project. If the project is purely a Flutter mobile app with a Supabase backend and no custom API layer, the monorepo adds ceremony without value.
The threshold for us is: does the project have two or more deployable units that share code? If yes, monorepo. If no, keep it simple.
What We Have Learned
After using this structure across LancerSpace, Trackelio, MindHyv, and several other projects, a few lessons stand out:
The shared package grows faster than you expect. Start with types and schemas, and resist the urge to dump utilities in there early. Let duplication exist between apps until a pattern repeats three times, then extract.
Turborepo caching is only valuable if your cache keys are right. Misconfigured outputs or missing inputs will cause stale builds that waste hours to debug. Test your caching by changing a file in a package and verifying that dependents rebuild.
pnpm is non-negotiable for us. npm and yarn workspaces work, but pnpm’s strict dependency resolution catches phantom dependency issues that the others silently allow. When a package works locally but fails in CI, it is almost always a phantom dependency, and pnpm prevents that class of bug entirely.
This is not a novel architecture. It is a boring, reliable one. That is the point. When we start a new client project, we want to spend our energy on the product, not on inventing a build system. The monorepo structure gives us that.
If you are building a product that spans multiple surfaces and you want a team that has done this before, reach out at hello@threshline.com.