Skip to content
Streamlining Full-Stack Development: A Monorepo Approach with Next.js and Node.js
Node.js Development

Streamlining Full-Stack Development: A Monorepo Approach with Next.js and Node.js

15 min read
MonorepoNext.jsNode.jsFull-Stack DevelopmentTurborepo

Monorepos offer a powerful solution for managing complex full-stack applications, enhancing code sharing and developer velocity. Discover how to effectively set up and leverage a monorepo for your Next.js frontend and Node.js backend.

Streamlining Full-Stack Development: A Monorepo Approach with Next.js and Node.js

Modern full-stack web applications demand tight cohesion between frontend interfaces and backend microservices. In traditional multi-repository setups, frontend and backend teams face continuous friction: duplicated TypeScript interfaces, out-of-sync API contracts, disparate linting rules, and disjointed deployment cycles. A simple database schema change often requires three separate pull requests, coordinated npm package publishing, and fragile version pinning.

Adopting a unified Monorepo architecture powered by Turborepo and pnpm fundamentally eliminates this overhead. In a monorepo, your Next.js frontend, Node.js API services, and shared libraries (Zod schemas, UI components, utility logic) live together in a single repository. You gain atomic cross-stack commits, instantaneous type inference across network boundaries, and lightning-fast incremental builds powered by computation caching.

In this comprehensive guide, we build a production-grade full-stack monorepo from scratch. We will set up Next.js 15 (App Router), an Express/Node.js API, shared TypeScript validation contracts, and optimize Docker container builds using turbo prune.

SQL
+-------------------------------------------------------------------------------+
|                       Enterprise Monorepo Architecture                        |
+-------------------------------------------------------------------------------+
| apps/                                                                         |
|   ├── web/                     --> Next.js 15 App Router (Frontend)           |
|   └── api/                     --> Node.js / Express Service (Backend API)    |
| packages/                                                                     |
|   ├── shared-types/            --> Zod schemas & TypeScript DTO contracts     |
|   ├── ui/                      --> Shared React design system components      |
|   └── tsconfig/                --> Shared root TypeScript configurations      |
+-------------------------------------------------------------------------------+
MERMAID
graph TD
    Root[Monorepo Root: pnpm + Turborepo] --> Apps[apps/]
    Root --> Packages[packages/]
    
    Apps --> Web[apps/web: Next.js 15]
    Apps --> API[apps/api: Node.js Express]
    
    Packages --> Types[packages/shared-types: Zod & DTOs]
    Packages --> UI[packages/ui: React Design System]
    Packages --> Config[packages/tsconfig: Strict Configs]
    
    Web -->|Imports Type Contracts| Types
    API -->|Imports Type Contracts| Types
    Web -->|Consumes Components| UI
    Web -.->|Extends| Config
    API -.->|Extends| Config

1. Setting Up the Workspace

Initialize the repository root using pnpm workspaces:

BASH
mkdir fullstack-monorepo && cd fullstack-monorepo
pnpm init

Create pnpm-workspace.yaml:

YAML
packages:
  - "apps/*"
  - "packages/*"

2. Shared Base TypeScript Configuration

Create packages/tsconfig/base.json to enforce strict type checking across the entire monorepo:

JSON
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Default",
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022", "dom"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  }
}

3. Shared Domain Contracts: packages/shared-types

This package is consumed by both the Node.js API and the Next.js frontend, ensuring zero contract drift.

JSON
// packages/shared-types/package.json
{
  "name": "@repo/shared-types",
  "version": "0.0.1",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "dependencies": {
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@repo/tsconfig": "workspace:*",
    "typescript": "^5.5.0"
  }
}
TYPESCRIPT
// packages/shared-types/src/index.ts
import { z } from 'zod';

export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(2),
  role: z.enum(['admin', 'member', 'guest']),
  createdAt: z.string().datetime(),
});

export type User = z.infer<typeof UserSchema>;

export const CreateUserRequestSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2),
  role: z.enum(['admin', 'member', 'guest']).default('member'),
});

export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;

export interface ApiResponse<T> {
  success: boolean;
  data: T;
  timestamp: string;
}

4. Node.js Backend: apps/api

apps/api/tsconfig.json

JSON
{
  "extends": "@repo/tsconfig/base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

Express Application Server (apps/api/src/index.ts)

TYPESCRIPT
import express, { Request, Response } from 'express';
import cors from 'cors';
import crypto from 'node:crypto';
import { CreateUserRequestSchema, User, ApiResponse } from '@repo/shared-types';

const app = express();
const port = process.env.PORT || 4000;

app.use(cors());
app.use(express.json());

// In-memory store
const users: User[] = [
  {
    id: 'a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d',
    email: 'admin@company.com',
    name: 'Platform Lead',
    role: 'admin',
    createdAt: new Date().toISOString(),
  },
];

app.get('/api/users', (_req: Request, res: Response) => {
  const response: ApiResponse<User[]> = {
    success: true,
    data: users,
    timestamp: new Date().toISOString(),
  };
  res.json(response);
});

app.post('/api/users', (req: Request, res: Response) => {
  const result = CreateUserRequestSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      success: false,
      error: result.error.flatten().fieldErrors,
    });
  }

  const newUser: User = {
    id: crypto.randomUUID(),
    email: result.data.email,
    name: result.data.name,
    role: result.data.role,
    createdAt: new Date().toISOString(),
  };

  users.push(newUser);

  const response: ApiResponse<User> = {
    success: true,
    data: newUser,
    timestamp: new Date().toISOString(),
  };

  return res.status(201).json(response);
});

app.listen(port, () => {
  console.log(`[Backend API] Service running on http://localhost:${port}`);
});

5. Next.js 15 Frontend: apps/web

Our Next.js application consumes @repo/shared-types directly inside Server Components:

TSX
// apps/web/src/app/users/page.tsx
import { User, ApiResponse } from '@repo/shared-types';

async function getUsers(): Promise<User[]> {
  const apiUrl = process.env.INTERNAL_API_URL || 'http://localhost:4000';
  const res = await fetch(`${apiUrl}/api/users`, {
    cache: 'no-store', // Always fetch fresh data
  });

  if (!res.ok) {
    throw new Error('Failed to fetch user list from backend API');
  }

  const json: ApiResponse<User[]> = await res.json();
  return json.data;
}

export default async function UsersPage() {
  const users = await getUsers();

  return (
    <main className="max-w-2xl mx-auto py-12 px-4">
      <h1 className="text-3xl font-bold tracking-tight text-slate-900 mb-6">
        Enterprise Team Directory
      </h1>

      <ul className="divide-y divide-slate-200 border border-slate-200 rounded-xl overflow-hidden bg-white shadow-xs">
        {users.map((user) => (
          <li key={user.id} className="p-4 flex justify-between items-center">
            <div>
              <p className="font-semibold text-slate-800">{user.name}</p>
              <p className="text-sm text-slate-500">{user.email}</p>
            </div>
            <span className="px-2.5 py-1 text-xs font-medium rounded-full bg-slate-100 text-slate-700 capitalize">
              {user.role}
            </span>
          </li>
        ))}
      </ul>
    </main>
  );
}

6. Turborepo Orchestration: turbo.json

Configure task caching, pipeline dependencies, and output directories:

JSON
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": []
    },
    "check-types": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

7. Optimizing Production Docker Builds with turbo prune

A common issue in monorepos is sending the entire repository context to Docker, creating multi-gigabyte image sizes. Turborepo solves this via turbo prune:

BASH
# Extract only the files needed for apps/web and its internal dependencies
npx turbo prune --scope=web --docker

This creates an isolated out/ folder containing only apps/web and @repo/shared-types, omitting apps/api and unrelated packages. The resulting production Dockerfile is lightweight and benefits from optimal layer caching.


Production Verification Checklist

  • Lockfile Single Source of Truth: Confirm all projects share the root pnpm-lock.yaml.
  • Workspace Protocol: Internal package references use "workspace:*" in package.json.
  • Topological Builds: Verify turbo.json specifies "dependsOn": ["^build"] so libraries compile before consuming applications.
  • TypeScript Path Resolution: Confirm TypeScript compiler resolves workspace packages without circular import errors.
  • Pruned Docker Builds: Validate turbo prune generates lean, isolated Docker deployment contexts.
Muhammad Tahir logo

Muhammad Tahir

Building web & mobile apps since 2021. Passionate about clean code and real-world impact.