Una Arquitectura Monorepo Práctica con Next.js, Fastify, Prisma y NGINX

Explora una arquitectura monorepo práctica utilizando Next.js, Fastify, Prisma y NGINX, destacando la integración y el flujo de trabajo en el mundo real.
Publicado:
Aleksandar Stajić
Actualizado el: 20 de febrero de 2026, 21:41
Una Arquitectura Monorepo Práctica con Next.js, Fastify, Prisma y NGINX

Ilustración

Monorepo de Plataforma: Next.js (Público + Admin) + API Fastify + DB Prisma + NGINX

Este documento describe la arquitectura objetivo actual para un monorepo con un sitio público Next.js, una aplicación de administración Next.js con sesiones de cookies, una API Fastify y un paquete de base de datos basado en Prisma. Está escrito para compañeros de equipo que trabajarán con múltiples paquetes y necesitan límites predecibles.

Restricciones que no estamos negociando (por ahora)

  • El sitio público debe ser SSR (Next.js App Router). Sin atajos solo para SPA.
  • El sitio público se comunica con el backend solo a través de rutas de API públicas bajo /api (por ejemplo, /api/content, /api/media).
  • El administrador utiliza autenticación de sesión basada en cookies. Cookie solo HTTP. Sin tokens de localStorage.
  • La API es Fastify. Sesiones a través de @fastify/session. Almacén “fakeRedis” para desarrollo; Redis en producción más adelante.
  • El acceso a la base de datos es solo a través de @platform/db. Sin clientes Prisma directos dispersos en las aplicaciones.
  • NGINX termina TLS y enruta /, /admin y /api. Un dominio está bien; el subdominio es opcional más adelante.
  • El monitoreo existe desde el primer día (recopilación de Prometheus + marcadores de posición del panel de Grafana).

Diseño del repositorio

repo/
  apps/
    platform/        # public site (SSR)
    admin/           # admin UI (cookie session)
  packages/
    api/             # Fastify backend (auth/users/content/media)
    db/              # Prisma client + migrations
    shared/          # shared types, UI bits, utilities
  INFRASTRUCTURE/
    nginx/
      nginx.conf
      sites/
        app.conf
    monitoring/
      prometheus.yml
      grafana/
  docker-compose.yml
  turbo.json
  package.json

Compensaciones (las reales)

  • Las sesiones de cookies son aburridas. Eso es una característica. Funcionan detrás de proxies y mantienen la lógica de autenticación fuera del frontend. La desventaja: debes configurar correctamente SameSite/Secure/path, o perseguirás errores de inicio de sesión “aleatorios”.
  • Una superficie /api es limpia, pero también significa que debes ser estricto sobre lo que es público frente a lo que es solo para administradores. No filtres endpoints de administración solo porque sea conveniente.
  • Prisma puede estar “listo para múltiples bases de datos” en el sentido de intercambiar proveedores. No es un puente mágico entre SQL y Mongo con un solo esquema. Si alguien dice que lo es, no lo ha implementado.
  • Next.js SSR es bueno para el SEO y la primera carga. También puede sobrecargar la API si no configuras el almacenamiento en caché y la revalidación intencionalmente.

Contrato de enrutamiento (NGINX como puerta principal)

Por ahora, enrutamos todo a través de un solo dominio. Es más sencillo de depurar. Si más adelante movemos la administración a un subdominio, revisaremos el alcance de las cookies y las suposiciones de CSRF.

# INFRASTRUCTURE/nginx/sites/app.conf
server {
  listen 80;
  server_name yourdomain.com;

  location /api/ {
    proxy_pass http://api:4000/;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
  }

  location /admin/ {
    proxy_pass http://admin:3001/;
    proxy_set_header Host $host;
  }

  location / {
    proxy_pass http://platform:3000/;
    proxy_set_header Host $host;
  }
}

Paquete API (Fastify) — sesiones y autenticación

La API es propietaria de la autenticación y el estado de la sesión. Las aplicaciones nunca emiten tokens. Simplemente devuelven cookies. Mantén la carga útil de la sesión pequeña. ID de usuario + rol. Eso es todo.

// packages/api/src/server.ts
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import session from "@fastify/session";
import { buildSessionStore } from "./sessionStore";
import { authRoutes } from "./routes/auth";
import { meRoutes } from "./routes/me";

export async function buildServer() {
  const app = Fastify({ logger: true });

  await app.register(cookie);
  await app.register(session, {
    secret: process.env.SESSION_SECRET || "dev-secret-change-me",
    cookieName: "sid",
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      path: "/"
    },
    store: buildSessionStore(),
    saveUninitialized: false
  });

  app.register(authRoutes, { prefix: "/api" });
  app.register(meRoutes, { prefix: "/api" });

  return app;
}

// packages/api/src/index.ts
import { buildServer } from "./server";

(async () => {
  const app = await buildServer();
  const port = Number(process.env.PORT || 4000);
  await app.listen({ port, host: "0.0.0.0" });
})();
// packages/api/src/sessionStore.ts
import type { SessionStore } from "@fastify/session";

export function buildSessionStore(): SessionStore {
  const mem = new Map<string, { value: any; expiresAt: number }>();

  return {
    get: (sid, cb) => {
      const hit = mem.get(sid);
      if (!hit) return cb(null, null);
      if (Date.now() > hit.expiresAt) {
        mem.delete(sid);
        return cb(null, null);
      }
      cb(null, hit.value);
    },
    set: (sid, session, cb) => {
      const ttlMs = 1000 * 60 * 60 * 8;
      mem.set(sid, { value: session, expiresAt: Date.now() + ttlMs });
      cb(null);
    },
    destroy: (sid, cb) => {
      mem.delete(sid);
      cb(null);
    }
  };
}

Paquete DB (Prisma) — cliente único, tipos compartidos

Exponemos un único PrismaClient desde @platform/db. La API importa eso y solo eso. Si creas un segundo cliente Prisma en una aplicación porque es “más rápido de prototipar”, solo estás creando un incidente futuro.

// packages/db/src/index.ts
import { PrismaClient } from "@prisma/client";

export const db = new PrismaClient();

Aplicación de administración — sesión de cookies y guardia /me

La aplicación de administración es una aplicación normal de Next.js App Router. La única parte especial es que debe tratar la API como la fuente de verdad y siempre incluir cookies al llamar a /api/me.

// apps/admin/app/(admin)/layout.tsx
import { cookies } from "next/headers";

async function getMe() {
  const cookieHeader = cookies().toString();
  const res = await fetch(`${process.env.ADMIN_BASE_URL}/api/me`, {
    headers: { cookie: cookieHeader },
    cache: "no-store"
  });
  return res.json();
}

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  const me = await getMe();
  if (!me?.ok) {
    return (
      <html>
        <body>
          <p>No autorizado. Ve a /admin/login.</p>
        </body>
      </html>
    );
  }

  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

Paso a paso: flujo de inicio de sesión del administrador

  1. El navegador envía credenciales: POST /api/login
  2. NGINX reenvía /api/* a Fastify
  3. Fastify valida al usuario contra la DB (Prisma)
  4. Fastify escribe la sesión en el almacén fakeRedis (desarrollo) y devuelve Set-Cookie: sid=…
  5. El navegador almacena la cookie (solo HTTP)
  6. Los componentes del servidor de administración llaman a GET /api/me con la cookie adjunta
  7. El administrador renderiza el panel solo si /me devuelve ok=true

Una cosa que salió mal (para no repetirla)

Tuvimos un bucle de inicio de sesión a pesar de que /api/login devolvió 200. La cookie nunca se envió de vuelta en /api/me. La causa fue aburrida: ruta de la cookie + desajuste de enrutamiento del proxy. Configuramos la cookie para /admin por accidente, pero /api/me reside bajo /api. El navegador hizo exactamente lo que debía. No envió la cookie. La solución fue establecer la ruta de la cookie en "/" y confirmar que NGINX no estaba reescribiendo las rutas de una manera que lo rompiera.

Plan del proyecto (lanzar primero, refinar después)

  1. Fase 0: cableado del monorepo (Turbo, configuración de TS, importaciones de paquetes compartidos). Mantenerlo ligero.
  2. Fase 1: Endpoints de autenticación/sesión de la API: /login, /logout, /me. Añadir limitación de tasa básica en /login.
  3. Fase 2: MVP de administración: pantalla de inicio de sesión + guardia del lado del servidor + shell de panel mínimo.
  4. Fase 3: Endpoints de lectura de contenido para la plataforma pública: /api/content/* (solo lectura primero).
  5. Fase 4: CRUD de contenido en administración + carga de medios (empezar simple, luego cambiar el almacenamiento más tarde).
  6. Fase 5: Monitoreo y endurecimiento: endpoint de métricas de Prometheus, paneles de Grafana, encabezados, copias de seguridad.

Definición de terminado (para no discutir después)

  • Un navegador nuevo puede iniciar sesión en /admin y permanecer conectado a través de las actualizaciones.
  • La plataforma pública renderiza contenido SSR desde /api/content sin exponer endpoints solo para administradores.
  • NGINX enruta /, /admin, /api correctamente en docker-compose.
  • La API puede reiniciarse sin corromper el comportamiento de la sesión (los reinicios del almacén de desarrollo están bien, pero debe fallar elegantemente).
  • Existe al menos un objetivo de raspado de Prometheus (incluso si los paneles de Grafana son marcadores de posición).

Si estás implementando un nuevo endpoint, decide primero: ¿es público, solo para administradores o interno? No adivines. Luego, ponlo detrás del prefijo correcto y protégelo. Ese es todo el juego.