Un'Architettura Monorepo Pratica con Next.js, Fastify, Prisma e NGINX

Esplora un'architettura monorepo pratica che utilizza Next.js, Fastify, Prisma e NGINX, evidenziando l'integrazione e il flusso di lavoro nel mondo reale.
Pubblicato:
Aleksandar Stajić
Aggiornato il: 20 febbraio 2026 alle ore 21:41
Un'Architettura Monorepo Pratica con Next.js, Fastify, Prisma e NGINX

Illustrazione

Monorepo della Piattaforma: Next.js (Pubblico + Admin) + API Fastify + DB Prisma + NGINX

Questo documento descrive l'architettura target attuale per un monorepo con un sito pubblico Next.js, un'app Next.js di amministrazione con sessioni basate su cookie, un'API Fastify e un pacchetto DB basato su Prisma. È scritto per i membri del team che lavoreranno su più pacchetti e necessitano di confini prevedibili.

Vincoli che non stiamo negoziando (per ora)

  • Il sito pubblico deve essere SSR (Next.js App Router). Nessuna scorciatoia solo SPA.
  • Il sito pubblico comunica con il backend solo tramite route API pubbliche sotto /api (es. /api/content, /api/media).
  • L'amministrazione utilizza l'autenticazione di sessione basata su cookie. Cookie HTTP-only. Nessun token in localStorage.
  • L'API è Fastify. Sessioni tramite @fastify/session. Store “fakeRedis” per lo sviluppo; Redis in produzione in seguito.
  • L'accesso al DB avviene solo tramite @platform/db. Nessun client Prisma diretto sparso nelle app.
  • NGINX termina TLS e instrada /, /admin e /api. Un unico dominio va bene; il sottodominio è opzionale in seguito.
  • Il monitoraggio esiste dal primo giorno (scrape Prometheus + segnaposto per dashboard Grafana).

Layout del repository

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

Compromessi (quelli veri)

  • Le sessioni basate su cookie sono noiose. Questa è una caratteristica. Funzionano dietro i proxy e mantengono la logica di autenticazione fuori dal frontend. Lo svantaggio: devi impostare correttamente SameSite/Secure/path, altrimenti inseguirai bug di accesso “casuali”.
  • Un'unica superficie /api è pulita, ma significa anche che devi essere rigoroso su ciò che è pubblico rispetto a ciò che è solo per l'amministrazione. Non far trapelare endpoint di amministrazione solo perché è comodo.
  • Prisma può essere “pronto per più DB” nel senso di scambiare i provider. Non è un ponte magico tra SQL e Mongo con un singolo schema. Se qualcuno dice che lo è, non l'ha ancora implementato.
  • Next.js SSR è ottimo per la SEO e il primo caricamento. Può anche sovraccaricare l'API se non imposti intenzionalmente la cache e la riconvalida.

Contratto di routing (NGINX come porta d'ingresso)

Per ora instradamo tutto tramite un unico dominio. È più semplice da debuggare. Se in seguito sposteremo l'amministrazione su un sottodominio, rivedremo l'ambito dei cookie e le ipotesi 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;
  }
}

Pacchetto API (Fastify) — sessioni e autenticazione

L'API gestisce l'autenticazione e lo stato della sessione. Le app non creano mai token. Si limitano a inviare i cookie. Mantieni il payload della sessione piccolo. ID utente + ruolo. Tutto qui.

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

Pacchetto DB (Prisma) — client singolo, tipi condivisi

Esporremo un unico PrismaClient da @platform/db. L'API importa quello e solo quello. Se crei un secondo client Prisma in un'app perché è “più veloce da prototipare”, stai solo creando un futuro incidente.

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

export const db = new PrismaClient();

App di amministrazione — sessione cookie e guardia /me

L'amministrazione è una normale app Next.js App Router. L'unica parte speciale è che deve trattare l'API come fonte di verità e includere sempre i cookie quando chiama /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>Unauthorized. Go to /admin/login.</p>
        </body>
      </html>
    );
  }

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

Passo dopo passo: flusso di accesso dell'amministratore

  1. Il browser invia le credenziali: POST /api/login
  2. NGINX inoltra /api/* a Fastify
  3. Fastify convalida l'utente rispetto al DB (Prisma)
  4. Fastify scrive la sessione nello store fakeRedis (dev) e restituisce Set-Cookie: sid=…
  5. Il browser memorizza il cookie (HTTP-only)
  6. I componenti del server di amministrazione chiamano GET /api/me con il cookie allegato
  7. L'amministrazione renderizza la dashboard solo se /me restituisce ok=true

Una cosa che è andata storta (così non la ripetiamo)

Avevamo un loop di accesso anche se /api/login restituiva 200. Il cookie non veniva mai inviato di nuovo su /api/me. La causa era banale: mancata corrispondenza tra il percorso del cookie e il routing del proxy. Avevamo impostato il cookie per /admin per errore, ma /api/me si trova sotto /api. Il browser ha fatto esattamente quello che doveva. Non ha inviato il cookie. La soluzione è stata impostare il percorso del cookie su "/" e confermare che NGINX non stesse riscrivendo i percorsi in modo da romperlo.

Piano di progetto (lanciare prima, perfezionare dopo)

  1. Fase 0: cablaggio del monorepo (Turbo, configurazione TS, importazioni di pacchetti condivisi). Mantienilo snello.
  2. Fase 1: Endpoint API di autenticazione/sessione: /login, /logout, /me. Aggiungi un rate limiting di base su /login.
  3. Fase 2: MVP Admin: schermata di accesso + guardia lato server + shell minima della dashboard.
  4. Fase 3: Endpoint di lettura dei contenuti per la piattaforma pubblica: /api/content/* (solo lettura inizialmente).
  5. Fase 4: CRUD dei contenuti nell'amministrazione + caricamento media (inizia semplice, poi cambia lo storage in seguito).
  6. Fase 5: Monitoraggio e rafforzamento: endpoint metriche Prometheus, dashboard Grafana, header, backup.

Definizione di completato (così non discuteremo dopo)

  • Un browser nuovo può accedere a /admin e rimanere loggato tra i refresh.
  • La piattaforma pubblica renderizza contenuti SSR da /api/content senza esporre endpoint solo per l'amministrazione.
  • NGINX instrada /, /admin, /api correttamente in docker-compose.
  • L'API può riavviarsi senza corrompere il comportamento della sessione (i reset dello store di sviluppo vanno bene, ma deve fallire in modo elegante).
  • Esiste almeno un target di scrape Prometheus (anche se i pannelli Grafana sono segnaposto).

Se stai implementando un nuovo endpoint, decidi prima: è pubblico, solo per l'amministrazione o interno. Non indovinare. Poi mettilo dietro il prefisso giusto e proteggilo. Questo è tutto il gioco.