Ejemplo de Turborepo con NestJS y Next.js
Tabla de contenidos
- Arquitectura del monorepo
- ¿Qué es tRPC y por qué lo usamos?
- Preparación del entorno
- Flujo de desarrollo del backend
- Flujo de desarrollo del frontend
- Gestión de la base de datos
- Integración de tRPC
- Comandos útiles
- 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 tRPCapps/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 ORMpackages/trpc/: cliente y servidor tRPC compartidospackages/eslint-config/: configuraciones de ESLintpackages/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:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 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
- 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.
- 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.
- Desarrollo más rápido: autocompletado inteligente en el IDE, refactors seguros en todo el stack y detección inmediata de cambios que rompen.
- 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):
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):
'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):
// 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):
// 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
- Los esquemas de Zod (
packages/schemas/) definen la estructura de los datos. - El backend con NestJS (
apps/backend/) expone los procedimientos tRPC. - El paquete tRPC (
packages/trpc/) contiene el cliente y los tipos compartidos. - 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
# 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 buildFlujo de desarrollo del backend
Estructura del backend
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 featureCrear un módulo nuevo
- 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} - Implementar el servicio (
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; } } - Crear el router de tRPC (
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); } } - Crear el módulo de NestJS (
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 {} - Registrar el módulo nuevo en
app.module.ts:typescriptimport { 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.
# 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=backendFlujo de desarrollo del frontend
Estructura del frontend
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 utilitiesInstalar componentes de Shadcn UI
Para agregar componentes de UI nuevos, corre este comando desde la raíz del monorepo:
# Example: Install a breadcrumb component
bunx shadcn-ui@latest add breadcrumb --cwd apps/frontendCrear una página nueva
- 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" - 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> ); } - Crear la página (
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> ); }
Comandos de desarrollo del frontend
# 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=frontendGestió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
- Definir el esquema de Drizzle en un archivo nuevo, por ejemplo
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>; - Exportar el esquema de la tabla nueva desde
packages/database/src/index.ts:typescriptexport * from './tables/new_table'; - Crear los esquemas de Zod correspondientes en
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, });
Comandos de base de datos
# 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:studioIntegración de tRPC
Configuración del cliente (frontend)
El cliente de tRPC se configura en apps/frontend/src/app/layout.tsx mediante un 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>
);
}Uso en los componentes
'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
# 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-typesGestión de paquetes
# 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/schemasBuenas 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/databaseypackages/schemasantes 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.