[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"portal-settings:stajic:zh":3,"public-menus:all":38,"post:a-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx:zh":205,"related:post:a-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx:zh:1":598},{"statusCode":4,"data":5,"message":37},200,{"tenantId":6,"lang":7,"defaultLang":8,"siteUrl":9,"contactEmail":10,"brandName":11,"logoUrl":12,"siteName":11,"siteDescription":13,"ogImage":10,"robotsIndex":14,"socialLinks":10,"reservedSlugs":10,"seoPolicy":15},"stajic","zh","de","https:\u002F\u002Fstajic.de",null,"Stajic Platform","\u002FLogo_Planet.svg","Stajic Portal",true,{"branding":16,"relatedContent":17,"crossDomainLinks":18},{"logoUrl":12},{"enabled":14},[19,22,25,28,31,34],{"url":20,"label":21,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Ffigure.rocks","figure.rocks",{"url":23,"label":24,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Floving.rocks","loving.rocks",{"url":26,"label":27,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.com","bazify.com",{"url":29,"label":30,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.de","bazify.de",{"url":32,"label":33,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.at","bazify.at",{"url":35,"label":36,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.ba","bazify.ba","Portal settings resolved",[39,45],{"id":40,"name":41,"location":42,"isActive":14,"isDefault":43,"items":44},1,"main-navigation","header",false,[],{"id":46,"name":47,"location":48,"isActive":14,"isDefault":14,"items":49},4,"main-menu","sidebar",[50,66,79,93,103,118,133],{"id":51,"title":52,"url":60,"target":61,"icon":62,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":64,"portfolioId":10,"children":65},"item-18",{"de":53,"en":54,"es":55,"fr":56,"it":54,"ru":57,"sr":58,"zh":59},"Startseite","Home","Inicio","Accueil","Главная","Почетна","首页","\u002Ffull-stack-web-developer-munich-performance-seo-and-maintainable-builds","_self","i-lucide-home","page",111,[],{"id":67,"title":68,"url":75,"target":61,"icon":76,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":77,"portfolioId":10,"children":78},"item-22",{"de":69,"en":69,"es":70,"fr":69,"it":71,"ru":72,"sr":73,"zh":74},"Vision","Visión","Visione","Видение","Визија","想象","\u002Fueber-uns-webdesign-muenchen-webaplikation","i-lucide-eye",113,[],{"id":80,"title":81,"url":89,"target":61,"icon":90,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":91,"portfolioId":10,"children":92},"item-19",{"de":82,"en":83,"es":84,"fr":83,"it":85,"ru":86,"sr":87,"zh":88},"Leistungen","Services","Servicios","Servizi","Услуги","Услуге","服务","\u002Fservices-dienstleistungen-muenchen","i-lucide-wrench",116,[],{"id":94,"title":95,"url":99,"target":61,"icon":100,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":101,"portfolioId":10,"children":102},"item-23",{"de":96,"en":96,"es":96,"fr":96,"it":96,"ru":97,"sr":97,"zh":98},"Blog","Блог","博客","\u002Fblog","i-lucide-book-open",112,[],{"id":104,"title":105,"url":114,"target":61,"icon":115,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":116,"portfolioId":10,"children":117},"item-32",{"de":106,"en":107,"es":108,"fr":109,"it":110,"ru":111,"sr":112,"zh":113},"Neue Technologien","New Technologies","Nuevas tecnologías","Nouvelles technologies","Nuove tecnologie","Новые технологии","Нове технологије","新技术！","\u002Fneue-webtechnologien","i-lucide-sparkles",122,[],{"id":119,"title":120,"url":129,"target":61,"icon":130,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":131,"portfolioId":10,"children":132},"item-20",{"de":121,"en":122,"es":123,"fr":124,"it":125,"ru":126,"sr":127,"zh":128},"Kontakt","Contact us!","Contacto","Contact","Contatto","Контакт","Контактирајте нас","联系我们！","\u002Fcontact","i-lucide-mail",115,[],{"id":134,"title":135,"url":144,"target":61,"icon":145,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":147},"item-21",{"de":136,"en":137,"es":138,"fr":139,"it":140,"ru":141,"sr":142,"zh":143},"Unsere Arbeit","Our Work","Nuestro trabajo","Nos réalisations","I nostri lavori","Наши работы","Наши радови","文件夹","\u002Fportfolio","i-lucide-briefcase",114,[148,161,175,181,193],{"id":149,"title":150,"url":144,"target":61,"icon":159,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":160},"item-24",{"de":151,"en":152,"es":153,"fr":154,"it":155,"ru":156,"sr":157,"zh":158},"Alle Projekte","All Projects","Todos los proyectos","Tous les projets","Tutti i progetti","Все проекты","Сви пројекти","所有项目","i-lucide-grid-3x3",[],{"id":162,"title":163,"url":171,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":174},"item-29",{"de":164,"en":165,"es":166,"fr":167,"it":168,"ru":169,"sr":170,"zh":143},"Local Roots, Global Reach","Local Roots - Global Reach","Empresa local ","Entreprise locale","Azienda locale","Местная компания","Локално предузеће глобално тржиште","\u002Fportfolio\u002Flocal-roots-global-reach-communication-media-systems-for-modern-business","i-lucide-folder","custom",[],{"id":176,"title":177,"url":179,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":180},"item-28",{"de":178,"en":178,"es":178,"fr":178,"it":178,"ru":178,"sr":178,"zh":178},"Solr Suggester","\u002Fportfolio\u002Fsolr-fuzzy-suggester-und-solr-infix-suggester-abfrage-ueber-ajax-und-filterung",[],{"id":182,"title":183,"url":191,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":192},"item-27",{"de":184,"en":185,"es":186,"fr":187,"it":188,"ru":189,"sr":190,"zh":185},"Firmenwebseite SEO","Company Website SEO","Sitio web corporativo SEO","Site web d’entreprise SEO","Sito web aziendale SEO","Корпоративный сайт SEO","Пословна веб-страница SEO","\u002Fportfolio\u002Fseo-sem-branding-mobile-webseite-muenchen",[],{"id":194,"title":195,"url":203,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":204},"item-31",{"de":196,"en":197,"es":198,"fr":199,"it":200,"ru":201,"sr":202,"zh":197},"Digitalisierungsportal","Digitalization Portal","Portal de digitalización","Portail de numérisation","Portale di digitalizzazione","Портал цифровизации","Портал за дигитализацију","\u002Fportfolio\u002Fdigitalisierungsportal-archiv-museum-bibliothek-ead-lido-mets-mods",[],{"statusCode":4,"data":206,"message":597},{"id":207,"title":208,"slug":209,"content":210,"contentJson":211,"excerpt":374,"featuredImage":375,"featuredImageAlt":376,"featuredImageCaption":10,"featuredImageTitle":10,"featuredImageCopyright":10,"featuredImageAuthor":10,"featuredImageSourceUrl":10,"featuredImageLicense":10,"featuredImageIsAiGenerated":43,"status":377,"publishedAt":378,"createdAt":379,"updatedAt":380,"seoLocalePaths":381,"categories":390,"author":398,"translations":403},"382","基于Next.js、Fastify、Prisma和NGINX的实用单体仓库架构","a-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1_arch\",\"data\":{\"text\":\"平台单体仓库：Next.js（公共 + 管理端）+ Fastify API + Prisma DB + NGINX\",\"level\":1},\"type\":\"header\"},{\"id\":\"p_scope\",\"data\":{\"text\":\"本文档描述了当前目标架构，该架构采用单体仓库，包含一个公共Next.js站点、一个带有Cookie会话的管理端Next.js应用、一个Fastify API以及一个基于Prisma的数据库包。本文档面向需要接触多个包并需要明确边界的团队成员。\"},\"type\":\"paragraph\"},{\"id\":\"h2_constraints\",\"data\":{\"text\":\"我们暂不妥协的约束条件\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_constraints\",\"data\":{\"items\":[\"公共站点必须采用SSR（Next.js App Router）。不允许仅使用SPA的快捷方式。\",\"公共站点只能通过位于 \u002Fapi 下的公共API路由与后端通信（例如，\u002Fapi\u002Fcontent, \u002Fapi\u002Fmedia）。\",\"管理端使用基于Cookie的会话认证。HTTP-only Cookie。不使用localStorage令牌。\",\"API使用Fastify。会话通过@fastify\u002Fsession实现。开发环境使用“fakeRedis”存储；生产环境后续使用Redis。\",\"数据库访问仅通过 @platform\u002Fdb 包。不允许在应用中分散使用直接的Prisma客户端。\",\"NGINX终止TLS并路由 \u002F、\u002Fadmin 和 \u002Fapi。初期使用一个域名即可；后续可选择使用子域名。\",\"从第一天起就建立监控（Prometheus抓取 + Grafana仪表板占位符）。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h2_layout\",\"data\":{\"text\":\"仓库布局\",\"level\":2},\"type\":\"header\"},{\"id\":\"code_tree\",\"data\":{\"code\":\"repo\u002F\\n  apps\u002F\\n    platform\u002F        # public site (SSR)\\n    admin\u002F           # admin UI (cookie session)\\n  packages\u002F\\n    api\u002F             # Fastify backend (auth\u002Fusers\u002Fcontent\u002Fmedia)\\n    db\u002F              # Prisma client + migrations\\n    shared\u002F          # shared types, UI bits, utilities\\n  INFRASTRUCTURE\u002F\\n    nginx\u002F\\n      nginx.conf\\n      sites\u002F\\n        app.conf\\n    monitoring\u002F\\n      prometheus.yml\\n      grafana\u002F\\n  docker-compose.yml\\n  turbo.json\\n  package.json\"},\"type\":\"code\"},{\"id\":\"h2_tradeoffs\",\"data\":{\"text\":\"权衡取舍（真正的取舍）\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_tradeoffs\",\"data\":{\"items\":[\"Cookie会话很普通。但这正是其优点。它们在代理后工作，并将认证逻辑排除在前端之外。缺点是：你必须正确设置SameSite\u002FSecure\u002Fpath，否则你会追查“随机”的登录错误。\",\"单一的 \u002Fapi 接口很清晰，但这同时也意味着你必须严格区分公共端点和仅限管理员的端点。不要因为方便而泄露管理员端点。\",\"Prisma在“多数据库就绪”的意义上可以切换提供商。它并不是一个能在单一模式下连接SQL和Mongo的魔法桥梁。如果有人这么说，那他们肯定没有实际部署过。\",\"Next.js SSR对SEO和首次加载有好处。但如果你没有有意设置缓存和重新验证，它也可能对API造成冲击。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h2_routing\",\"data\":{\"text\":\"路由契约（NGINX作为前门）\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_routing\",\"data\":{\"text\":\"我们目前将所有流量通过一个域名路由。这样调试更简单。如果我们后续将管理端移至子域名，我们将重新审视Cookie范围和CSRF假设。\"},\"type\":\"paragraph\"},{\"id\":\"code_nginx\",\"data\":{\"code\":\"# INFRASTRUCTURE\u002Fnginx\u002Fsites\u002Fapp.conf\\nserver {\\n  listen 80;\\n  server_name yourdomain.com;\\n\\n  location \u002Fapi\u002F {\\n    proxy_pass http:\u002F\u002Fapi:4000\u002F;\\n    proxy_set_header Host $host;\\n    proxy_set_header X-Forwarded-Proto $scheme;\\n    proxy_set_header X-Real-IP $remote_addr;\\n  }\\n\\n  location \u002Fadmin\u002F {\\n    proxy_pass http:\u002F\u002Fadmin:3001\u002F;\\n    proxy_set_header Host $host;\\n  }\\n\\n  location \u002F {\\n    proxy_pass http:\u002F\u002Fplatform:3000\u002F;\\n    proxy_set_header Host $host;\\n  }\\n}\"},\"type\":\"code\"},{\"id\":\"h2_api\",\"data\":{\"text\":\"API包（Fastify）—— 会话与认证\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_api\",\"data\":{\"text\":\"API负责认证和会话状态。应用从不生成令牌。它们只是回传Cookie。保持会话负载小巧。用户ID + 角色。仅此而已。\"},\"type\":\"paragraph\"},{\"id\":\"code_server\",\"data\":{\"code\":\"\u002F\u002F packages\u002Fapi\u002Fsrc\u002Fserver.ts\\nimport Fastify from \\\"fastify\\\";\\nimport cookie from \\\"@fastify\u002Fcookie\\\";\\nimport session from \\\"@fastify\u002Fsession\\\";\\nimport { buildSessionStore } from \\\".\u002FsessionStore\\\";\\nimport { authRoutes } from \\\".\u002Froutes\u002Fauth\\\";\\nimport { meRoutes } from \\\".\u002Froutes\u002Fme\\\";\\n\\nexport async function buildServer() {\\n  const app = Fastify({ logger: true });\\n\\n  await app.register(cookie);\\n  await app.register(session, {\\n    secret: process.env.SESSION_SECRET || \\\"dev-secret-change-me\\\",\\n    cookieName: \\\"sid\\\",\\n    cookie: {\\n      httpOnly: true,\\n      secure: process.env.NODE_ENV === \\\"production\\\",\\n      sameSite: \\\"lax\\\",\\n      path: \\\"\u002F\\\"\\n    },\\n    store: buildSessionStore(),\\n    saveUninitialized: false\\n  });\\n\\n  app.register(authRoutes, { prefix: \\\"\u002Fapi\\\" });\\n  app.register(meRoutes, { prefix: \\\"\u002Fapi\\\" });\\n\\n  return app;\\n}\\n\\n\u002F\u002F packages\u002Fapi\u002Fsrc\u002Findex.ts\\nimport { buildServer } from \\\".\u002Fserver\\\";\\n\\n(async () => {\\n  const app = await buildServer();\\n  const port = Number(process.env.PORT || 4000);\\n  await app.listen({ port, host: \\\"0.0.0.0\\\" });\\n})();\"},\"type\":\"code\"},{\"id\":\"code_session_store\",\"data\":{\"code\":\"\u002F\u002F packages\u002Fapi\u002Fsrc\u002FsessionStore.ts\\nimport type { SessionStore } from \\\"@fastify\u002Fsession\\\";\\n\\nexport function buildSessionStore(): SessionStore {\\n  const mem = new Map\u003Cstring, { value: any; expiresAt: number }>();\\n\\n  return {\\n    get: (sid, cb) => {\\n      const hit = mem.get(sid);\\n      if (!hit) return cb(null, null);\\n      if (Date.now() > hit.expiresAt) {\\n        mem.delete(sid);\\n        return cb(null, null);\\n      }\\n      cb(null, hit.value);\\n    },\\n    set: (sid, session, cb) => {\\n      const ttlMs = 1000 * 60 * 60 * 8;\\n      mem.set(sid, { value: session, expiresAt: Date.now() + ttlMs });\\n      cb(null);\\n    },\\n    destroy: (sid, cb) => {\\n      mem.delete(sid);\\n      cb(null);\\n    }\\n  };\\n}\"},\"type\":\"code\"},{\"id\":\"h2_db\",\"data\":{\"text\":\"数据库包（Prisma）—— 单一客户端，共享类型\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_db\",\"data\":{\"text\":\"我们从 @platform\u002Fdb 暴露一个 PrismaClient。API 只导入这个客户端。如果你因为“原型开发更快”而在应用中创建第二个 Prisma 客户端，你只是在制造未来的事故。\"},\"type\":\"paragraph\"},{\"id\":\"code_db\",\"data\":{\"code\":\"\u002F\u002F packages\u002Fdb\u002Fsrc\u002Findex.ts\\nimport { PrismaClient } from \\\"@prisma\u002Fclient\\\";\\n\\nexport const db = new PrismaClient();\"},\"type\":\"code\"},{\"id\":\"h2_admin\",\"data\":{\"text\":\"管理端应用 —— Cookie 会话与 \u002Fme 守卫\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_admin\",\"data\":{\"text\":\"管理端是一个普通的 Next.js App Router 应用。唯一特殊之处在于它必须将 API 视为唯一真实来源，并且在调用 \u002Fapi\u002Fme 时始终包含 Cookie。\"},\"type\":\"paragraph\"},{\"id\":\"code_admin_guard\",\"data\":{\"code\":\"\u002F\u002F apps\u002Fadmin\u002Fapp\u002F(admin)\u002Flayout.tsx\\nimport { cookies } from \\\"next\u002Fheaders\\\";\\n\\nasync function getMe() {\\n  const cookieHeader = cookies().toString();\\n  const res = await fetch(`${process.env.ADMIN_BASE_URL}\u002Fapi\u002Fme`, {\\n    headers: { cookie: cookieHeader },\\n    cache: \\\"no-store\\\"\\n  });\\n  return res.json();\\n}\\n\\nexport default async function AdminLayout({ children }: { children: React.ReactNode }) {\\n  const me = await getMe();\\n  if (!me?.ok) {\\n    return (\\n      \u003Chtml>\\n        \u003Cbody>\\n          \u003Cp>未经授权。请前往 \u002Fadmin\u002Flogin。\u003C\u002Fp>\\n        \u003C\u002Fbody>\\n      \u003C\u002Fhtml>\\n    );\\n  }\\n\\n  return (\\n    \u003Chtml>\\n      \u003Cbody>{children}\u003C\u002Fbody>\\n    \u003C\u002Fhtml>\\n  );\\n}\"},\"type\":\"code\"},{\"id\":\"h2_flow\",\"data\":{\"text\":\"逐步说明：管理端登录流程\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_flow\",\"data\":{\"items\":[\"浏览器提交凭据：POST \u002Fapi\u002Flogin\",\"NGINX 将 \u002Fapi\u002F* 转发给 Fastify\",\"Fastify 根据数据库（Prisma）验证用户\",\"Fastify 将会话写入 fakeRedis 存储（开发环境）并返回 Set-Cookie: sid=…\",\"浏览器存储 Cookie（HTTP-only）\",\"管理端服务器组件调用 GET \u002Fapi\u002Fme 并附上 Cookie\",\"仅当 \u002Fme 返回 ok=true 时，管理端才渲染仪表板\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h2_went_wrong\",\"data\":{\"text\":\"一个出过的问题（以免重蹈覆辙）\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_went_wrong\",\"data\":{\"text\":\"我们曾遇到登录循环，即使 \u002Fapi\u002Flogin 返回 200。Cookie 从未在 \u002Fapi\u002Fme 请求中发送回来。原因很普通：Cookie 路径与代理路由不匹配。我们错误地将 Cookie 路径设置为 \u002Fadmin，但 \u002Fapi\u002Fme 位于 \u002Fapi 下。浏览器完全按照应有的方式工作。它没有发送 Cookie。修复方法是将 Cookie 路径设置为 \\\"\u002F\\\"，并确认 NGINX 没有以破坏它的方式重写路径。\"},\"type\":\"paragraph\"},{\"id\":\"h2_project_plan\",\"data\":{\"text\":\"项目计划（先交付，后优化）\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_plan\",\"data\":{\"items\":[\"阶段 0：单体仓库配置（Turbo、TS 配置、共享包导入）。保持精简。\",\"阶段 1：API 认证\u002F会话端点：\u002Flogin、\u002Flogout、\u002Fme。在 \u002Flogin 上添加基本速率限制。\",\"阶段 2：管理端 MVP：登录界面 + 服务器端守卫 + 最小化仪表板外壳。\",\"阶段 3：公共平台的内容读取端点：\u002Fapi\u002Fcontent\u002F*（先实现只读）。\",\"阶段 4：管理端的内容 CRUD + 媒体上传（从简单开始，后续再更换存储方案）。\",\"阶段 5：监控与加固：Prometheus 指标端点、Grafana 仪表板、HTTP 头、备份。\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h2_definition_done\",\"data\":{\"text\":\"完成定义（以免后续争论）\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_dod\",\"data\":{\"items\":[\"新浏览器可以登录 \u002Fadmin，并在刷新后保持登录状态。\",\"公共平台通过 \u002Fapi\u002Fcontent 渲染 SSR 内容，且不暴露仅限管理员的端点。\",\"NGINX 在 docker-compose 中正确路由 \u002F、\u002Fadmin、\u002Fapi。\",\"API 可以重启而不会破坏会话行为（开发存储重置是可以的，但必须优雅地失败）。\",\"至少存在一个 Prometheus 抓取目标（即使 Grafana 面板是占位符）。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"hr_end\",\"data\":{},\"type\":\"delimiter\"},{\"id\":\"p_next\",\"data\":{\"text\":\"如果你正在实现一个新端点，请先决定：它是公共的、仅限管理员的，还是内部的。不要猜测。然后将其放在正确的前缀后面并加以保护。这就是全部要义。\"},\"type\":\"paragraph\"}],\"version\":\"2.29.1\"}",{"time":212,"blocks":213,"version":373},1769827200000,[214,218,223,228,241,245,250,254,262,266,270,274,278,282,286,290,294,298,302,306,310,314,318,330,334,338,342,352,356,365,369],{"id":215,"data":216,"type":42},"h1_arch",{"text":217,"level":40},"平台单体仓库：Next.js（公共 + 管理端）+ Fastify API + Prisma DB + NGINX",{"id":219,"data":220,"type":222},"p_scope",{"text":221},"本文档描述了当前目标架构，该架构采用单体仓库，包含一个公共Next.js站点、一个带有Cookie会话的管理端Next.js应用、一个Fastify API以及一个基于Prisma的数据库包。本文档面向需要接触多个包并需要明确边界的团队成员。","paragraph",{"id":224,"data":225,"type":42},"h2_constraints",{"text":226,"level":227},"我们暂不妥协的约束条件",2,{"id":229,"data":230,"type":240},"list_constraints",{"items":231,"style":239},[232,233,234,235,236,237,238],"公共站点必须采用SSR（Next.js App Router）。不允许仅使用SPA的快捷方式。","公共站点只能通过位于 \u002Fapi 下的公共API路由与后端通信（例如，\u002Fapi\u002Fcontent, \u002Fapi\u002Fmedia）。","管理端使用基于Cookie的会话认证。HTTP-only Cookie。不使用localStorage令牌。","API使用Fastify。会话通过@fastify\u002Fsession实现。开发环境使用“fakeRedis”存储；生产环境后续使用Redis。","数据库访问仅通过 @platform\u002Fdb 包。不允许在应用中分散使用直接的Prisma客户端。","NGINX终止TLS并路由 \u002F、\u002Fadmin 和 \u002Fapi。初期使用一个域名即可；后续可选择使用子域名。","从第一天起就建立监控（Prometheus抓取 + Grafana仪表板占位符）。","unordered","list",{"id":242,"data":243,"type":42},"h2_layout",{"text":244,"level":227},"仓库布局",{"id":246,"data":247,"type":249},"code_tree",{"code":248},"repo\u002F\n  apps\u002F\n    platform\u002F        # public site (SSR)\n    admin\u002F           # admin UI (cookie session)\n  packages\u002F\n    api\u002F             # Fastify backend (auth\u002Fusers\u002Fcontent\u002Fmedia)\n    db\u002F              # Prisma client + migrations\n    shared\u002F          # shared types, UI bits, utilities\n  INFRASTRUCTURE\u002F\n    nginx\u002F\n      nginx.conf\n      sites\u002F\n        app.conf\n    monitoring\u002F\n      prometheus.yml\n      grafana\u002F\n  docker-compose.yml\n  turbo.json\n  package.json","code",{"id":251,"data":252,"type":42},"h2_tradeoffs",{"text":253,"level":227},"权衡取舍（真正的取舍）",{"id":255,"data":256,"type":240},"list_tradeoffs",{"items":257,"style":239},[258,259,260,261],"Cookie会话很普通。但这正是其优点。它们在代理后工作，并将认证逻辑排除在前端之外。缺点是：你必须正确设置SameSite\u002FSecure\u002Fpath，否则你会追查“随机”的登录错误。","单一的 \u002Fapi 接口很清晰，但这同时也意味着你必须严格区分公共端点和仅限管理员的端点。不要因为方便而泄露管理员端点。","Prisma在“多数据库就绪”的意义上可以切换提供商。它并不是一个能在单一模式下连接SQL和Mongo的魔法桥梁。如果有人这么说，那他们肯定没有实际部署过。","Next.js SSR对SEO和首次加载有好处。但如果你没有有意设置缓存和重新验证，它也可能对API造成冲击。",{"id":263,"data":264,"type":42},"h2_routing",{"text":265,"level":227},"路由契约（NGINX作为前门）",{"id":267,"data":268,"type":222},"p_routing",{"text":269},"我们目前将所有流量通过一个域名路由。这样调试更简单。如果我们后续将管理端移至子域名，我们将重新审视Cookie范围和CSRF假设。",{"id":271,"data":272,"type":249},"code_nginx",{"code":273},"# INFRASTRUCTURE\u002Fnginx\u002Fsites\u002Fapp.conf\nserver {\n  listen 80;\n  server_name yourdomain.com;\n\n  location \u002Fapi\u002F {\n    proxy_pass http:\u002F\u002Fapi:4000\u002F;\n    proxy_set_header Host $host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    proxy_set_header X-Real-IP $remote_addr;\n  }\n\n  location \u002Fadmin\u002F {\n    proxy_pass http:\u002F\u002Fadmin:3001\u002F;\n    proxy_set_header Host $host;\n  }\n\n  location \u002F {\n    proxy_pass http:\u002F\u002Fplatform:3000\u002F;\n    proxy_set_header Host $host;\n  }\n}",{"id":275,"data":276,"type":42},"h2_api",{"text":277,"level":227},"API包（Fastify）—— 会话与认证",{"id":279,"data":280,"type":222},"p_api",{"text":281},"API负责认证和会话状态。应用从不生成令牌。它们只是回传Cookie。保持会话负载小巧。用户ID + 角色。仅此而已。",{"id":283,"data":284,"type":249},"code_server",{"code":285},"\u002F\u002F packages\u002Fapi\u002Fsrc\u002Fserver.ts\nimport Fastify from \"fastify\";\nimport cookie from \"@fastify\u002Fcookie\";\nimport session from \"@fastify\u002Fsession\";\nimport { buildSessionStore } from \".\u002FsessionStore\";\nimport { authRoutes } from \".\u002Froutes\u002Fauth\";\nimport { meRoutes } from \".\u002Froutes\u002Fme\";\n\nexport async function buildServer() {\n  const app = Fastify({ logger: true });\n\n  await app.register(cookie);\n  await app.register(session, {\n    secret: process.env.SESSION_SECRET || \"dev-secret-change-me\",\n    cookieName: \"sid\",\n    cookie: {\n      httpOnly: true,\n      secure: process.env.NODE_ENV === \"production\",\n      sameSite: \"lax\",\n      path: \"\u002F\"\n    },\n    store: buildSessionStore(),\n    saveUninitialized: false\n  });\n\n  app.register(authRoutes, { prefix: \"\u002Fapi\" });\n  app.register(meRoutes, { prefix: \"\u002Fapi\" });\n\n  return app;\n}\n\n\u002F\u002F packages\u002Fapi\u002Fsrc\u002Findex.ts\nimport { buildServer } from \".\u002Fserver\";\n\n(async () => {\n  const app = await buildServer();\n  const port = Number(process.env.PORT || 4000);\n  await app.listen({ port, host: \"0.0.0.0\" });\n})();",{"id":287,"data":288,"type":249},"code_session_store",{"code":289},"\u002F\u002F packages\u002Fapi\u002Fsrc\u002FsessionStore.ts\nimport type { SessionStore } from \"@fastify\u002Fsession\";\n\nexport function buildSessionStore(): SessionStore {\n  const mem = new Map\u003Cstring, { value: any; expiresAt: number }>();\n\n  return {\n    get: (sid, cb) => {\n      const hit = mem.get(sid);\n      if (!hit) return cb(null, null);\n      if (Date.now() > hit.expiresAt) {\n        mem.delete(sid);\n        return cb(null, null);\n      }\n      cb(null, hit.value);\n    },\n    set: (sid, session, cb) => {\n      const ttlMs = 1000 * 60 * 60 * 8;\n      mem.set(sid, { value: session, expiresAt: Date.now() + ttlMs });\n      cb(null);\n    },\n    destroy: (sid, cb) => {\n      mem.delete(sid);\n      cb(null);\n    }\n  };\n}",{"id":291,"data":292,"type":42},"h2_db",{"text":293,"level":227},"数据库包（Prisma）—— 单一客户端，共享类型",{"id":295,"data":296,"type":222},"p_db",{"text":297},"我们从 @platform\u002Fdb 暴露一个 PrismaClient。API 只导入这个客户端。如果你因为“原型开发更快”而在应用中创建第二个 Prisma 客户端，你只是在制造未来的事故。",{"id":299,"data":300,"type":249},"code_db",{"code":301},"\u002F\u002F packages\u002Fdb\u002Fsrc\u002Findex.ts\nimport { PrismaClient } from \"@prisma\u002Fclient\";\n\nexport const db = new PrismaClient();",{"id":303,"data":304,"type":42},"h2_admin",{"text":305,"level":227},"管理端应用 —— Cookie 会话与 \u002Fme 守卫",{"id":307,"data":308,"type":222},"p_admin",{"text":309},"管理端是一个普通的 Next.js App Router 应用。唯一特殊之处在于它必须将 API 视为唯一真实来源，并且在调用 \u002Fapi\u002Fme 时始终包含 Cookie。",{"id":311,"data":312,"type":249},"code_admin_guard",{"code":313},"\u002F\u002F apps\u002Fadmin\u002Fapp\u002F(admin)\u002Flayout.tsx\nimport { cookies } from \"next\u002Fheaders\";\n\nasync function getMe() {\n  const cookieHeader = cookies().toString();\n  const res = await fetch(`${process.env.ADMIN_BASE_URL}\u002Fapi\u002Fme`, {\n    headers: { cookie: cookieHeader },\n    cache: \"no-store\"\n  });\n  return res.json();\n}\n\nexport default async function AdminLayout({ children }: { children: React.ReactNode }) {\n  const me = await getMe();\n  if (!me?.ok) {\n    return (\n      \u003Chtml>\n        \u003Cbody>\n          \u003Cp>未经授权。请前往 \u002Fadmin\u002Flogin。\u003C\u002Fp>\n        \u003C\u002Fbody>\n      \u003C\u002Fhtml>\n    );\n  }\n\n  return (\n    \u003Chtml>\n      \u003Cbody>{children}\u003C\u002Fbody>\n    \u003C\u002Fhtml>\n  );\n}",{"id":315,"data":316,"type":42},"h2_flow",{"text":317,"level":227},"逐步说明：管理端登录流程",{"id":319,"data":320,"type":240},"list_flow",{"items":321,"style":329},[322,323,324,325,326,327,328],"浏览器提交凭据：POST \u002Fapi\u002Flogin","NGINX 将 \u002Fapi\u002F* 转发给 Fastify","Fastify 根据数据库（Prisma）验证用户","Fastify 将会话写入 fakeRedis 存储（开发环境）并返回 Set-Cookie: sid=…","浏览器存储 Cookie（HTTP-only）","管理端服务器组件调用 GET \u002Fapi\u002Fme 并附上 Cookie","仅当 \u002Fme 返回 ok=true 时，管理端才渲染仪表板","ordered",{"id":331,"data":332,"type":42},"h2_went_wrong",{"text":333,"level":227},"一个出过的问题（以免重蹈覆辙）",{"id":335,"data":336,"type":222},"p_went_wrong",{"text":337},"我们曾遇到登录循环，即使 \u002Fapi\u002Flogin 返回 200。Cookie 从未在 \u002Fapi\u002Fme 请求中发送回来。原因很普通：Cookie 路径与代理路由不匹配。我们错误地将 Cookie 路径设置为 \u002Fadmin，但 \u002Fapi\u002Fme 位于 \u002Fapi 下。浏览器完全按照应有的方式工作。它没有发送 Cookie。修复方法是将 Cookie 路径设置为 \"\u002F\"，并确认 NGINX 没有以破坏它的方式重写路径。",{"id":339,"data":340,"type":42},"h2_project_plan",{"text":341,"level":227},"项目计划（先交付，后优化）",{"id":343,"data":344,"type":240},"list_plan",{"items":345,"style":329},[346,347,348,349,350,351],"阶段 0：单体仓库配置（Turbo、TS 配置、共享包导入）。保持精简。","阶段 1：API 认证\u002F会话端点：\u002Flogin、\u002Flogout、\u002Fme。在 \u002Flogin 上添加基本速率限制。","阶段 2：管理端 MVP：登录界面 + 服务器端守卫 + 最小化仪表板外壳。","阶段 3：公共平台的内容读取端点：\u002Fapi\u002Fcontent\u002F*（先实现只读）。","阶段 4：管理端的内容 CRUD + 媒体上传（从简单开始，后续再更换存储方案）。","阶段 5：监控与加固：Prometheus 指标端点、Grafana 仪表板、HTTP 头、备份。",{"id":353,"data":354,"type":42},"h2_definition_done",{"text":355,"level":227},"完成定义（以免后续争论）",{"id":357,"data":358,"type":240},"list_dod",{"items":359,"style":239},[360,361,362,363,364],"新浏览器可以登录 \u002Fadmin，并在刷新后保持登录状态。","公共平台通过 \u002Fapi\u002Fcontent 渲染 SSR 内容，且不暴露仅限管理员的端点。","NGINX 在 docker-compose 中正确路由 \u002F、\u002Fadmin、\u002Fapi。","API 可以重启而不会破坏会话行为（开发存储重置是可以的，但必须优雅地失败）。","至少存在一个 Prometheus 抓取目标（即使 Grafana 面板是占位符）。",{"id":366,"data":367,"type":368},"hr_end",{},"delimiter",{"id":370,"data":371,"type":222},"p_next",{"text":372},"如果你正在实现一个新端点，请先决定：它是公共的、仅限管理员的，还是内部的。不要猜测。然后将其放在正确的前缀后面并加以保护。这就是全部要义。","2.29.1","探索一种实用的单体仓库架构，结合Next.js、Fastify、Prisma与NGINX，重点展示实际集成与工作流程。","\u002Fuploads\u002F2026\u002F01\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx-1769885526116-q1100n.webp","a-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx-1769885526116-q1100n","PUBLISHED","2026-01-31T08:18:00.000Z","2026-01-31T17:18:53.110Z","2026-02-20T20:41:28.972Z",{"en":382,"de":383,"sr":384,"es":385,"fr":386,"it":387,"ru":388,"zh":389},"\u002Fblog\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx","\u002Fde\u002Fblog\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx","\u002Fsr\u002Fblog\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx","\u002Fes\u002Fblog\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx","\u002Ffr\u002Fblog\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx","\u002Fit\u002Fblog\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx","\u002Fru\u002Fblog\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx","\u002Fzh\u002Fblog\u002Fa-practical-monorepo-architecture-next-js-platform-admin-fastify-api-prisma-and-nginx",[391,394],{"id":46,"name":392,"slug":393},"Aaasaasa","aaasaasa",{"id":395,"name":396,"slug":397},48,"能力","capabilities",{"id":399,"login":400,"email":401,"displayName":402},"20","rooth8233","aleksandar@stajic.de","Aleksandar Stajić",[404,527],{"lang":405,"title":406,"content":407,"contentJson":408,"excerpt":526},"en","A Practical Monorepo Architecture with Next.js, Fastify, Prisma, and NGINX","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1_arch\",\"data\":{\"text\":\"Platform Monorepo: Next.js (Public + Admin) + Fastify API + Prisma DB + NGINX\",\"level\":1},\"type\":\"header\"},{\"id\":\"p_scope\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"h2_constraints\",\"data\":{\"text\":\"Constraints we’re not negotiating (for now)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_constraints\",\"data\":{\"items\":[\"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 \u002Fapi (e.g., \u002Fapi\u002Fcontent, \u002Fapi\u002Fmedia).\",\"Admin uses cookie-based session auth. HTTP-only cookie. No localStorage tokens.\",\"API is Fastify. Sessions via @fastify\u002Fsession. “fakeRedis” store for development; Redis in production later.\",\"DB access is via @platform\u002Fdb only. No direct Prisma clients scattered in apps.\",\"NGINX terminates TLS and routes \u002F, \u002Fadmin, and \u002Fapi. One domain is fine; subdomain is optional later.\",\"Monitoring exists from day one (Prometheus scrape + Grafana dashboard placeholders).\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h2_layout\",\"data\":{\"text\":\"Repository layout\",\"level\":2},\"type\":\"header\"},{\"id\":\"code_tree\",\"data\":{\"code\":\"repo\u002F\\n  apps\u002F\\n    platform\u002F        # public site (SSR)\\n    admin\u002F           # admin UI (cookie session)\\n  packages\u002F\\n    api\u002F             # Fastify backend (auth\u002Fusers\u002Fcontent\u002Fmedia)\\n    db\u002F              # Prisma client + migrations\\n    shared\u002F          # shared types, UI bits, utilities\\n  INFRASTRUCTURE\u002F\\n    nginx\u002F\\n      nginx.conf\\n      sites\u002F\\n        app.conf\\n    monitoring\u002F\\n      prometheus.yml\\n      grafana\u002F\\n  docker-compose.yml\\n  turbo.json\\n  package.json\"},\"type\":\"code\"},{\"id\":\"h2_tradeoffs\",\"data\":{\"text\":\"Trade-offs (the real ones)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_tradeoffs\",\"data\":{\"items\":[\"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\u002FSecure\u002Fpath right, or you’ll chase “random” login bugs.\",\"One \u002Fapi 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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h2_routing\",\"data\":{\"text\":\"Routing contract (NGINX as the front door)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_routing\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"code_nginx\",\"data\":{\"code\":\"# INFRASTRUCTURE\u002Fnginx\u002Fsites\u002Fapp.conf\\nserver {\\n  listen 80;\\n  server_name yourdomain.com;\\n\\n  location \u002Fapi\u002F {\\n    proxy_pass http:\u002F\u002Fapi:4000\u002F;\\n    proxy_set_header Host $host;\\n    proxy_set_header X-Forwarded-Proto $scheme;\\n    proxy_set_header X-Real-IP $remote_addr;\\n  }\\n\\n  location \u002Fadmin\u002F {\\n    proxy_pass http:\u002F\u002Fadmin:3001\u002F;\\n    proxy_set_header Host $host;\\n  }\\n\\n  location \u002F {\\n    proxy_pass http:\u002F\u002Fplatform:3000\u002F;\\n    proxy_set_header Host $host;\\n  }\\n}\"},\"type\":\"code\"},{\"id\":\"h2_api\",\"data\":{\"text\":\"API package (Fastify) — sessions and auth\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_api\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"code_server\",\"data\":{\"code\":\"\u002F\u002F packages\u002Fapi\u002Fsrc\u002Fserver.ts\\nimport Fastify from \\\"fastify\\\";\\nimport cookie from \\\"@fastify\u002Fcookie\\\";\\nimport session from \\\"@fastify\u002Fsession\\\";\\nimport { buildSessionStore } from \\\".\u002FsessionStore\\\";\\nimport { authRoutes } from \\\".\u002Froutes\u002Fauth\\\";\\nimport { meRoutes } from \\\".\u002Froutes\u002Fme\\\";\\n\\nexport async function buildServer() {\\n  const app = Fastify({ logger: true });\\n\\n  await app.register(cookie);\\n  await app.register(session, {\\n    secret: process.env.SESSION_SECRET || \\\"dev-secret-change-me\\\",\\n    cookieName: \\\"sid\\\",\\n    cookie: {\\n      httpOnly: true,\\n      secure: process.env.NODE_ENV === \\\"production\\\",\\n      sameSite: \\\"lax\\\",\\n      path: \\\"\u002F\\\"\\n    },\\n    store: buildSessionStore(),\\n    saveUninitialized: false\\n  });\\n\\n  app.register(authRoutes, { prefix: \\\"\u002Fapi\\\" });\\n  app.register(meRoutes, { prefix: \\\"\u002Fapi\\\" });\\n\\n  return app;\\n}\\n\\n\u002F\u002F packages\u002Fapi\u002Fsrc\u002Findex.ts\\nimport { buildServer } from \\\".\u002Fserver\\\";\\n\\n(async () => {\\n  const app = await buildServer();\\n  const port = Number(process.env.PORT || 4000);\\n  await app.listen({ port, host: \\\"0.0.0.0\\\" });\\n})();\"},\"type\":\"code\"},{\"id\":\"code_session_store\",\"data\":{\"code\":\"\u002F\u002F packages\u002Fapi\u002Fsrc\u002FsessionStore.ts\\nimport type { SessionStore } from \\\"@fastify\u002Fsession\\\";\\n\\nexport function buildSessionStore(): SessionStore {\\n  const mem = new Map\u003Cstring, { value: any; expiresAt: number }>();\\n\\n  return {\\n    get: (sid, cb) => {\\n      const hit = mem.get(sid);\\n      if (!hit) return cb(null, null);\\n      if (Date.now() > hit.expiresAt) {\\n        mem.delete(sid);\\n        return cb(null, null);\\n      }\\n      cb(null, hit.value);\\n    },\\n    set: (sid, session, cb) => {\\n      const ttlMs = 1000 * 60 * 60 * 8;\\n      mem.set(sid, { value: session, expiresAt: Date.now() + ttlMs });\\n      cb(null);\\n    },\\n    destroy: (sid, cb) => {\\n      mem.delete(sid);\\n      cb(null);\\n    }\\n  };\\n}\"},\"type\":\"code\"},{\"id\":\"h2_db\",\"data\":{\"text\":\"DB package (Prisma) — single client, shared types\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_db\",\"data\":{\"text\":\"We expose one PrismaClient from @platform\u002Fdb. 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.\"},\"type\":\"paragraph\"},{\"id\":\"code_db\",\"data\":{\"code\":\"\u002F\u002F packages\u002Fdb\u002Fsrc\u002Findex.ts\\nimport { PrismaClient } from \\\"@prisma\u002Fclient\\\";\\n\\nexport const db = new PrismaClient();\"},\"type\":\"code\"},{\"id\":\"h2_admin\",\"data\":{\"text\":\"Admin app — cookie session and \u002Fme guard\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_admin\",\"data\":{\"text\":\"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 \u002Fapi\u002Fme.\"},\"type\":\"paragraph\"},{\"id\":\"code_admin_guard\",\"data\":{\"code\":\"\u002F\u002F apps\u002Fadmin\u002Fapp\u002F(admin)\u002Flayout.tsx\\nimport { cookies } from \\\"next\u002Fheaders\\\";\\n\\nasync function getMe() {\\n  const cookieHeader = cookies().toString();\\n  const res = await fetch(`${process.env.ADMIN_BASE_URL}\u002Fapi\u002Fme`, {\\n    headers: { cookie: cookieHeader },\\n    cache: \\\"no-store\\\"\\n  });\\n  return res.json();\\n}\\n\\nexport default async function AdminLayout({ children }: { children: React.ReactNode }) {\\n  const me = await getMe();\\n  if (!me?.ok) {\\n    return (\\n      \u003Chtml>\\n        \u003Cbody>\\n          \u003Cp>Unauthorized. Go to \u002Fadmin\u002Flogin.\u003C\u002Fp>\\n        \u003C\u002Fbody>\\n      \u003C\u002Fhtml>\\n    );\\n  }\\n\\n  return (\\n    \u003Chtml>\\n      \u003Cbody>{children}\u003C\u002Fbody>\\n    \u003C\u002Fhtml>\\n  );\\n}\"},\"type\":\"code\"},{\"id\":\"h2_flow\",\"data\":{\"text\":\"Step-by-step: admin login flow\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_flow\",\"data\":{\"items\":[\"Browser submits credentials: POST \u002Fapi\u002Flogin\",\"NGINX forwards \u002Fapi\u002F* to Fastify\",\"Fastify validates user against DB (Prisma)\",\"Fastify writes session into fakeRedis store (dev) and returns Set-Cookie: sid=…\",\"Browser stores the cookie (HTTP-only)\",\"Admin server components call GET \u002Fapi\u002Fme with the cookie attached\",\"Admin renders dashboard only if \u002Fme returns ok=true\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h2_went_wrong\",\"data\":{\"text\":\"One thing that went wrong (so we don’t repeat it)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p_went_wrong\",\"data\":{\"text\":\"We had a login loop even though \u002Fapi\u002Flogin returned 200. The cookie was never sent back on \u002Fapi\u002Fme. The cause was boring: cookie path + proxy routing mismatch. We set the cookie for \u002Fadmin by accident, but \u002Fapi\u002Fme lives under \u002Fapi. Browser did exactly what it should. It didn’t send the cookie. Fix was to set cookie path to \\\"\u002F\\\" and confirm NGINX wasn’t rewriting paths in a way that broke it.\"},\"type\":\"paragraph\"},{\"id\":\"h2_project_plan\",\"data\":{\"text\":\"Project plan (ship-first, refine later)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_plan\",\"data\":{\"items\":[\"Phase 0: monorepo wiring (Turbo, TS config, shared package imports). Keep it lean.\",\"Phase 1: API auth\u002Fsession endpoints: \u002Flogin, \u002Flogout, \u002Fme. Add basic rate limiting on \u002Flogin.\",\"Phase 2: Admin MVP: login screen + server-side guard + minimal dashboard shell.\",\"Phase 3: Content read endpoints for public platform: \u002Fapi\u002Fcontent\u002F* (read-only first).\",\"Phase 4: Content CRUD in admin + media upload (start simple, then swap storage later).\",\"Phase 5: Monitoring and hardening: Prometheus metrics endpoint, Grafana dashboards, headers, backups.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h2_definition_done\",\"data\":{\"text\":\"Definition of done (so we don’t argue later)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list_dod\",\"data\":{\"items\":[\"A fresh browser can log into \u002Fadmin and stay logged in across refreshes.\",\"Public platform renders SSR content from \u002Fapi\u002Fcontent without exposing admin-only endpoints.\",\"NGINX routes \u002F, \u002Fadmin, \u002Fapi 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).\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"hr_end\",\"data\":{},\"type\":\"delimiter\"},{\"id\":\"p_next\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"}],\"version\":\"2.29.1\"}",{"time":212,"blocks":409,"version":373},[410,413,416,419,429,432,434,437,444,447,450,452,455,458,460,462,465,468,470,473,476,479,482,492,495,498,501,510,513,521,523],{"id":215,"data":411,"type":42},{"text":412,"level":40},"Platform Monorepo: Next.js (Public + Admin) + Fastify API + Prisma DB + NGINX",{"id":219,"data":414,"type":222},{"text":415},"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.",{"id":224,"data":417,"type":42},{"text":418,"level":227},"Constraints we’re not negotiating (for now)",{"id":229,"data":420,"type":240},{"items":421,"style":239},[422,423,424,425,426,427,428],"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 \u002Fapi (e.g., \u002Fapi\u002Fcontent, \u002Fapi\u002Fmedia).","Admin uses cookie-based session auth. HTTP-only cookie. No localStorage tokens.","API is Fastify. Sessions via @fastify\u002Fsession. “fakeRedis” store for development; Redis in production later.","DB access is via @platform\u002Fdb only. No direct Prisma clients scattered in apps.","NGINX terminates TLS and routes \u002F, \u002Fadmin, and \u002Fapi. One domain is fine; subdomain is optional later.","Monitoring exists from day one (Prometheus scrape + Grafana dashboard placeholders).",{"id":242,"data":430,"type":42},{"text":431,"level":227},"Repository layout",{"id":246,"data":433,"type":249},{"code":248},{"id":251,"data":435,"type":42},{"text":436,"level":227},"Trade-offs (the real ones)",{"id":255,"data":438,"type":240},{"items":439,"style":239},[440,441,442,443],"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\u002FSecure\u002Fpath right, or you’ll chase “random” login bugs.","One \u002Fapi 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.",{"id":263,"data":445,"type":42},{"text":446,"level":227},"Routing contract (NGINX as the front door)",{"id":267,"data":448,"type":222},{"text":449},"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.",{"id":271,"data":451,"type":249},{"code":273},{"id":275,"data":453,"type":42},{"text":454,"level":227},"API package (Fastify) — sessions and auth",{"id":279,"data":456,"type":222},{"text":457},"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.",{"id":283,"data":459,"type":249},{"code":285},{"id":287,"data":461,"type":249},{"code":289},{"id":291,"data":463,"type":42},{"text":464,"level":227},"DB package (Prisma) — single client, shared types",{"id":295,"data":466,"type":222},{"text":467},"We expose one PrismaClient from @platform\u002Fdb. 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.",{"id":299,"data":469,"type":249},{"code":301},{"id":303,"data":471,"type":42},{"text":472,"level":227},"Admin app — cookie session and \u002Fme guard",{"id":307,"data":474,"type":222},{"text":475},"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 \u002Fapi\u002Fme.",{"id":311,"data":477,"type":249},{"code":478},"\u002F\u002F apps\u002Fadmin\u002Fapp\u002F(admin)\u002Flayout.tsx\nimport { cookies } from \"next\u002Fheaders\";\n\nasync function getMe() {\n  const cookieHeader = cookies().toString();\n  const res = await fetch(`${process.env.ADMIN_BASE_URL}\u002Fapi\u002Fme`, {\n    headers: { cookie: cookieHeader },\n    cache: \"no-store\"\n  });\n  return res.json();\n}\n\nexport default async function AdminLayout({ children }: { children: React.ReactNode }) {\n  const me = await getMe();\n  if (!me?.ok) {\n    return (\n      \u003Chtml>\n        \u003Cbody>\n          \u003Cp>Unauthorized. Go to \u002Fadmin\u002Flogin.\u003C\u002Fp>\n        \u003C\u002Fbody>\n      \u003C\u002Fhtml>\n    );\n  }\n\n  return (\n    \u003Chtml>\n      \u003Cbody>{children}\u003C\u002Fbody>\n    \u003C\u002Fhtml>\n  );\n}",{"id":315,"data":480,"type":42},{"text":481,"level":227},"Step-by-step: admin login flow",{"id":319,"data":483,"type":240},{"items":484,"style":329},[485,486,487,488,489,490,491],"Browser submits credentials: POST \u002Fapi\u002Flogin","NGINX forwards \u002Fapi\u002F* to Fastify","Fastify validates user against DB (Prisma)","Fastify writes session into fakeRedis store (dev) and returns Set-Cookie: sid=…","Browser stores the cookie (HTTP-only)","Admin server components call GET \u002Fapi\u002Fme with the cookie attached","Admin renders dashboard only if \u002Fme returns ok=true",{"id":331,"data":493,"type":42},{"text":494,"level":227},"One thing that went wrong (so we don’t repeat it)",{"id":335,"data":496,"type":222},{"text":497},"We had a login loop even though \u002Fapi\u002Flogin returned 200. The cookie was never sent back on \u002Fapi\u002Fme. The cause was boring: cookie path + proxy routing mismatch. We set the cookie for \u002Fadmin by accident, but \u002Fapi\u002Fme lives under \u002Fapi. Browser did exactly what it should. It didn’t send the cookie. Fix was to set cookie path to \"\u002F\" and confirm NGINX wasn’t rewriting paths in a way that broke it.",{"id":339,"data":499,"type":42},{"text":500,"level":227},"Project plan (ship-first, refine later)",{"id":343,"data":502,"type":240},{"items":503,"style":329},[504,505,506,507,508,509],"Phase 0: monorepo wiring (Turbo, TS config, shared package imports). Keep it lean.","Phase 1: API auth\u002Fsession endpoints: \u002Flogin, \u002Flogout, \u002Fme. Add basic rate limiting on \u002Flogin.","Phase 2: Admin MVP: login screen + server-side guard + minimal dashboard shell.","Phase 3: Content read endpoints for public platform: \u002Fapi\u002Fcontent\u002F* (read-only first).","Phase 4: Content CRUD in admin + media upload (start simple, then swap storage later).","Phase 5: Monitoring and hardening: Prometheus metrics endpoint, Grafana dashboards, headers, backups.",{"id":353,"data":511,"type":42},{"text":512,"level":227},"Definition of done (so we don’t argue later)",{"id":357,"data":514,"type":240},{"items":515,"style":239},[516,517,518,519,520],"A fresh browser can log into \u002Fadmin and stay logged in across refreshes.","Public platform renders SSR content from \u002Fapi\u002Fcontent without exposing admin-only endpoints.","NGINX routes \u002F, \u002Fadmin, \u002Fapi 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).",{"id":366,"data":522,"type":368},{},{"id":370,"data":524,"type":222},{"text":525},"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.","Explore a practical monorepo architecture using Next.js, Fastify, Prisma, and NGINX, highlighting real-world integration and workflow.",{"lang":7,"title":208,"content":210,"contentJson":528,"excerpt":374},{"time":212,"blocks":529,"version":373},[530,532,534,536,539,541,543,545,548,550,552,554,556,558,560,562,564,566,568,570,572,574,576,579,581,583,585,588,590,593,595],{"id":215,"data":531,"type":42},{"text":217,"level":40},{"id":219,"data":533,"type":222},{"text":221},{"id":224,"data":535,"type":42},{"text":226,"level":227},{"id":229,"data":537,"type":240},{"items":538,"style":239},[232,233,234,235,236,237,238],{"id":242,"data":540,"type":42},{"text":244,"level":227},{"id":246,"data":542,"type":249},{"code":248},{"id":251,"data":544,"type":42},{"text":253,"level":227},{"id":255,"data":546,"type":240},{"items":547,"style":239},[258,259,260,261],{"id":263,"data":549,"type":42},{"text":265,"level":227},{"id":267,"data":551,"type":222},{"text":269},{"id":271,"data":553,"type":249},{"code":273},{"id":275,"data":555,"type":42},{"text":277,"level":227},{"id":279,"data":557,"type":222},{"text":281},{"id":283,"data":559,"type":249},{"code":285},{"id":287,"data":561,"type":249},{"code":289},{"id":291,"data":563,"type":42},{"text":293,"level":227},{"id":295,"data":565,"type":222},{"text":297},{"id":299,"data":567,"type":249},{"code":301},{"id":303,"data":569,"type":42},{"text":305,"level":227},{"id":307,"data":571,"type":222},{"text":309},{"id":311,"data":573,"type":249},{"code":313},{"id":315,"data":575,"type":42},{"text":317,"level":227},{"id":319,"data":577,"type":240},{"items":578,"style":329},[322,323,324,325,326,327,328],{"id":331,"data":580,"type":42},{"text":333,"level":227},{"id":335,"data":582,"type":222},{"text":337},{"id":339,"data":584,"type":42},{"text":341,"level":227},{"id":343,"data":586,"type":240},{"items":587,"style":329},[346,347,348,349,350,351],{"id":353,"data":589,"type":42},{"text":355,"level":227},{"id":357,"data":591,"type":240},{"items":592,"style":239},[360,361,362,363,364],{"id":366,"data":594,"type":368},{},{"id":370,"data":596,"type":222},{"text":372},"Post erfolgreich abgerufen",{"items":599,"source":621,"manualIds":622,"manualMatchedIds":623},[600,607,614],{"id":601,"slug":602,"title":603,"excerpt":604,"featuredImage":605,"publishedAt":606},"2","multi-database-architecture","Prisma 7 多数据库架构：专家深度解析","复杂数据环境的管理需要现代化的架构。Prisma 7提供多数据库集成的高级功能，并应对多语言持久化带来的挑战。","\u002Fuploads\u002F2014\u002F09\u002FSEO-Mobile-Webapplikation-Muenchen-www.stajic.de_1.webp","2025-10-31T04:31:00.000Z",{"id":608,"slug":609,"title":610,"excerpt":611,"featuredImage":612,"publishedAt":613},"381","enterprise-grade-multi-tenant-architecture-for-an-international-platform","企业级多租户架构，适用于国际平台","Loving Rocks 是一款企业级婚礼平台，采用真正的多租户架构设计，实现租户间数据库隔离，并内置国际化支持，以确保全球可扩展性、安全性及长期运营稳定性。","\u002Fuploads\u002F2026\u002F01\u002Fenterprise-grade-multi-tenant-architecture-for-an-international-platform-1769789121298-b6v7ak.webp","2026-01-30T12:04:00.000Z",{"id":615,"slug":616,"title":617,"excerpt":618,"featuredImage":619,"publishedAt":620},"473","openai-agents-api-vs-agents-sdk-vs-responses-api-what-should-you-build-on-in-2026","OpenAI Agents API 与 Agents SDK 与 Responses API：2026 年你应该基于什么来构建？","OpenAI 的智能体技术栈在 2026 年 9 月发生了变化。本架构指南按运行时归属将 Agents API、Agents SDK、Responses API 和 Codex SDK 区分开来——以便团队能够选择正确的控制边界，而不是比较产品名称。","\u002Fuploads\u002F2026\u002F09\u002Fopenai-agents-api-vs-agents-sdk-vs-responses-api-what-should-you-build-on-in-2026-1790351846714-zi7lus.webp","2026-09-25T11:56:00.000Z","fallback",[],[]]