A Practical Monorepo Architecture with Next.js, Fastify, Prisma, and NGINX

Explore a practical monorepo architecture using Next.js, Fastify, Prisma, and NGINX, highlighting real-world integration and workflow.
Published:
Aleksandar Stajić
Updated: January 31, 2026 at 09:11 PM
A Practical Monorepo Architecture with Next.js, Fastify, Prisma, and NGINX

Image generated

Platform Monorepo: Next.js (Public + Admin) + Fastify API + Prisma DB + NGINX

This document describes the current target architecture for a monorepo with a public Next.js site, an admin Next.js app with cookie sessions, a Fastify API, and a Prisma-based DB package. It’s written for teammates who will touch multiple packages and need predictable boundaries.

Constraints we’re not negotiating (for now)

  • Public site must be SSR (Next.js App Router). No SPA-only shortcuts.
  • Public site talks to the backend only via public API routes under /api (e.g., /api/content, /api/media).
  • Admin uses cookie-based session auth. HTTP-only cookie. No localStorage tokens.
  • API is Fastify. Sessions via @fastify/session. “fakeRedis” store for development; Redis in production later.
  • DB access is via @platform/db only. No direct Prisma clients scattered in apps.
  • NGINX terminates TLS and routes /, /admin, and /api. One domain is fine; subdomain is optional later.
  • Monitoring exists from day one (Prometheus scrape + Grafana dashboard placeholders).

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

Trade-offs (the real ones)

  • Cookie sessions are boring. That’s a feature. They work behind proxies and keep auth logic out of the frontend. The downside: you must get SameSite/Secure/path right, or you’ll chase “random” login bugs.
  • One /api surface is clean, but it also means you must be strict about what is public vs admin-only. Don’t leak admin endpoints just because it’s convenient.
  • Prisma can be “multi-db ready” in the sense of swapping providers. It is not a magic bridge between SQL and Mongo with a single schema. If someone says it is, they haven’t shipped it.
  • Next.js SSR is good for SEO and first load. It can also slam the API if you don’t set caching and revalidation intentionally.

Routing contract (NGINX as the front door)

We route everything through one domain for now. It’s simpler to debug. If we move admin to a subdomain later, we’ll revisit cookie scope and CSRF assumptions.

# 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 package (Fastify) — sessions and auth

The API owns authentication and session state. The apps never mint tokens. They just send cookies back. Keep the session payload small. User id + role. That’s it.

// 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 package (Prisma) — single client, shared types

We expose one PrismaClient from @platform/db. The API imports that and only that. If you create a second Prisma client in an app because it’s “faster to prototype”, you’re just creating a future incident.

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

export const db = new PrismaClient();

Admin app — cookie session and /me guard

Admin is a normal Next.js App Router app. The only special part is that it must treat the API as the source of truth and always include cookies when calling /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>
  );
}

Step-by-step: admin login flow

  1. Browser submits credentials: POST /api/login
  2. NGINX forwards /api/* to Fastify
  3. Fastify validates user against DB (Prisma)
  4. Fastify writes session into fakeRedis store (dev) and returns Set-Cookie: sid=…
  5. Browser stores the cookie (HTTP-only)
  6. Admin server components call GET /api/me with the cookie attached
  7. Admin renders dashboard only if /me returns ok=true

One thing that went wrong (so we don’t repeat it)

We had a login loop even though /api/login returned 200. The cookie was never sent back on /api/me. The cause was boring: cookie path + proxy routing mismatch. We set the cookie for /admin by accident, but /api/me lives under /api. Browser did exactly what it should. It didn’t send the cookie. Fix was to set cookie path to "/" and confirm NGINX wasn’t rewriting paths in a way that broke it.

Project plan (ship-first, refine later)

  1. Phase 0: monorepo wiring (Turbo, TS config, shared package imports). Keep it lean.
  2. Phase 1: API auth/session endpoints: /login, /logout, /me. Add basic rate limiting on /login.
  3. Phase 2: Admin MVP: login screen + server-side guard + minimal dashboard shell.
  4. Phase 3: Content read endpoints for public platform: /api/content/* (read-only first).
  5. Phase 4: Content CRUD in admin + media upload (start simple, then swap storage later).
  6. Phase 5: Monitoring and hardening: Prometheus metrics endpoint, Grafana dashboards, headers, backups.

Definition of done (so we don’t argue later)

  • A fresh browser can log into /admin and stay logged in across refreshes.
  • Public platform renders SSR content from /api/content without exposing admin-only endpoints.
  • NGINX routes /, /admin, /api correctly in docker-compose.
  • API can restart without corrupting session behavior (dev store resets are fine, but it must fail gracefully).
  • At least one Prometheus scrape target exists (even if Grafana panels are placeholders).

If you’re implementing a new endpoint, decide first: is it public, admin-only, or internal. Don’t guess. Then put it behind the right prefix and guard it. That’s the whole game.