Typescript Monorepo
Tools To Use
Section titled “Tools To Use”| Tool | Responsibility |
|---|---|
| pnpm | Dependency management and workspace linking |
| Turborepo | Task orchestration, build ordering and caching |
| TypeScript | Type safety throughout the repository |
| Next.js | Frontend with App Router |
| Fastify | Backend HTTP API |
| Zod | Runtime validation and shared data contracts |
| Biome | Linting and formatting |
| tsx | Fast TypeScript execution and development watching |
Project Structure
Section titled “Project Structure”your-monorepo/├── apps/│ ├── web/│ │ ├── src/│ │ │ └── app/│ │ ├── next.config.ts│ │ ├── package.json│ │ └── tsconfig.json│ ││ └── api/│ ├── src/│ │ └── server.ts│ ├── package.json│ └── tsconfig.json│├── packages/│ └── shared/│ ├── src/ │ ├── contracts/ # Shared API contracts │ │ ├── health.ts │ │ ├── auth.ts │ │ └── user.ts │ │ │ ├── types/ # Standalone shared TS types │ │ ├── common.ts │ │ └── pagination.ts │ │ │ ├── constants/ # Shared constants │ │ ├── api.ts │ │ └── index.ts │ │ │ ├── utils/ # Pure shared utilities │ │ └── index.ts │ │ │ └── index.ts # Public package exports │ ├── package.json └── tsconfig.json│├── biome.json├── tsconfig.base.json├── pnpm-workspace.yaml├── turbo.json├── package.json├── pnpm-lock.yaml└── .gitignoreInstall prerequisites and initialize the repository
Section titled “Install prerequisites and initialize the repository”Run all the commands in the project’s root.
mkdir your-monorepocd your-monorepo
git initpnpm init- Update the root
package.json:
{ "name": "your-monorepo", "version": "0.0.1", "private": true, "scripts": { "dev": "pnpm --filter @repo/shared build && turbo run dev", "build": "turbo run build", "check-types": "turbo run check-types", "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write ." }}- Create this file at the root:
pnpm-workspace.yaml
packages: - "apps/*" - "packages/*"- Create the initial directories:
mkdir -p apps/api/srcmkdir -p packages/shared/src- Add a root
.gitignore:
# Dependenciesnode_modules/
# Build outputsdist/.next/.turbo/*.tsbuildinfo
# Environment variables.env.env.*!.env.example
# OS.DS_Store
# Logs*.log- Initialize the Next.js frontend:
pnpm create next-app@latest apps/web \ --ts \ --tailwind \ --app \ --src-dir \ --no-linter \ --use-pnpm \ --skip-install \ --disable-git \ --yes- Edit
apps/web/package.json:
{ "name": "@repo/web", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "check-types": "next typegen && tsc --noEmit" } // keep other lines the same}Shared Package
Section titled “Shared Package”- Create the shared TypeScript configuration,
tsconfig.base.jsonat repository root:
// Each application can extend this common TypeScript rules while defining its own module resolution and compilation settings{ "compilerOptions": { "target": "ES2022", "strict": true, "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "resolveJsonModule": true }}- Create
packages/shared/package.json:
{ "name": "@repo/shared", "version": "0.0.0", "private": true, "type": "module", // uses native ESM "main": "./dist/index.js", "types": "./dist/index.d.ts", // defines the public API of your package "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, "files": ["dist"], // contains compiled JavaScript and TypeScript declarations "scripts": { "dev": "tsc --watch --preserveWatchOutput", // recompiles automatically when shared source files change "build": "tsc -p tsconfig.json", "check-types": "tsc --noEmit" }}- Configure TypeScript in
packages/shared/tsconfig.json:
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "src", "outDir": "dist", "declaration": true, "declarationMap": true, // helps your editor navigate from imports to the original shared TypeScript source "sourceMap": true, "noEmitOnError": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"]}- Add shared code and use Zod for a simple API response contract
packages/shared/src/index.ts:
import { z } from "zod";
// validates data at runtimeexport const healthResponseSchema = z.object({ status: z.literal("ok"), service: z.literal("api"),});
// provides TypeScript type checkingexport type HealthResponse = z.infer<typeof healthResponseSchema>;Backend
Section titled “Backend”- Initialize the Fastify backend and create
apps/api/package.json
{ "name": "@repo/api", "version": "0.0.0", "private": true, "type": "module", "scripts": { // --include watch path also makes the Fastify server restart when the compiled shared package changes "dev": "tsx watch --include \"../../packages/shared/dist/**/*\" src/server.ts", "build": "tsc -p tsconfig.json", "start": "node dist/server.js", "check-types": "tsc --noEmit" }}- Configure TypeScript
apps/api/tsconfig.json:
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "src", "outDir": "dist", "types": ["node"], "sourceMap": true, "noEmitOnError": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"]}- Create the Fastify application:
apps/api/src/app.ts
import Fastify from "fastify";import { healthResponseSchema } from "@repo/shared";
export function buildApp() { const app = Fastify({ logger: true, });
app.get("/health", async () => { return healthResponseSchema.parse({ status: "ok", service: "api", }); });
return app;}apps/api/src/server.ts:
import { buildApp } from "./app.js";
const app = buildApp();
const port = Number(process.env.PORT ?? 4000);
try { await app.listen({ port, host: "0.0.0.0", });} catch (error) { app.log.error(error); process.exit(1);}Install Dependencies
Section titled “Install Dependencies”- Install dependencies across the workspace:
# Root toolingpnpm add -Dw turbo @biomejs/biome
# Shared Packagepnpm --filter @repo/shared add zodpnpm --filter @repo/shared add -D typescript
# Fastify APIpnpm --filter @repo/api add fastifypnpm --filter @repo/api add -D typescript @types/node tsx
# Link the shared package to both applicationspnpm --filter @repo/web add '@repo/shared@workspace:*'pnpm --filter @repo/api add '@repo/shared@workspace:*'
# After running this command, You'll have one workspace lockfile at the repository rootpnpm installTurborepo
Section titled “Turborepo”- Create
turbo.jsonat the root:
{ "$schema": "https://turborepo.dev/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**", "!.next/dev/**"] }, "dev": { "cache": false, "persistent": true }, "check-types": { "dependsOn": ["^build"], "outputs": [] } }}- Initialize:
pnpm biome init- Add tailwndcss config to
biome.json:
"css": { "parser": { "tailwindDirectives": true }}Connect Next.js to Fastify
Section titled “Connect Next.js to Fastify”- Replace
apps/web/next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true, turbopack: { rules: { "*.css": { loaders: ["@tailwindcss/turbopack"], as: "*.css", }, }, }, allowedDevOrigins: ["127.0.0.1"], async rewrites() { return [ { source: "/api/:path*", destination: `${ process.env.API_INTERNAL_URL ?? "http://localhost:4000" }/:path*`, }, ]; },};
export default nextConfig;- Create
apps/web/.env.local:
API_INTERNAL_URL=http://localhost:4000- Replace
apps/web/src/app/page.tsx:
"use client";
import { useEffect, useState } from "react";import { healthResponseSchema, type HealthResponse } from "@repo/shared";
export default function Home() { const [health, setHealth] = useState<HealthResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => { const controller = new AbortController();
async function checkHealth() { try { const response = await fetch("/api/health", { signal: controller.signal, });
if (!response.ok) { throw new Error("API request failed"); }
const data = healthResponseSchema.parse(await response.json());
setHealth(data); } catch { if (!controller.signal.aborted) { setError("Could not connect to the API"); } } }
void checkHealth();
return () => controller.abort(); }, []);
return ( <main className="p-10"> <h1 className="text-3xl font-bold">TypeScript Monorepo</h1>
<p className="mt-4"> API Status:{" "} {error ?? (health ? `${health.service}: ${health.status}` : "Loading...")} </p> </main> );}- Run the complete monorepo:
pnpm dev-
and test everything:
Web: http://127.0.0.1:3000/ API: http://127.0.0.1:4000/health
Verify production builds
Section titled “Verify production builds”- Run:
pnpm lint:fixpnpm check-typespnpm build- After a successful build, you should have:
apps/├── web/│ └── .next/└── api/ └── dist/
packages/└── shared/ └── dist/ ├── index.js ├── index.d.ts └── ...Add CI with GitHub Actions
Section titled “Add CI with GitHub Actions”- Create
.github/workflows/ci.yml:
name: CI
on: push: pull_request:
jobs: checks: runs-on: ubuntu-latest
steps: - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 with: node-version: 26 cache: pnpm
- name: Install dependencies run: pnpm install --frozen-lockfile
- name: Lint run: pnpm lint
- name: Type check run: pnpm check-types
- name: Build run: pnpm buildAdd shadcn UI component
Section titled “Add shadcn UI component”- Initialize shadcn:
pnpm dlx shadcn@latest init --cwd apps/web --no-monorepo- Install shadcn’s cn dependency explicitly:
pnpm --filter @repo/web add cn- Add shadcn/ui component from inside apps/web:
cd apps/web
pnpm dlx shadcn@latest add <your-component-name>Architecture Decisions
Section titled “Architecture Decisions”| Practice | Recommendation |
|---|---|
| Dependency direction | web → shared ← api |
| Shared code | Keep it environment-independent |
| Validation | Use shared Zod schemas for API contracts |
| React components | Keep them in web or a dedicated ui package (packages/ui) |
| Database operations | Keep them in the API until another consumer justifies sharing |
| Secrets | Never export them from a shared browser-importable package |
| Internal imports | Use @repo/shared, not relative cross-package paths |
| Package management | One pnpm lockfile, explicit dependencies |
| Deployment | Deploy web and API independently |
| Testing | Add Fastify inject() tests and frontend tests as the app grows |
Useful development commands
Section titled “Useful development commands”| Command | What it does |
|---|---|
| pnpm dev | Start the entire monorepo |
| pnpm –filter @repo/web dev | Start only the frontend |
| pnpm –filter @repo/api dev | Start only Fastify |
| pnpm build | Build all projects |
| pnpm check-types | Check TypeScript across the repo |
| pnpm lint | Check code quality and formatting |
| pnpm lint:fix | Automatically fix supported issues |
| pnpm format | Format the entire repository |