martes 19 / 08 / 2025

タイラーRetourner

Ejemplo de Turborepo con NestJS y Next.js

Tabla de contenidos

  1. Arquitectura del monorepo
  2. ¿Qué es tRPC y por qué lo usamos?
  3. Preparación del entorno
  4. Flujo de desarrollo del backend
  5. Flujo de desarrollo del frontend
  6. Gestión de la base de datos
  7. Integración de tRPC
  8. Comandos útiles
  9. Buenas prácticas

Arquitectura del monorepo

Este proyecto usa Turborepo para gestionar un monorepo con las siguientes aplicaciones y paquetes:

Aplicaciones (apps/)

  • apps/backend/: API REST con NestJS y tRPC
  • apps/frontend/: aplicación Next.js 15 con React 19

Paquetes compartidos (packages/)

  • packages/database/: capa de base de datos con Drizzle ORM (PostgreSQL)
  • packages/schemas/: esquemas y tipos de TypeScript con Drizzle ORM
  • packages/trpc/: cliente y servidor tRPC compartidos
  • packages/eslint-config/: configuraciones de ESLint
  • packages/typescript-config/: configuraciones de TypeScript

Stack tecnológico

  • Frontend: Next.js 15, React 19, Tailwind CSS v4, Shadcn UI
  • Backend: NestJS, nestjs-trpc, TypeScript
  • Base de datos: PostgreSQL con Drizzle ORM
  • API: tRPC con validación de Zod
  • Gestor de paquetes: Bun
  • Monorepo: Turborepo

¿Qué es tRPC y por qué lo usamos?

¿Qué es tRPC?

tRPC (TypeScript Remote Procedure Call) es una librería que permite crear APIs con tipos totalmente seguros entre el cliente y el servidor, sin necesidad de generar tipos a mano ni de mantener contratos de API por separado.

Principio: una única fuente de verdad

En nuestro proyecto, tRPC actúa como única fuente de verdad para toda la comunicación entre el frontend y el 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      │
└─────────────────┘      └──────────────────┘      └─────────────────┘

Beneficios en nuestro proyecto

  1. Seguridad de tipos completa: los tipos se definen una sola vez en el backend y se propagan automáticamente al frontend. Los errores de tipo se detectan en tiempo de compilación, no en ejecución.
  2. Sin duplicación de código: no hay que escribir tipos en el frontend y en el backend ni mantener documentación de API aparte. Los cambios en el backend se reflejan de inmediato en el frontend.
  3. Desarrollo más rápido: autocompletado inteligente en el IDE, refactors seguros en todo el stack y detección inmediata de cambios que rompen.
  4. Validación automática: los esquemas de Zod validan los datos de entrada y de salida, y corren tanto en el cliente como en el servidor para dar errores claros y consistentes.

Ejemplo práctico: el flujo completo

1. Definición en el 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. Uso automático en el 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. Lo que NO hay que hacer:

  • Escribir interfaces duplicadas en el frontend.
  • Mantener documentación de API aparte.
  • Generar tipos a mano.
  • Validar datos a mano en el frontend.
  • Manejar a mano las URLs de los endpoints.

Comparación: con y sin tRPC

Sin tRPC (lo tradicional):

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
};

Con tRPC (nuestro enfoque):

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!

Flujo de datos en nuestro proyecto

  1. Los esquemas de Zod (packages/schemas/) definen la estructura de los datos.
  2. El backend con NestJS (apps/backend/) expone los procedimientos tRPC.
  3. El paquete tRPC (packages/trpc/) contiene el cliente y los tipos compartidos.
  4. El frontend con Next.js (apps/frontend/) los consume automáticamente con seguridad de tipos completa.

Este enfoque asegura que cualquier cambio en el backend se refleje de inmediato en el frontend, lo que elimina bugs por desincronización y acelera el desarrollo.

Preparación del entorno

Requisitos previos

  • Node.js >= 18
  • Bun >= 1.1.0
  • PostgreSQL

Instalación

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

Flujo de desarrollo del backend

Estructura del backend

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

Crear un módulo nuevo

  1. Crear la estructura de archivos:
    bash
    mkdir -p "apps/backend/src/users"
    touch "apps/backend/src/users/"{users.module.ts,users.service.ts,users.router.ts}
  2. Implementar el servicio (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. Crear el router de tRPC (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. Crear el módulo de NestJS (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. Registrar el módulo nuevo en app.module.ts:
    typescript
    import { Module } from '@nestjs/common';
    import { UsersModule } from './users/users.module';
    
    @Module({
      imports: [
        // ... other modules
        UsersModule,
      ],
      // ...
    })
    export class AppModule {}

Comandos de desarrollo del backend

Todos los comandos se corren desde la raíz del 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

Flujo de desarrollo del frontend

Estructura del frontend

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

Instalar componentes de Shadcn UI

Para agregar componentes de UI nuevos, corre este comando desde la raíz del monorepo:

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

Crear una página nueva

  1. Crear la estructura de la funcionalidad:
    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. Implementar el componente (_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. Crear la página (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>
      );
    }

Comandos de desarrollo del frontend

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

Gestión de la base de datos

Esquema de la base de datos

Las tablas principales son: - users: gestión de usuarios con roles - processes: entidades de procesos de negocio - roles: control de acceso basado en roles - analysis_results: resultados de los análisis

Crear tablas nuevas

  1. Definir el esquema de Drizzle en un archivo nuevo, por ejemplo 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. Exportar el esquema de la tabla nueva desde packages/database/src/index.ts:
    typescript
    export * from './tables/new_table';
  3. Crear los esquemas de Zod correspondientes en 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,
    });

Comandos de base de datos

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

Integración de tRPC

Configuración del cliente (frontend)

El cliente de tRPC se configura en apps/frontend/src/app/layout.tsx mediante un 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>
  );
}

Uso en los componentes

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>
  );
}

Endpoints disponibles

  • tRPC: http://localhost:4000/api/trpc
  • Documentación de la API: http://localhost:4000/docs

Comandos útiles

Desarrollo general

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

Gestión de paquetes

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

Buenas prácticas

  • Idioma del código: todo el código, los comentarios y las variables van en inglés.
  • tRPC primero: prioriza tRPC sobre REST para la comunicación entre cliente y servidor.
  • Esquemas sincronizados: mantén sincronizados los esquemas de Drizzle y de Zod.
  • Dependencias de build: después de cambiarlos, siempre construye packages/database y packages/schemas antes de correr el backend.
  • Correr desde la raíz: ejecuta todos los comandos desde la raíz del monorepo, por consistencia.
  • CORS: configura CORS en el backend con orígenes permitidos explícitos, por seguridad.