Практическая архитектура монорепозитория с Next.js, Fastify, Prisma и NGINX

Исследуйте практическую архитектуру монорепозитория с использованием Next.js, Fastify, Prisma и NGINX, подчеркивающую реальную интеграцию и рабочий процесс.
Опубликовано:
Aleksandar Stajić
Обновлено: 20 февраля 2026 г. в 21:41
Практическая архитектура монорепозитория с Next.js, Fastify, Prisma и NGINX

Иллюстрация

Монорепозиторий платформы: Next.js (Публичный + Админ) + Fastify API + Prisma DB + NGINX

Этот документ описывает текущую целевую архитектуру монорепозитория с публичным сайтом на Next.js, административным приложением на Next.js с сессиями на основе куки, Fastify API и пакетом базы данных на основе Prisma. Он написан для членов команды, которые будут работать с несколькими пакетами и нуждаются в предсказуемых границах.

Ограничения, которые мы не обсуждаем (пока)

  • Публичный сайт должен быть SSR (Next.js App Router). Никаких сокращений только для SPA.
  • Публичный сайт взаимодействует с бэкендом только через публичные маршруты API по адресу /api (например, /api/content, /api/media).
  • Админ использует аутентификацию на основе куки-сессий. Куки только для HTTP. Никаких токенов в localStorage.
  • API — Fastify. Сессии через @fastify/session. Хранилище «fakeRedis» для разработки; Redis в продакшене позже.
  • Доступ к БД осуществляется только через @platform/db. Никаких прямых клиентов Prisma, разбросанных по приложениям.
  • NGINX завершает TLS и маршрутизирует /, /admin и /api. Один домен подходит; поддомен необязателен позже.
  • Мониторинг существует с первого дня (сбор данных Prometheus + заглушки для дашбордов Grafana).

Структура репозитория

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

Компромиссы (реальные)

  • Куки-сессии скучны. Это особенность. Они работают за прокси и удерживают логику аутентификации вне фронтенда. Недостаток: вы должны правильно настроить SameSite/Secure/path, иначе будете гоняться за «случайными» ошибками входа.
  • Единый интерфейс /api чист, но это также означает, что вы должны строго разделять публичные и только для администратора эндпоинты. Не допускайте утечки административных эндпоинтов просто потому, что это удобно.
  • Prisma может быть «готовой к работе с несколькими БД» в смысле замены провайдеров. Это не волшебный мост между SQL и Mongo с единой схемой. Если кто-то говорит, что это так, они еще не выпустили продукт.
  • Next.js SSR хорош для SEO и первой загрузки. Он также может перегрузить API, если вы намеренно не настроите кеширование и ревалидацию.

Контракт маршрутизации (NGINX как входная дверь)

Пока мы маршрутизируем все через один домен. Это проще для отладки. Если позже мы перенесем админку на поддомен, мы пересмотрим область действия куки и предположения о 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;
  }
}

Пакет API (Fastify) — сессии и аутентификация

API владеет аутентификацией и состоянием сессии. Приложения никогда не выпускают токены. Они просто отправляют куки обратно. Сохраняйте полезную нагрузку сессии небольшой. ID пользователя + роль. Вот и все.

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

Пакет БД (Prisma) — один клиент, общие типы

Мы предоставляем один PrismaClient из @platform/db. API импортирует только его. Если вы создадите второй клиент Prisma в приложении, потому что это «быстрее для прототипирования», вы просто создадите будущий инцидент.

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

export const db = new PrismaClient();

Админ-приложение — куки-сессия и защита /me

Админ — это обычное приложение Next.js App Router. Единственная особенность заключается в том, что оно должно рассматривать API как источник истины и всегда включать куки при вызове /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>
  );
}

Пошагово: процесс входа администратора

  1. Браузер отправляет учетные данные: POST /api/login
  2. NGINX перенаправляет /api/* на Fastify
  3. Fastify проверяет пользователя по БД (Prisma)
  4. Fastify записывает сессию в хранилище fakeRedis (разработка) и возвращает Set-Cookie: sid=…
  5. Браузер сохраняет куки (только HTTP)
  6. Серверные компоненты админки вызывают GET /api/me с прикрепленным куки
  7. Админка отображает дашборд только если /me возвращает ok=true

Одна вещь, которая пошла не так (чтобы мы не повторяли)

У нас был цикл входа, хотя /api/login возвращал 200. Куки никогда не отправлялись обратно на /api/me. Причина была скучной: несоответствие пути куки и маршрутизации прокси. Мы случайно установили куки для /admin, но /api/me находится по адресу /api. Браузер сделал именно то, что должен был. Он не отправил куки. Решение заключалось в установке пути куки на «/» и подтверждении того, что NGINX не переписывал пути таким образом, чтобы это нарушало работу.

План проекта (сначала выпуск, потом доработка)

  1. Фаза 0: настройка монорепозитория (Turbo, TS config, импорты общих пакетов). Сохраняйте его минималистичным.
  2. Фаза 1: Эндпоинты аутентификации/сессий API: /login, /logout, /me. Добавить базовое ограничение скорости на /login.
  3. Фаза 2: MVP админки: экран входа + серверная защита + минимальная оболочка дашборда.
  4. Фаза 3: Эндпоинты для чтения контента для публичной платформы: /api/content/* (сначала только чтение).
  5. Фаза 4: CRUD контента в админке + загрузка медиа (начните с простого, затем замените хранилище).
  6. Фаза 5: Мониторинг и усиление безопасности: эндпоинт метрик Prometheus, дашборды Grafana, заголовки, резервные копии.

Определение готовности (чтобы потом не спорить)

  • Новый браузер может войти в /admin и оставаться авторизованным при обновлениях страницы.
  • Публичная платформа отображает SSR контент из /api/content, не раскрывая эндпоинты только для администратора.
  • NGINX корректно маршрутизирует /, /admin, /api в docker-compose.
  • API может перезапускаться без нарушения поведения сессии (сбросы хранилища разработки допустимы, но он должен завершаться корректно).
  • Существует хотя бы одна цель сбора метрик Prometheus (даже если панели Grafana являются заглушками).

Если вы реализуете новый эндпоинт, сначала решите: он публичный, только для администратора или внутренний. Не гадайте. Затем поместите его за правильный префикс и защитите. В этом вся суть.