
Managing a frontend and backend in separate repositories sounds clean until you need to keep shared types in sync, coordinate deployments, or run both apps locally with a single command. A monorepo solves all of that by keeping everything in one place without sacrificing independent deployability.
This guide walks you through building a production-grade monorepo from scratch using Turborepo for task orchestration, Next.js for the frontend, and Express + Prisma for the backend. Shared TypeScript and ESLint config packages remove duplication across apps. You will end up with a workspace where pnpm dev or npm run dev (depending on your preferred package manager) starts both apps simultaneously with proper caching and filtering.
Verify your setup:
$ node -v
$ pnpm -v # if using pnpm
$ npm -v # if using npm
$ git --version
Note: This guide shows both pnpm and npm commands side by side wherever they differ. pnpm is preferred for monorepos because of its efficient symlinking and workspace protocol, but npm workspaces work well too. Pick one and stay consistent throughout your project.
By the end of this guide you will have a working monorepo with this structure:
.
├── package.json
├── pnpm-lock.yaml # or package-lock.json for npm
├── pnpm-workspace.yaml # pnpm only — npm uses package.json "workspaces"
├── turbo.json
├── apps/
│ ├── backend/ # Express + Prisma + Jest
│ └── frontend/ # Next.js 15 with App Router
└── packages/
├── eslint-config/ # shared lint rules
└── typescript-config/ # shared tsconfig presets
Create the project directory and initialize the root package.
$ mkdir my-monorepo
$ cd my-monorepo
Using pnpm:
$ pnpm init
Using npm:
$ npm init -y
Replace the generated package.json with the following. This is the control center for your entire workspace — root scripts call Turborepo, which fans out to every app.
pnpm version:
{
"name": "my-monorepo",
"version": "0.0.1",
"private": true,
"packageManager": "pnpm@10.29.2",
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"test": "turbo test",
"lint": "turbo lint",
"format": "prettier --write \"**/*.{ts,tsx,js,mjs,json,md,css}\" --ignore-path .gitignore",
"format:check": "prettier --check \"**/*.{ts,tsx,js,mjs,json,md,css}\" --ignore-path .gitignore",
"clean": "turbo clean && find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +"
},
"devDependencies": {
"prettier": "^3.8.1",
"turbo": "^2.8.17"
},
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma",
"sharp",
"unrs-resolver"
]
}
}
npm version (remove the "packageManager" and "pnpm" keys, add "workspaces"):
{
"name": "my-monorepo",
"version": "0.0.1",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"test": "turbo test",
"lint": "turbo lint",
"format": "prettier --write \"**/*.{ts,tsx,js,mjs,json,md,css}\" --ignore-path .gitignore",
"format:check": "prettier --check \"**/*.{ts,tsx,js,mjs,json,md,css}\" --ignore-path .gitignore",
"clean": "turbo clean"
},
"devDependencies": {
"prettier": "^3.8.1",
"turbo": "^2.8.17"
}
}
Now install the root tooling:
pnpm:
$ pnpm add -D prettier turbo
npm:
$ npm install --save-dev prettier turbo
This step tells your package manager which directories contain workspace packages.
pnpm — create pnpm-workspace.yaml at the root:
packages:
- apps/*
- packages/*
ignoredBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- esbuild
- prisma
- sharp
- unrs-resolver
npm — no extra file needed. The "workspaces" field you already added to package.json in Step 1 does the same job. You can skip this step entirely.
Note: The
ignoredBuiltDependencieslist in the pnpm config prevents pnpm from running post-install build scripts for these packages automatically. npm does not have an equivalent — those packages will build normally, which is fine in most cases.
Create turbo.json at the root. This file defines how Turborepo runs tasks across all packages — including dependency ordering, caching behavior, and which tasks run persistently.
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"format": {
"cache": false
},
"test": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env.test"],
"outputs": ["coverage/**"],
"cache": false
},
"clean": {
"cache": false
}
}
}
Key behaviors to understand:
"dependsOn": ["^build"] — before building a package, Turborepo builds all of its workspace dependencies first."persistent": true on dev — keeps long-running dev servers (Next.js, tsx watch) alive."inputs" including .env* — if your environment file changes, Turborepo invalidates the cache and reruns the task.$ mkdir -p apps/backend apps/frontend
$ mkdir -p packages/eslint-config packages/typescript-config
$ mkdir -p doc
Your root should now look like:
.
├── apps/
│ ├── backend/
│ └── frontend/
├── doc/
├── packages/
│ ├── eslint-config/
│ └── typescript-config/
├── package.json
├── pnpm-workspace.yaml # pnpm only
└── turbo.json
This package eliminates duplicated tsconfig.json settings. Both apps extend from it, so changing strictness rules or a compiler option in one place propagates everywhere.
Create packages/typescript-config/package.json:
{
"name": "@repo/typescript-config",
"version": "0.0.1",
"private": true,
"license": "MIT",
"exports": {
"./base.json": "./base.json",
"./nextjs.json": "./nextjs.json",
"./node.json": "./node.json"
}
}
Create packages/typescript-config/base.json — the foundation shared by all presets:
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"incremental": true
}
}
Create packages/typescript-config/nextjs.json — used by apps/frontend:
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"jsx": "preserve",
"plugins": [{ "name": "next" }]
}
}
Create packages/typescript-config/node.json — used by apps/backend:
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "CommonJS",
"moduleResolution": "node"
}
}
This package gives both apps a consistent set of lint rules without copy-pasting ESLint configs.
Create packages/eslint-config/package.json:
{
"name": "@repo/eslint-config",
"version": "0.0.1",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
"./next": "./next.mjs",
"./node": "./node.mjs"
},
"peerDependencies": {
"@eslint/eslintrc": "^3",
"@eslint/js": "^9",
"eslint": "^9",
"eslint-config-next": "*",
"typescript-eslint": "^8"
},
"peerDependenciesMeta": {
"eslint-config-next": {
"optional": true
}
}
}
Create packages/eslint-config/next.mjs — for the Next.js frontend:
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const nextConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
ignores: [
'node_modules/**',
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
],
},
];
export default nextConfig;
Create packages/eslint-config/node.mjs — for the Express backend:
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
const nodeConfig = defineConfig(
eslint.configs.recommended,
tseslint.configs.strict,
tseslint.configs.stylistic,
{
ignores: ['node_modules/**', 'dist/**', 'build/**'],
rules: {
'no-console': 'warn',
},
}
);
export default nodeConfig;
Each app references this package using workspace:* (pnpm) or * (npm), so there is no published registry dependency needed.
Scaffold the Next.js app inside apps/frontend:
pnpm:
$ pnpm create next-app@latest apps/frontend --ts --eslint --app --src-dir --use-pnpm --import-alias "@/*"
npm:
$ npx create-next-app@latest apps/frontend --ts --eslint --app --src-dir --import-alias "@/*"
Note: The
--use-pnpmflag tells the Next.js scaffolder which package manager to use internally. Omit it when using npm.
Replace the generated apps/frontend/package.json with:
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"format": "prettier --write .",
"lint": "eslint"
},
"dependencies": {
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@mui/material-nextjs": "^7.3.7",
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.13.4",
"clsx": "^2.1.1",
"next": "15.5.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-redux": "^9.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.7",
"eslint-plugin-react-hooks": "^7.0.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
npm users: Replace
"workspace:*"with"*"for@repo/eslint-configand@repo/typescript-config. npm workspaces use"*"to resolve internal packages.
Create or update apps/frontend/tsconfig.json:
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Create apps/frontend/eslint.config.mjs:
import nextConfig from '@repo/eslint-config/next';
export default nextConfig;
Create the backend folder structure:
$ mkdir -p apps/backend/src/{config,errors,interfaces,middlewares,modules,routes,tests,utils}
$ mkdir -p apps/backend/prisma
Create apps/backend/package.json:
{
"name": "backend",
"version": "1.0.0",
"description": "A backend project using Prisma ORM with Jest testing framework",
"main": "index.js",
"scripts": {
"test": "NODE_ENV=test jest --runInBand",
"dev": "tsx watch src/server.ts",
"build": "npx eslint ./src && tsc",
"start": "node ./dist/server.js",
"postinstall": "prisma generate",
"lint": "npx eslint ./src",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev --name init",
"prisma:studio": "prisma studio",
"prisma:db-push": "prisma db push",
"prisma:db-reset": "prisma migrate reset --force",
"prisma:migrate-test": "NODE_ENV=test prisma migrate dev --name init",
"prisma:generate-test": "NODE_ENV=test prisma generate",
"prisma:db-reset-test": "NODE_ENV=test prisma migrate reset --force"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-session": "^1.18.2",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/multer": "^2.0.0",
"@types/node": "^25.1.0",
"@types/pg": "^8.16.0",
"@types/supertest": "^6.0.3",
"eslint": "^9.39.2",
"jest": "^30.2.0",
"jest-mock-extended": "^4.0.0",
"prisma": "^6.17.1",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0"
},
"dependencies": {
"@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3",
"cloudinary": "^2.9.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"express-session": "^1.19.0",
"http-status-codes": "^2.3.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"multer": "^2.0.2",
"pg": "^8.18.0",
"prettier": "^3.8.1",
"slugify": "^1.6.6",
"zod": "^4.3.6"
}
}
npm users: Replace
"workspace:*"with"*"for the two@repo/*packages.
Create apps/backend/tsconfig.json:
{
"extends": "@repo/typescript-config/node.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
Create apps/backend/eslint.config.mjs:
import nodeConfig from '@repo/eslint-config/node';
export default nodeConfig;
Prisma needs to know which database URL to use based on the current environment. Create apps/backend/prisma.config.ts:
import { defineConfig } from 'prisma/config';
import dotenv from 'dotenv';
// Load environment-specific file first (e.g. .env.test), then fall back to .env
const envFile = process.env['NODE_ENV']
? `.env.${process.env['NODE_ENV']}`
: undefined;
dotenv.config({ path: envFile });
dotenv.config();
const getDatabaseUrl = () => {
if (process.env['NODE_ENV'] === 'test') {
return process.env['TEST_DB_URI'] || '';
}
return process.env['DATABASE_URL'] || '';
};
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: getDatabaseUrl(),
},
});
This gives you:
DATABASE_URLTEST_DB_URINODE_ENVRun this from the workspace root. Your package manager will hoist common dependencies, link internal packages, and install everything in one pass.
pnpm:
$ pnpm install
npm:
$ npm install
Internal packages like @repo/eslint-config resolve automatically — no publishing to a registry required. With pnpm, workspace:* handles this. With npm, the "workspaces" field in package.json does the same.
Each app owns its own environment files. Never commit these to git — add them to .gitignore.
Create apps/frontend/.env.local:
NEXT_PUBLIC_API_URL=http://localhost:5000/api
Create apps/backend/.env:
DATABASE_URL="postgresql://user:password@localhost:5432/app_db"
JWT_SECRET="your-secret"
JWT_EXPIRES_IN="7d"
CLOUDINARY_CLOUD_NAME="your-cloud-name"
CLOUDINARY_API_KEY="your-api-key"
CLOUDINARY_API_SECRET="your-api-secret"
PORT=5000
Create apps/backend/.env.test:
TEST_DB_URI="postgresql://user:password@localhost:5432/app_test_db"
JWT_SECRET="test-secret"
JWT_EXPIRES_IN="7d"
PORT=5001
Note: Using a separate
TEST_DB_URIprevents your test suite from wiping your development database. Always run tests against a dedicated test database.
Start both apps simultaneously from the root:
pnpm:
$ pnpm dev
npm:
$ npm run dev
Both commands call turbo dev, which finds every workspace package that has a dev script and starts them in parallel. You should see both the Next.js dev server and the Express server boot in the Turborepo terminal UI.
For commands that target a single app, use the --filter flag.
pnpm:
$ pnpm --filter frontend dev
$ pnpm --filter frontend build
$ pnpm --filter backend dev
$ pnpm --filter backend test
$ pnpm --filter backend prisma:studio
npm:
$ npm run dev --workspace=apps/frontend
$ npm run build --workspace=apps/frontend
$ npm run dev --workspace=apps/backend
$ npm run test --workspace=apps/backend
$ npm run prisma:studio --workspace=apps/backend
You can also use Turborepo's filter flag, which works the same for both package managers:
$ pnpm turbo build --filter=frontend
$ npx turbo build --filter=frontend
$ pnpm turbo test --filter=backend
$ npx turbo test --filter=backend
Pro tip: Turborepo filters accept glob patterns.
--filter=apps/*runs a task across all apps at once.
After pnpm dev or npm run dev succeeds, verify both apps are running:
http://localhost:3000 — you should see the Next.js default page.http://localhost:5000 — if you have a health route, hit it. Otherwise check the terminal for the "Server running on port 5000" log.Run the full build to confirm TypeScript and ESLint pass across all packages:
pnpm:
$ pnpm build
npm:
$ npm run build
Expected output: Turborepo logs each package's build task in order, with the shared packages building before the apps that depend on them.
Error: Cannot find module '@repo/eslint-config/next' Cause: The internal package is not linked — usually because install was not run from the root, or "workspace:*" was not changed to "*" for npm. Fix: Run pnpm install or npm install from the workspace root. For npm, confirm that the @repo/* entries in package.json use "*" not "workspace:*".
Error: Cannot find module '@repo/typescript-config/nextjs.json' Cause: The exports field in packages/typescript-config/package.json does not match the key being imported. Fix: Check that the exports map in package.json includes "./nextjs.json": "./nextjs.json". Ensure the file itself exists at the expected path.
Error: turbo: command not found Cause: Root devDependencies were not installed, or turbo was not found in PATH. Fix: Run pnpm install or npm install from the root first. Then use pnpm turbo or npx turbo to invoke it directly.
Error: Prisma generates against the wrong database in tests. Cause: NODE_ENV=test was not set, so getDatabaseUrl() fell through to DATABASE_URL. Fix: Use the prisma:migrate-test and prisma:generate-test scripts which prefix commands with NODE_ENV=test. Never run plain prisma migrate against a test database manually.
Error: pnpm install fails with Unsupported engine on Node.js version. Cause: The root package.json may declare an engines field incompatible with your Node.js version, or a dependency requires a newer Node. Fix: Upgrade Node.js to >= 22 as specified in the prerequisites. Use a version manager like nvm or fnm to switch cleanly.
You now have a full-stack monorepo where a single command starts both your Next.js frontend and Express backend, shared TypeScript and ESLint configs enforce consistency without duplication, and Turborepo handles build ordering and caching automatically. The structure works with both pnpm and npm — the only meaningful differences are the workspace declaration file and the internal package version specifier.
Natural next steps from here:
apps/backend/prisma/schema.prisma and run pnpm --filter backend prisma:migrate to apply the first migration.turbo build and turbo test on every pull request. Turborepo's remote caching (via turbo login) can skip unchanged packages entirely.packages/types to share Zod schemas or TypeScript interfaces between the frontend and backend, eliminating the risk of request/response type drift.