Turborepo Example with NestJS and Next.js
Table of Contents
- Monorepo Architecture
- What is tRPC and Why Do We Use It?
- Environment Setup
- Backend Development Workflow
- Frontend Development Workflow
- Database Management
- tRPC Integration
- Useful Commands
- Best Practices
Monorepo Architecture
This project uses Turborepo to manage a monorepo with the following applications and packages:
Applications (apps/)
apps/backend/: REST API with NestJS and tRPCapps/frontend/: Next.js 15 application with React 19
Shared Packages (packages/)
packages/database/: Database layer with Drizzle ORM (PostgreSQL)packages/schemas/: TypeScript schemas and types using Drizzle ORMpackages/trpc/: Shared tRPC client and serverpackages/eslint-config/: ESLint configurationspackages/typescript-config/: TypeScript configurations
Technology Stack
- Frontend: Next.js 15, React 19, Tailwind CSS v4, Shadcn UI
- Backend: NestJS, nestjs-trpc, TypeScript
- Database: PostgreSQL with Drizzle ORM
- API: tRPC with Zod validation
- Package Manager: Bun
- Monorepo: Turborepo
What is tRPC and Why Do We Use It?
What is tRPC?
tRPC (TypeScript Remote Procedure Call) is a library that allows for creating fully type-safe APIs between the client and the server, eliminating the need to manually generate types or maintain separate API contracts.
Principle: A Single Source of Truth
In our project, tRPC acts as a single source of truth for all communication between the frontend and backend:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ FRONTEND │ │ tRPC │ │ BACKEND │
│ (Next.js) │◄────►│ (Single Source │◄────►│ (NestJS) │
│ │ │ of Truth) │ │ │
│ • Auto-inferred │ │ • Procedures │ │ • Definitions │
│ Types │ │ • Zod Validation │ │ • Business Logic│
│ • Autocomplete │ │ • Type Inference │ │ • Database │
└─────────────────┘ └──────────────────┘ └─────────────────┘Benefits in Our Project
- Complete Type Safety: Types are defined once in the backend and automatically propagate to the frontend. Type errors are caught at compile-time, not runtime.
- No Code Duplication: No need to write types in both the frontend and backend or maintain separate API documentation. Changes in the backend are immediately reflected in the frontend.
- Faster Development: Get intelligent autocompletion in the IDE, safe refactoring across the stack, and immediate detection of breaking changes.
- Automatic Validation: Zod schemas validate input and output data, running on both client and server for clear, consistent errors.
Practical Example: Complete Workflow
1. Definition in the Backend (apps/backend/src/processes/processes.router.ts):
import { Input, Mutation, Query, Router } from 'nestjs-trpc';
import { ProcessesService } from './processes.service';
import { z } from 'zod';
import { processSchema, createProcessSchema, type CreateProcessInput } from '@repo/schemas';
@Router({ alias: 'processes' })
export class ProcessesRouter {
constructor(private readonly processesService: ProcessesService) {}
@Query({
output: z.array(processSchema),
})
getAll() {
return this.processesService.findAll();
}
@Mutation({
input: createProcessSchema,
output: processSchema,
})
create(@Input() input: CreateProcessInput) {
return this.processesService.create(input);
}
}2. Automatic Usage in the Frontend (apps/frontend/src/app/(dashboard)/processes/_components/process-list.tsx):
'use client';
import { trpc } from '@repo/trpc/client';
export function ProcessList() {
// ← Types are fully inferred, no duplication
const { data: processes } = trpc.processes.getAll.useQuery();
const createProcess = trpc.processes.create.useMutation();
// ← TypeScript knows exactly what properties 'processes' has
return (
<div>
{processes?.map((process) => (
<div key={process.id}>{process.name}</div> // ← Autocomplete
))}
</div>
);
}3. What You DON’T Need to Do:
- Write duplicate interfaces in the frontend.
- Maintain separate API documentation.
- Manually generate types.
- Manually validate data in the frontend.
- Manually handle endpoint URLs.
Comparison: With and Without tRPC
Without tRPC (Traditional):
// Backend - types.ts
interface Process {
id: string;
name: string;
}
// Frontend - types.ts (DUPLICATED!)
interface Process {
id: string;
name: string; // What if the backend changes this to 'title'?
}
// Frontend - api.ts
const getProcesses = async (): Promise<Process[]> => {
const response = await fetch('/api/processes'); // Manual URL
return response.json(); // No validation
};With tRPC (Our Approach):
// Backend only - single definition in the router
@Query({ output: z.array(processSchema) })
getAll() {
return this.processesService.findAll();
}
// Frontend - direct usage with full type safety
const { data } = trpc.processes.getAll.useQuery(); // Everything is automatic!Data Flow in Our Project
- Zod Schemas (
packages/schemas/) define the data structure. - NestJS Backend (
apps/backend/) exposes tRPC procedures. - tRPC Package (
packages/trpc/) contains the shared client and types. - Next.js Frontend (
apps/frontend/) automatically consumes them with full type safety.
This approach ensures that any change in the backend is immediately reflected in the frontend, eliminating bugs from desynchronization and accelerating development.
Environment Setup
Prerequisites
- Node.js >= 18
- Bun >= 1.1.0
- PostgreSQL
Installation
# Install dependencies from the root of the monorepo
bun install
# Configure environment variables by copying the example file
cp .env.example .env
# Edit the .env file with your DATABASE_URL
# Example:
DATABASE_URL="postgresql://user:password@localhost:5432/your_db"
# Build all shared packages and apps
bun run buildBackend Development Workflow
Backend Structure
apps/backend/src/
├── main.ts # Application entry point
├── app.module.ts # Main NestJS module
├── trpc-router.ts # Standalone tRPC router definition
└── [feature]/
├── [feature].module.ts
├── [feature].service.ts
├── [feature].controller.ts # (Optional) REST endpoints
└── [feature].router.ts # tRPC procedures for the featureCreating a New Module
- Create the file structure:bash
mkdir -p "apps/backend/src/users" touch "apps/backend/src/users/"{users.module.ts,users.service.ts,users.router.ts} - Implement the service (
users.service.ts):typescriptimport { Injectable } from '@nestjs/common'; import { DatabaseService } from '@repo/database'; import { users, type User, type NewUser } from '@repo/schemas'; @Injectable() export class UsersService { constructor(private db: DatabaseService) {} async findAll(): Promise<User[]> { return this.db.database.select().from(users); } async create(userData: NewUser): Promise<User> { const [user] = await this.db.database .insert(users) .values(userData) .returning(); return user; } } - Create the tRPC Router (
users.router.ts):typescriptimport { Input, Mutation, Query, Router } from 'nestjs-trpc'; import { UsersService } from './users.service'; import { z } from 'zod'; import { userSchema, createUserSchema, type CreateUserInput } from '@repo/schemas'; @Router({ alias: 'users' }) export class UsersRouter { constructor(private readonly usersService: UsersService) {} @Query({ output: z.array(userSchema) }) getAll() { return this.usersService.findAll(); } @Mutation({ input: createUserSchema, output: userSchema }) create(@Input() input: CreateUserInput) { return this.usersService.create(input); } } - Create the NestJS module (
users.module.ts):typescriptimport { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { UsersRouter } from './users.router'; @Module({ providers: [UsersService, UsersRouter], exports: [UsersService], }) export class UsersModule {} - Register the new module in
app.module.ts:typescriptimport { Module } from '@nestjs/common'; import { UsersModule } from './users/users.module'; @Module({ imports: [ // ... other modules UsersModule, ], // ... }) export class AppModule {}
Backend Development Commands
All commands should be run from the root of the monorepo.
# Run backend in development mode
bun run dev:backend
# Build backend for production
turbo build --filter=backend
# Run backend tests
turbo run test --filter=backend
# Lint backend code
turbo run lint --filter=backendFrontend Development Workflow
Frontend Structure
apps/frontend/src/
├── app/
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Main page
│ └── (dashboard)/ # Route group for authenticated routes
│ └── [feature]/
│ ├── page.tsx
│ ├── _components/ # Feature-specific components
│ └── _lib/ # Hooks, types, and utilities
├── components/
│ ├── ui/ # Shadcn UI components
│ └── ... # Other shared components
└── lib/
└── ... # Shared utilitiesInstalling Shadcn UI Components
To add new UI components, run the following command from the monorepo root:
# Example: Install a breadcrumb component
bunx shadcn-ui@latest add breadcrumb --cwd apps/frontendCreating a New Page
- Create the feature structure:bash
mkdir -p "apps/frontend/src/app/(dashboard)/analytics/_components" mkdir -p "apps/frontend/src/app/(dashboard)/analytics/_lib" touch "apps/frontend/src/app/(dashboard)/analytics/page.tsx" touch "apps/frontend/src/app/(dashboard)/analytics/_lib/types.ts" - Implement component (
_components/analytics-chart.tsx):typescript'use client'; import { trpc } from '@repo/trpc/client'; export function AnalyticsChart() { const { data: analytics, isLoading } = trpc.analytics.getAll.useQuery(); if (isLoading) return <div>Loading...</div>; return ( <div> {analytics?.map((item) => ( <div key={item.id}>{`${item.metric}: ${item.value}`}</div> ))} </div> ); } - Create the page (
page.tsx):typescriptimport { AnalyticsChart } from './_components/analytics-chart'; export default function AnalyticsPage() { return ( <div className="container mx-auto p-6"> <h1 className="text-2xl font-bold mb-6">Analytics</h1> <AnalyticsChart /> </div> ); }
Frontend Development Commands
# Run frontend in development mode
bun run dev:frontend
# Build frontend for production
turbo build --filter=frontend
# Lint frontend code
turbo run lint --filter=frontend
# Generate API types from backend schema
# Note: Requires the backend dev server to be running
bun run generate-api --filter=frontendDatabase Management
Database Schema
The main tables include:
- users: User management with roles
- processes: Business process entities
- roles: Role-based access control
- analysis_results: Analysis results
Creating New Tables
- Define the Drizzle schema in a new file, e.g.,
packages/database/src/tables/new_table.ts:typescriptimport { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core'; import { type InferInsertModel, type InferSelectModel } from 'drizzle-orm'; export const newTable = pgTable('new_table', { id: uuid('id').primaryKey().defaultRandom(), name: varchar('name', { length: 255 }).notNull(), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), }); export type NewTable = InferSelectModel<typeof newTable>; export type NewNewTable = InferInsertModel<typeof newTable>; - Export the new table schema from
packages/database/src/index.ts:typescriptexport * from './tables/new_table'; - Create corresponding Zod schemas in
packages/schemas/src/api/new-table.schema.ts:typescriptimport { z } from 'zod'; export const newTableSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), createdAt: z.date().optional(), updatedAt: z.date().optional(), }); export const createNewTableSchema = newTableSchema.omit({ id: true, createdAt: true, updatedAt: true, });
Database Commands
# Generate a new migration based on schema changes
bun run db:generate
# Apply all pending migrations to the database
bun run db:migrate
# Open Drizzle Studio to view and manage data
bun run db:studiotRPC Integration
Client Configuration (Frontend)
The tRPC client is configured in apps/frontend/src/app/layout.tsx via a provider:
import { TrpcProvider } from '@repo/trpc/client/providers/TrpcProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<TrpcProvider>{children}</TrpcProvider>
</body>
</html>
);
}Usage in Components
'use client';
import { trpc } from '@repo/trpc/client';
export function ProcessList() {
const utils = trpc.useUtils();
const { data: processes, isLoading, error } = trpc.processes.getAll.useQuery();
const createProcess = trpc.processes.create.useMutation({
onSuccess: () => {
// Invalidate the query to refetch data automatically
utils.processes.getAll.invalidate();
},
});
const handleCreate = async (data: { name: string }) => {
try {
await createProcess.mutateAsync(data);
} catch (error) {
console.error('Error creating process:', error);
}
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{processes?.map((process) => (
<div key={process.id}>{process.name}</div>
))}
</div>
);
}Available Endpoints
- tRPC:
http://localhost:4000/api/trpc - API Docs:
http://localhost:4000/docs
Useful Commands
General Development
# Start all applications in development mode
bun run dev
# Build all applications for production
bun run build
# Run linters across the entire monorepo
bun run lint
# Format all code with Prettier
bun run format
# Run TypeScript type checking
bun run check-typesPackage Management
# Install a dependency in a specific workspace (e.g., backend)
bun add <package-name> --filter=backend
# Build only specific packages
turbo build --filter=@repo/database --filter=@repo/schemasBest Practices
- Code Language: All code, comments, and variables should be in English.
- tRPC First: Prioritize tRPC for client-server communication over REST.
- Schema Synchronization: Keep Drizzle and Zod schemas synchronized.
- Build Dependencies: Always build
packages/databaseandpackages/schemasafter changes before running the backend. - Run from Root: Execute all commands from the monorepo root for consistency.
- CORS: Configure CORS in the backend with explicit allowed origins for security.