Une Architecture Monorepo Pratique avec Next.js, Fastify, Prisma, et NGINX

Illustration
Monorepo de plateforme : Next.js (Public + Admin) + API Fastify + DB Prisma + NGINX
Ce document décrit l'architecture cible actuelle pour un monorepo comprenant un site public Next.js, une application d'administration Next.js avec sessions par cookies, une API Fastify et un package de base de données basé sur Prisma. Il est rédigé pour les coéquipiers qui travailleront sur plusieurs packages et ont besoin de limites prévisibles.
Contraintes non négociables (pour l'instant)
- Le site public doit être SSR (Next.js App Router). Pas de raccourcis uniquement SPA.
- Le site public communique avec le backend uniquement via des routes API publiques sous /api (par exemple, /api/content, /api/media).
- L'administration utilise l'authentification par session basée sur les cookies. Cookie HTTP-only. Pas de jetons localStorage.
- L'API est Fastify. Sessions via @fastify/session. Stockage « fakeRedis » pour le développement ; Redis en production plus tard.
- L'accès à la base de données se fait uniquement via @platform/db. Pas de clients Prisma directs dispersés dans les applications.
- NGINX termine le TLS et route /, /admin et /api. Un seul domaine est acceptable ; un sous-domaine est facultatif plus tard.
- La surveillance existe dès le premier jour (récupération Prometheus + espaces réservés pour les tableaux de bord Grafana).
Structure du dépôt
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
Compromis (les vrais)
- Les sessions par cookies sont ennuyeuses. C'est une fonctionnalité. Elles fonctionnent derrière les proxys et maintiennent la logique d'authentification hors du frontend. L'inconvénient : vous devez configurer correctement SameSite/Secure/path, sinon vous courrez après des bugs de connexion « aléatoires ».
- Une seule surface /api est propre, mais cela signifie aussi que vous devez être strict sur ce qui est public et ce qui est réservé à l'administration. Ne divulguez pas d'endpoints d'administration juste parce que c'est pratique.
- Prisma peut être « prêt pour plusieurs bases de données » dans le sens où il permet de changer de fournisseur. Ce n'est pas un pont magique entre SQL et Mongo avec un schéma unique. Si quelqu'un dit que c'est le cas, il ne l'a pas encore livré.
- Le SSR de Next.js est bon pour le SEO et le premier chargement. Il peut aussi surcharger l'API si vous ne configurez pas intentionnellement la mise en cache et la revalidation.
Contrat de routage (NGINX comme porte d'entrée)
Nous routons tout via un seul domaine pour l'instant. C'est plus simple à déboguer. Si nous déplaçons l'administration vers un sous-domaine plus tard, nous réexaminerons la portée des cookies et les hypothèses 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;
}
}
Package API (Fastify) — sessions et authentification
L'API gère l'authentification et l'état de la session. Les applications ne créent jamais de jetons. Elles renvoient simplement des cookies. Gardez la charge utile de la session petite. ID utilisateur + rôle. C'est tout.
// 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);
}
};
}
Package DB (Prisma) — client unique, types partagés
Nous exposons un seul PrismaClient depuis @platform/db. L'API importe cela et seulement cela. Si vous créez un deuxième client Prisma dans une application parce que c'est « plus rapide à prototyper », vous ne faites que créer un incident futur.
// packages/db/src/index.ts
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
Application d'administration — session par cookie et garde /me
L'administration est une application Next.js App Router normale. La seule particularité est qu'elle doit considérer l'API comme la source de vérité et toujours inclure les cookies lors de l'appel à /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>
);
}
Étape par étape : flux de connexion de l'administrateur
- Le navigateur soumet les identifiants : POST /api/login
- NGINX transmet /api/* à Fastify
- Fastify valide l'utilisateur par rapport à la base de données (Prisma)
- Fastify écrit la session dans le stockage fakeRedis (dev) et renvoie Set-Cookie: sid=…
- Le navigateur stocke le cookie (HTTP-only)
- Les composants serveur de l'administration appellent GET /api/me avec le cookie attaché
- L'administration affiche le tableau de bord uniquement si /me renvoie ok=true
Une chose qui a mal tourné (pour ne pas la répéter)
Nous avions une boucle de connexion même si /api/login renvoyait 200. Le cookie n'était jamais renvoyé sur /api/me. La cause était ennuyeuse : une incompatibilité entre le chemin du cookie et le routage du proxy. Nous avions défini le cookie pour /admin par accident, mais /api/me se trouve sous /api. Le navigateur a fait exactement ce qu'il devait. Il n'a pas envoyé le cookie. La solution a été de définir le chemin du cookie sur "/" et de confirmer que NGINX ne réécrivait pas les chemins d'une manière qui le cassait.
Plan de projet (livrer d'abord, affiner ensuite)
- Phase 0 : câblage du monorepo (Turbo, configuration TS, importations de packages partagés). Restez léger.
- Phase 1 : Endpoints d'authentification/session de l'API : /login, /logout, /me. Ajouter une limitation de débit de base sur /login.
- Phase 2 : MVP Admin : écran de connexion + garde côté serveur + shell de tableau de bord minimal.
- Phase 3 : Endpoints de lecture de contenu pour la plateforme publique : /api/content/* (lecture seule d'abord).
- Phase 4 : CRUD de contenu dans l'administration + téléchargement de médias (commencer simple, puis changer de stockage plus tard).
- Phase 5 : Surveillance et renforcement : endpoint de métriques Prometheus, tableaux de bord Grafana, en-têtes, sauvegardes.
Définition de « terminé » (pour éviter les discussions ultérieures)
- Un nouveau navigateur peut se connecter à /admin et rester connecté après des actualisations.
- La plateforme publique affiche le contenu SSR depuis /api/content sans exposer les endpoints réservés à l'administration.
- NGINX route /, /admin, /api correctement dans docker-compose.
- L'API peut redémarrer sans corrompre le comportement de la session (les réinitialisations du stockage de développement sont acceptables, mais elle doit échouer gracieusement).
- Au moins une cible de récupération Prometheus existe (même si les panneaux Grafana sont des espaces réservés).
Si vous implémentez un nouvel endpoint, décidez d'abord : est-il public, réservé à l'administration ou interne. Ne devinez pas. Ensuite, placez-le derrière le bon préfixe et protégez-le. C'est tout le jeu.
Related Articles

Enterprise-Grade Multi-Tenant Architecture for an International Platform
Loving Rocks is an enterprise-grade wedding platform designed with a true multi-tenant architecture, isolated databases per tenant, and built-in internationalization for global scalability, security, and long-term operational stability.

Architecture multi-bases de données avec Prisma 7 : Un Deep Dive pour experts
La gestion de paysages de données complexes nécessite des architectures modernes. Prisma 7 offre des fonctionnalités avancées pour l'intégration multi-bases de données et adresse les défis de la persistance polyglotte.