Skip to content

Typescript Monorepo

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
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
└── .gitignore

Install prerequisites and initialize the repository

Section titled “Install prerequisites and initialize the repository”

Run all the commands in the project’s root.

  1. First, install Node.js and pnpm.

  2. Create the Repository:

Terminal window
mkdir your-monorepo
cd your-monorepo
git init
pnpm init
  1. 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 ."
}
}
  1. Create this file at the root: pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
  1. Create the initial directories:
Terminal window
mkdir -p apps/api/src
mkdir -p packages/shared/src
  1. Add a root .gitignore:
# Dependencies
node_modules/
# Build outputs
dist/
.next/
.turbo/
*.tsbuildinfo
# Environment variables
.env
.env.*
!.env.example
# OS
.DS_Store
# Logs
*.log
  1. Initialize the Next.js frontend:
Terminal window
pnpm create next-app@latest apps/web \
--ts \
--tailwind \
--app \
--src-dir \
--no-linter \
--use-pnpm \
--skip-install \
--disable-git \
--yes
  1. 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
}
  1. Create the shared TypeScript configuration, tsconfig.base.json at 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
}
}
  1. 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"
}
}
  1. 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"]
}
  1. Add shared code and use Zod for a simple API response contract packages/shared/src/index.ts:
import { z } from "zod";
// validates data at runtime
export const healthResponseSchema = z.object({
status: z.literal("ok"),
service: z.literal("api"),
});
// provides TypeScript type checking
export type HealthResponse = z.infer<typeof healthResponseSchema>;
  1. 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"
}
}
  1. 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"]
}
  1. 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);
}
  1. Install dependencies across the workspace:
Terminal window
# Root tooling
pnpm add -Dw turbo @biomejs/biome
# Shared Package
pnpm --filter @repo/shared add zod
pnpm --filter @repo/shared add -D typescript
# Fastify API
pnpm --filter @repo/api add fastify
pnpm --filter @repo/api add -D typescript @types/node tsx
# Link the shared package to both applications
pnpm --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 root
pnpm install
  1. Create turbo.json at 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": []
}
}
}
  1. Initialize:
Terminal window
pnpm biome init
  1. Add tailwndcss config to biome.json:
"css": {
"parser": {
"tailwindDirectives": true
}
}
  1. 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;
  1. Create apps/web/.env.local:
API_INTERNAL_URL=http://localhost:4000
  1. 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>
);
}
  1. Run the complete monorepo:
Terminal window
pnpm dev
  1. Run:
Terminal window
pnpm lint:fix
pnpm check-types
pnpm build
  • After a successful build, you should have:
apps/
├── web/
│ └── .next/
└── api/
└── dist/
packages/
└── shared/
└── dist/
├── index.js
├── index.d.ts
└── ...
  1. 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 build
  1. Initialize shadcn:
Terminal window
pnpm dlx shadcn@latest init --cwd apps/web --no-monorepo
  1. Install shadcn’s cn dependency explicitly:
Terminal window
pnpm --filter @repo/web add cn
  1. Add shadcn/ui component from inside apps/web:
Terminal window
cd apps/web
pnpm dlx shadcn@latest add <your-component-name>
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
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