Tuesday 19 / 08 / 2025タイラー

Retourner

Turborepo Example with NestJS and Next.js

Table of Contents

  1. Monorepo Architecture
  2. What is tRPC and Why Do We Use It?
  3. Environment Setup
  4. Backend Development Workflow
  5. Frontend Development Workflow
  6. Database Management
  7. tRPC Integration
  8. Useful Commands
  9. 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 tRPC
  • apps/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 ORM
  • packages/trpc/: Shared tRPC client and server
  • packages/eslint-config/: ESLint configurations
  • packages/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:

text
┌─────────────────┐      ┌──────────────────┐      ┌─────────────────┐
│   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

  1. 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.
  2. 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.
  3. Faster Development: Get intelligent autocompletion in the IDE, safe refactoring across the stack, and immediate detection of breaking changes.
  4. 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):

typescript
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):

typescript
'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):

typescript
// 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):

typescript
// 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

  1. Zod Schemas (packages/schemas/) define the data structure.
  2. NestJS Backend (apps/backend/) exposes tRPC procedures.
  3. tRPC Package (packages/trpc/) contains the shared client and types.
  4. 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

bash
# 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 build

Backend Development Workflow

Backend Structure

text
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 feature

Creating a New Module

  1. 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}
  2. Implement the service (users.service.ts):
    typescript
    import { 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;
      }
    }
  3. Create the tRPC Router (users.router.ts):
    typescript
    import { 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);
      }
    }
  4. Create the NestJS module (users.module.ts):
    typescript
    import { Module } from '@nestjs/common';
    import { UsersService } from './users.service';
    import { UsersRouter } from './users.router';
    
    @Module({
      providers: [UsersService, UsersRouter],
      exports: [UsersService],
    })
    export class UsersModule {}
  5. Register the new module in app.module.ts:
    typescript
    import { 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.

bash
# 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=backend

Frontend Development Workflow

Frontend Structure

text
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 utilities

Installing Shadcn UI Components

To add new UI components, run the following command from the monorepo root:

bash
# Example: Install a breadcrumb component
bunx shadcn-ui@latest add breadcrumb --cwd apps/frontend

Creating a New Page

  1. 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"
  2. 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>
      );
    }
  3. Create the page (page.tsx):
    typescript
    import { 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

bash
# 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=frontend

Database 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

  1. Define the Drizzle schema in a new file, e.g., packages/database/src/tables/new_table.ts:
    typescript
    import { 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>;
  2. Export the new table schema from packages/database/src/index.ts:
    typescript
    export * from './tables/new_table';
  3. Create corresponding Zod schemas in packages/schemas/src/api/new-table.schema.ts:
    typescript
    import { 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

bash
# 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:studio

tRPC Integration

Client Configuration (Frontend)

The tRPC client is configured in apps/frontend/src/app/layout.tsx via a provider:

typescript
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

typescript
'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

bash
# 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-types

Package Management

bash
# 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/schemas

Best 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/database and packages/schemas after 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.