基于Next.js、Fastify、Prisma和NGINX的实用单体仓库架构
探索一种实用的单体仓库架构,结合Next.js、Fastify、Prisma与NGINX,重点展示实际集成与工作流程。
已发布:
Aleksandar Stajić
已更新: 2026年2月20日 21:41

配图
平台单体仓库:Next.js(公共 + 管理端)+ Fastify API + Prisma DB + NGINX
本文档描述了当前目标架构,该架构采用单体仓库,包含一个公共Next.js站点、一个带有Cookie会话的管理端Next.js应用、一个Fastify API以及一个基于Prisma的数据库包。本文档面向需要接触多个包并需要明确边界的团队成员。
我们暂不妥协的约束条件
- 公共站点必须采用SSR(Next.js App Router)。不允许仅使用SPA的快捷方式。
- 公共站点只能通过位于 /api 下的公共API路由与后端通信(例如,/api/content, /api/media)。
- 管理端使用基于Cookie的会话认证。HTTP-only Cookie。不使用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
权衡取舍(真正的取舍)
- Cookie会话很普通。但这正是其优点。它们在代理后工作,并将认证逻辑排除在前端之外。缺点是:你必须正确设置SameSite/Secure/path,否则你会追查“随机”的登录错误。
- 单一的 /api 接口很清晰,但这同时也意味着你必须严格区分公共端点和仅限管理员的端点。不要因为方便而泄露管理员端点。
- Prisma在“多数据库就绪”的意义上可以切换提供商。它并不是一个能在单一模式下连接SQL和Mongo的魔法桥梁。如果有人这么说,那他们肯定没有实际部署过。
- Next.js SSR对SEO和首次加载有好处。但如果你没有有意设置缓存和重新验证,它也可能对API造成冲击。
路由契约(NGINX作为前门)
我们目前将所有流量通过一个域名路由。这样调试更简单。如果我们后续将管理端移至子域名,我们将重新审视Cookie范围和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负责认证和会话状态。应用从不生成令牌。它们只是回传Cookie。保持会话负载小巧。用户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)—— 单一客户端,共享类型
我们从 @platform/db 暴露一个 PrismaClient。API 只导入这个客户端。如果你因为“原型开发更快”而在应用中创建第二个 Prisma 客户端,你只是在制造未来的事故。
// packages/db/src/index.ts
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
管理端应用 —— Cookie 会话与 /me 守卫
管理端是一个普通的 Next.js App Router 应用。唯一特殊之处在于它必须将 API 视为唯一真实来源,并且在调用 /api/me 时始终包含 Cookie。
// 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>未经授权。请前往 /admin/login。</p>
</body>
</html>
);
}
return (
<html>
<body>{children}</body>
</html>
);
}
逐步说明:管理端登录流程
- 浏览器提交凭据:POST /api/login
- NGINX 将 /api/* 转发给 Fastify
- Fastify 根据数据库(Prisma)验证用户
- Fastify 将会话写入 fakeRedis 存储(开发环境)并返回 Set-Cookie: sid=…
- 浏览器存储 Cookie(HTTP-only)
- 管理端服务器组件调用 GET /api/me 并附上 Cookie
- 仅当 /me 返回 ok=true 时,管理端才渲染仪表板
一个出过的问题(以免重蹈覆辙)
我们曾遇到登录循环,即使 /api/login 返回 200。Cookie 从未在 /api/me 请求中发送回来。原因很普通:Cookie 路径与代理路由不匹配。我们错误地将 Cookie 路径设置为 /admin,但 /api/me 位于 /api 下。浏览器完全按照应有的方式工作。它没有发送 Cookie。修复方法是将 Cookie 路径设置为 "/",并确认 NGINX 没有以破坏它的方式重写路径。
项目计划(先交付,后优化)
- 阶段 0:单体仓库配置(Turbo、TS 配置、共享包导入)。保持精简。
- 阶段 1:API 认证/会话端点:/login、/logout、/me。在 /login 上添加基本速率限制。
- 阶段 2:管理端 MVP:登录界面 + 服务器端守卫 + 最小化仪表板外壳。
- 阶段 3:公共平台的内容读取端点:/api/content/*(先实现只读)。
- 阶段 4:管理端的内容 CRUD + 媒体上传(从简单开始,后续再更换存储方案)。
- 阶段 5:监控与加固:Prometheus 指标端点、Grafana 仪表板、HTTP 头、备份。
完成定义(以免后续争论)
- 新浏览器可以登录 /admin,并在刷新后保持登录状态。
- 公共平台通过 /api/content 渲染 SSR 内容,且不暴露仅限管理员的端点。
- NGINX 在 docker-compose 中正确路由 /、/admin、/api。
- API 可以重启而不会破坏会话行为(开发存储重置是可以的,但必须优雅地失败)。
- 至少存在一个 Prometheus 抓取目标(即使 Grafana 面板是占位符)。
如果你正在实现一个新端点,请先决定:它是公共的、仅限管理员的,还是内部的。不要猜测。然后将其放在正确的前缀后面并加以保护。这就是全部要义。

