Eine Praktische Monorepo-Architektur mit Next.js, Fastify, Prisma und NGINX

Erkunden Sie eine praktische Monorepo-Architektur mit Next.js, Fastify, Prisma und NGINX, die reale Integration und den Workflow hervorhebt.
Veröffentlicht:
Aleksandar Stajić
Aktualisiert am: 20. Februar 2026 um 21:41
Eine Praktische Monorepo-Architektur mit Next.js, Fastify, Prisma und NGINX

Illustration

Plattform-Monorepo: Next.js (Öffentlich + Admin) + Fastify API + Prisma DB + NGINX

Dieses Dokument beschreibt die aktuelle Zielarchitektur für ein Monorepo mit einer öffentlichen Next.js-Website, einer Next.js-Admin-App mit Cookie-Sessions, einer Fastify-API und einem Prisma-basierten DB-Paket. Es ist für Teammitglieder geschrieben, die mit mehreren Paketen arbeiten und vorhersehbare Grenzen benötigen.

Einschränkungen, die wir (vorerst) nicht verhandeln

  • Die öffentliche Website muss SSR sein (Next.js App Router). Keine reinen SPA-Abkürzungen.
  • Die öffentliche Website kommuniziert mit dem Backend nur über öffentliche API-Routen unter /api (z.B. /api/content, /api/media).
  • Admin verwendet Cookie-basierte Session-Authentifizierung. HTTP-only Cookie. Keine localStorage-Tokens.
  • API ist Fastify. Sessions über @fastify/session. „fakeRedis“-Speicher für die Entwicklung; Redis später in Produktion.
  • DB-Zugriff erfolgt ausschließlich über @platform/db. Keine direkten Prisma-Clients, die in Apps verstreut sind.
  • NGINX beendet TLS und leitet /, /admin und /api weiter. Eine Domain ist in Ordnung; Subdomain ist später optional.
  • Monitoring existiert vom ersten Tag an (Prometheus Scrape + Grafana Dashboard-Platzhalter).

Repository-Layout

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

Kompromisse (die echten)

  • Cookie-Sessions sind langweilig. Das ist ein Feature. Sie funktionieren hinter Proxys und halten die Authentifizierungslogik vom Frontend fern. Der Nachteil: Man muss SameSite/Secure/path richtig einstellen, sonst jagt man „zufällige“ Anmeldefehler.
  • Eine /api-Oberfläche ist sauber, bedeutet aber auch, dass man streng sein muss, was öffentlich und was nur für Administratoren ist. Keine Admin-Endpunkte preisgeben, nur weil es bequem ist.
  • Prisma kann im Sinne des Austauschs von Anbietern „multi-db-fähig“ sein. Es ist keine magische Brücke zwischen SQL und Mongo mit einem einzigen Schema. Wenn jemand das behauptet, hat er es noch nicht ausgeliefert.
  • Next.js SSR ist gut für SEO und die erste Ladezeit. Es kann aber auch die API überlasten, wenn man Caching und Revalidierung nicht bewusst einstellt.

Routing-Vertrag (NGINX als Front-Door)

Wir leiten vorerst alles über eine Domain. Das ist einfacher zu debuggen. Wenn wir den Admin-Bereich später auf eine Subdomain verschieben, werden wir den Cookie-Geltungsbereich und die CSRF-Annahmen überprüfen.

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

API-Paket (Fastify) – Sessions und Authentifizierung

Die API ist für Authentifizierung und Session-Status zuständig. Die Apps erstellen niemals Tokens. Sie senden lediglich Cookies zurück. Halten Sie die Session-Nutzlast klein. Benutzer-ID + Rolle. Das ist alles.

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

DB-Paket (Prisma) – einzelner Client, gemeinsame Typen

Wir stellen einen PrismaClient von @platform/db zur Verfügung. Die API importiert diesen und nur diesen. Wenn Sie einen zweiten Prisma-Client in einer App erstellen, weil es „schneller zu prototypisieren“ ist, schaffen Sie nur einen zukünftigen Vorfall.

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

export const db = new PrismaClient();

Admin-App – Cookie-Session und /me-Schutz

Admin ist eine normale Next.js App Router-Anwendung. Das Besondere ist, dass sie die API als Quelle der Wahrheit behandeln und beim Aufruf von /api/me immer Cookies mitsenden muss.

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

Schritt für Schritt: Admin-Login-Flow

  1. Browser übermittelt Anmeldedaten: POST /api/login
  2. NGINX leitet /api/* an Fastify weiter
  3. Fastify validiert Benutzer gegen DB (Prisma)
  4. Fastify schreibt Session in fakeRedis-Speicher (Entwicklung) und gibt Set-Cookie: sid=… zurück
  5. Browser speichert das Cookie (HTTP-only)
  6. Admin-Serverkomponenten rufen GET /api/me mit angehängtem Cookie auf
  7. Admin rendert Dashboard nur, wenn /me ok=true zurückgibt

Eine Sache, die schiefgelaufen ist (damit wir sie nicht wiederholen)

Wir hatten eine Anmeldeschleife, obwohl /api/login 200 zurückgab. Das Cookie wurde nie an /api/me zurückgesendet. Die Ursache war langweilig: Cookie-Pfad + Proxy-Routing-Fehler. Wir haben das Cookie versehentlich für /admin gesetzt, aber /api/me befindet sich unter /api. Der Browser hat genau das getan, was er sollte. Er hat das Cookie nicht gesendet. Die Lösung war, den Cookie-Pfad auf „/“ zu setzen und zu bestätigen, dass NGINX die Pfade nicht so umschrieb, dass es zu Problemen kam.

Projektplan (zuerst liefern, später verfeinern)

  1. Phase 0: Monorepo-Verkabelung (Turbo, TS-Konfiguration, gemeinsame Paketimporte). Schlank halten.
  2. Phase 1: API-Authentifizierungs-/Session-Endpunkte: /login, /logout, /me. Grundlegende Ratenbegrenzung für /login hinzufügen.
  3. Phase 2: Admin MVP: Anmeldebildschirm + serverseitiger Schutz + minimale Dashboard-Shell.
  4. Phase 3: Content-Lese-Endpunkte für die öffentliche Plattform: /api/content/* (zuerst nur lesend).
  5. Phase 4: Content-CRUD im Admin + Medien-Upload (einfach beginnen, dann später den Speicher wechseln).
  6. Phase 5: Überwachung und Härtung: Prometheus-Metrik-Endpunkt, Grafana-Dashboards, Header, Backups.

Definition von „Fertig“ (damit wir später nicht streiten)

  • Ein frischer Browser kann sich bei /admin anmelden und bleibt über Aktualisierungen hinweg angemeldet.
  • Die öffentliche Plattform rendert SSR-Inhalte von /api/content, ohne nur für Administratoren zugängliche Endpunkte freizulegen.
  • NGINX leitet /, /admin, /api in docker-compose korrekt weiter.
  • Die API kann neu gestartet werden, ohne das Session-Verhalten zu beschädigen (Entwicklungs-Speicher-Resets sind in Ordnung, aber sie muss graceful fehlschlagen).
  • Mindestens ein Prometheus-Scrape-Ziel existiert (auch wenn Grafana-Panels Platzhalter sind).

Wenn Sie einen neuen Endpunkt implementieren, entscheiden Sie zuerst: Ist er öffentlich, nur für Administratoren oder intern? Raten Sie nicht. Dann platzieren Sie ihn hinter dem richtigen Präfix und schützen ihn. Das ist das ganze Spiel.