|
| 1 | +# Multi-Zone Architecture Overview |
| 2 | + |
| 3 | +This repository is a pnpm monorepo with three closely related Next.js apps: |
| 4 | + |
| 5 | +- `apps/site`: the main Prisma website and the public multi-zone entrypoint |
| 6 | +- `apps/blog`: the blog zone |
| 7 | +- `apps/docs`: the docs zone |
| 8 | + |
| 9 | +At a high level, users experience docs and blog content as `www.prisma.io/docs/*` and `www.prisma.io/blog/*`, but those paths are served by separate Next.js apps behind the scenes. |
| 10 | + |
| 11 | +## Two Layers |
| 12 | + |
| 13 | +There are two architectural layers to keep in mind: |
| 14 | + |
| 15 | +1. The monorepo layer decides how code is organized, shared, built, and run. |
| 16 | +2. The runtime layer decides which app answers a given request. |
| 17 | + |
| 18 | +The monorepo layer is defined by `pnpm-workspace.yaml`, the root `package.json`, and `turbo.json`. |
| 19 | + |
| 20 | +- `pnpm-workspace.yaml` includes `apps/*` and `packages/*`, so all apps and shared packages live in one workspace. |
| 21 | +- The root `package.json` delegates common workflows to Turbo with `pnpm build`, `pnpm dev`, `pnpm check`, and `pnpm types:check`. |
| 22 | +- `turbo.json` defines the task graph, shared cache inputs, and build outputs for `.next`, `dist`, and cache folders. |
| 23 | + |
| 24 | +This matches the useful Turborepo framing for this repo: package boundaries are managed at the workspace level, while runtime composition happens inside the app configs rather than through root scripts. |
| 25 | + |
| 26 | +## Shared Packages |
| 27 | + |
| 28 | +All three apps rely on shared workspace packages: |
| 29 | + |
| 30 | +- `packages/ui` (`@prisma-docs/ui`): shared navigation, footer, theming, and UI helpers |
| 31 | +- `packages/eclipse` (`@prisma/eclipse`): shared design system components and styles |
| 32 | + |
| 33 | +That means the apps are separate deployable zones, but they still present a mostly unified UI by consuming the same package-level building blocks. |
| 34 | + |
| 35 | +## App Roles |
| 36 | + |
| 37 | +| App | Local port | Runtime role | Key routing config | |
| 38 | +|------|------|------|------| |
| 39 | +| `apps/site` | `3000` | Main website host and multi-zone entrypoint | `assetPrefix: "/site-static"` and cross-zone `rewrites()` | |
| 40 | +| `apps/docs` | `3001` | Standalone docs zone | `basePath: "/docs"` and `assetPrefix: "/docs-static"` | |
| 41 | +| `apps/blog` | `3002` | Standalone blog zone | `basePath: "/blog"` and `assetPrefix: "/blog-static"` | |
| 42 | + |
| 43 | +## `apps/site` |
| 44 | + |
| 45 | +`apps/site` is the root host zone. Its own `README.md` describes it as the "Primary host app for the multi-zone Next.js setup." |
| 46 | + |
| 47 | +Important characteristics: |
| 48 | + |
| 49 | +- It runs on port `3000`. |
| 50 | +- It does not use a `basePath`. |
| 51 | +- It serves its own static assets from `/site-static`. |
| 52 | +- It owns the cross-zone rewrites that make docs and blog look like subpaths of the main site. |
| 53 | +- It also contains a large `redirects()` table for legacy Prisma routes and hostnames. |
| 54 | + |
| 55 | +The most important part of the multi-zone setup lives in `apps/site/next.config.mjs`: |
| 56 | + |
| 57 | +```758:790:apps/site/next.config.mjs |
| 58 | + // Proxy canonical docs path to docs infrastructure |
| 59 | + { |
| 60 | + source: "/docs", |
| 61 | + destination: `${DOCS_ORIGIN}/docs`, |
| 62 | + missing: [{ type: "host", value: DOCS_ORIGIN_HOST }], |
| 63 | + }, |
| 64 | + { |
| 65 | + source: "/docs/:any*", |
| 66 | + destination: `${DOCS_ORIGIN}/docs/:any*`, |
| 67 | + missing: [{ type: "host", value: DOCS_ORIGIN_HOST }], |
| 68 | + }, |
| 69 | + { |
| 70 | + source: "/docs-static/:path*", |
| 71 | + destination: `${DOCS_ORIGIN}/docs-static/:path*`, |
| 72 | + missing: [{ type: "host", value: DOCS_ORIGIN_HOST }], |
| 73 | + }, |
| 74 | + |
| 75 | + // Proxy canonical blog path to blog infrastructure |
| 76 | + { |
| 77 | + source: "/blog", |
| 78 | + destination: `${BLOG_ORIGIN}/blog`, |
| 79 | + missing: [{ type: "host", value: BLOG_ORIGIN_HOST }], |
| 80 | + }, |
| 81 | +``` |
| 82 | + |
| 83 | +Two details matter here: |
| 84 | + |
| 85 | +- `NEXT_DOCS_ORIGIN` and `NEXT_BLOG_ORIGIN` tell the site app where the docs and blog deployments live. |
| 86 | +- The `missing` host checks prevent the site from rewriting a request if it is already running on the docs or blog host, which avoids rewrite loops. |
| 87 | + |
| 88 | +In production, `apps/site` requires both origin variables. In development, it falls back to `https://docs.prisma.io` and `https://blog.prisma.io` if they are unset. |
| 89 | + |
| 90 | +## `apps/docs` |
| 91 | + |
| 92 | +`apps/docs` is a standalone Next.js app that still expects to live under `/docs`. |
| 93 | + |
| 94 | +Important characteristics: |
| 95 | + |
| 96 | +- It runs on port `3001`. |
| 97 | +- It sets `basePath: "/docs"`. |
| 98 | +- It sets `assetPrefix: "/docs-static"`. |
| 99 | +- Its root path redirects to `/docs`. |
| 100 | +- It wraps the Next config in `withSentryConfig(...)`. |
| 101 | +- Its build runs `fetch-openapi` before `next build`. |
| 102 | + |
| 103 | +Content and structure are Fumadocs-driven: |
| 104 | + |
| 105 | +- `apps/docs/source.config.ts` defines the main docs collection in `content/docs`. |
| 106 | +- It also defines a versioned `content/docs.v6` collection. |
| 107 | +- Docs frontmatter includes metadata fields like `url`, `metaTitle`, and `metaDescription`, which feed the docs app's routing and SEO layer. |
| 108 | + |
| 109 | +The docs deployment also has its own `apps/docs/vercel.json`, but that file is primarily a large redirect map for old docs URLs. It is not what makes the multi-zone architecture work. The multi-zone behavior comes from `apps/site` rewriting to the docs origin. |
| 110 | + |
| 111 | +## `apps/blog` |
| 112 | + |
| 113 | +`apps/blog` is also a standalone Next.js app, but mounted under `/blog`. |
| 114 | + |
| 115 | +Important characteristics: |
| 116 | + |
| 117 | +- It runs on port `3002`. |
| 118 | +- It sets `basePath: "/blog"`. |
| 119 | +- It sets `assetPrefix: "/blog-static"`. |
| 120 | +- Its root path redirects to `/blog`. |
| 121 | +- It has tag redirects that rewrite older tag URLs into query-param based routes. |
| 122 | + |
| 123 | +Blog content is also Fumadocs-based: |
| 124 | + |
| 125 | +- `apps/blog/source.config.ts` defines a collection at `content/blog`. |
| 126 | +- Blog frontmatter includes authors, date, hero image paths, tags, excerpts, and metadata fields. |
| 127 | + |
| 128 | +Like docs, blog is a separate app and separate deployment target. The difference is that end users usually reach it through the main site's `/blog/*` path model. |
| 129 | + |
| 130 | +## Request Flow |
| 131 | + |
| 132 | +This is the request path users usually experience in production: |
| 133 | + |
| 134 | +```mermaid |
| 135 | +flowchart LR |
| 136 | + user[UserBrowser] |
| 137 | + site[appsSite] |
| 138 | + blog[appsBlog] |
| 139 | + docs[appsDocs] |
| 140 | + ui[packagesUi] |
| 141 | + eclipse[packagesEclipse] |
| 142 | +
|
| 143 | + user -->|"www.prisma.io/*"| site |
| 144 | + site -->|"rewrite /blog/*"| blog |
| 145 | + site -->|"rewrite /docs/*"| docs |
| 146 | + site --> ui |
| 147 | + site --> eclipse |
| 148 | + blog --> ui |
| 149 | + blog --> eclipse |
| 150 | + docs --> ui |
| 151 | + docs --> eclipse |
| 152 | +``` |
| 153 | + |
| 154 | +In other words: |
| 155 | + |
| 156 | +1. A browser hits `www.prisma.io`. |
| 157 | +2. `apps/site` handles the request first. |
| 158 | +3. If the path starts with `/docs` or `/blog`, `apps/site` proxies that request to the corresponding origin. |
| 159 | +4. The target app still serves content with its own `basePath`, asset prefix, metadata rules, and local navigation. |
| 160 | + |
| 161 | +This follows a Next.js multi-zone pattern: each zone owns its own app boundary, while the top-level host composes them into one public URL space. |
| 162 | + |
| 163 | +## Why `basePath` and `assetPrefix` Matter |
| 164 | + |
| 165 | +The Next.js-side separation is deliberate: |
| 166 | + |
| 167 | +- `apps/docs` owns the `/docs` path space and `/docs-static` assets. |
| 168 | +- `apps/blog` owns the `/blog` path space and `/blog-static` assets. |
| 169 | +- `apps/site` stays at the root path space and uses `/site-static`. |
| 170 | + |
| 171 | +That separation prevents static asset collisions and keeps each app self-contained enough to run on its own origin or locally on its own port. |
| 172 | + |
| 173 | +This is also the main Next.js best-practice insight that explains the repo: each zone has a clear app boundary, and shared behavior is coordinated through config rather than by blurring the app edges. |
| 174 | + |
| 175 | +## Cross-Zone Linking Nuances |
| 176 | + |
| 177 | +The linking model is intentionally mixed, and that is one of the easiest parts of the system to misunderstand. |
| 178 | + |
| 179 | +### Main site navigation |
| 180 | + |
| 181 | +In `apps/site/src/app/layout.tsx`, the top-level `Docs` and `Blog` nav items point to absolute `https://www.prisma.io/docs` and `https://www.prisma.io/blog` URLs, while many other site links are root-relative like `/pricing` or `/orm`. |
| 182 | + |
| 183 | +That biases navigation toward the canonical public host, even though docs and blog are separate deployments behind the scenes. |
| 184 | + |
| 185 | +### Blog navigation |
| 186 | + |
| 187 | +In `apps/blog/src/app/(blog)/layout.tsx`, the nav mixes absolute and relative links: |
| 188 | + |
| 189 | +- `Docs` is linked as `/docs` |
| 190 | +- `Blog` is linked as `https://www.prisma.io/blog` |
| 191 | +- Most product pages point to absolute `https://www.prisma.io/...` URLs |
| 192 | + |
| 193 | +This works well when the user is on `www.prisma.io`, because `/docs` is handled by the site rewrite layer. It is less universal when the blog app is visited directly on its own origin, because `/docs` then depends on how that origin is configured at the edge. |
| 194 | + |
| 195 | +### Docs navigation |
| 196 | + |
| 197 | +In `apps/docs/src/lib/layout.shared.tsx`, docs mostly links internally within the docs zone, but the Prisma logo links back to `https://www.prisma.io`. |
| 198 | + |
| 199 | +That makes docs feel like part of the main site, even though it is operationally a separate app. |
| 200 | + |
| 201 | +### Shared footer behavior |
| 202 | + |
| 203 | +The shared footer data in `packages/ui/src/data/footer.ts` uses relative `/docs` and `/blog` paths. |
| 204 | + |
| 205 | +`packages/ui/src/lib/is-absolute-url.ts` exposes `getRedirectableLink()`, which can convert relative links to `https://www.prisma.io/...` when `absoluteLinks` is enabled. By default, the footer can stay relative, which is fine on the main host but can behave differently off the canonical `www` origin. |
| 206 | + |
| 207 | +## URL Generation and Metadata Nuances |
| 208 | + |
| 209 | +Each app has its own base-URL helper: |
| 210 | + |
| 211 | +- `apps/site/src/lib/url.ts` |
| 212 | +- `apps/docs/src/lib/urls.ts` |
| 213 | +- `apps/blog/src/lib/url.ts` |
| 214 | + |
| 215 | +Those helpers are used for things like `metadataBase`, canonical URLs, OpenGraph data, and local defaults. |
| 216 | + |
| 217 | +The behavior differs slightly by app: |
| 218 | + |
| 219 | +- `apps/site` normalizes `NEXT_PUBLIC_PRISMA_URL`, falls back to `https://www.prisma.io` in production, and otherwise uses `VERCEL_URL` or `http://localhost:3000`. |
| 220 | +- `apps/docs` and `apps/blog` fall back to their own local ports (`3001` and `3002`) if no environment variable is present. |
| 221 | + |
| 222 | +That means each zone can reason about its own canonical base URL, but it also means environment consistency matters if you want metadata to align cleanly across deployments. |
| 223 | + |
| 224 | +### When to use `withDocsBasePath()` and `withBlogBasePath()` |
| 225 | + |
| 226 | +The `withDocsBasePath()` and `withBlogBasePath()` helpers are mainly for URL strings that will not be processed by Next.js routing for you. |
| 227 | + |
| 228 | +Use them when you are building a path manually for: |
| 229 | + |
| 230 | +- metadata fields such as `alternates.canonical`, `openGraph.url`, and `openGraph.images` |
| 231 | +- sitemap, robots, RSS, JSON-LD, and other SEO-oriented URL generation |
| 232 | +- API endpoint strings like `/api/search` |
| 233 | +- raw HTML elements such as `<a href="/...">` or `<img src="/...">` |
| 234 | +- any other plain string URL that must include `/docs` or `/blog` explicitly |
| 235 | + |
| 236 | +Do not use them when you are already using framework-aware components that respect `basePath`, especially: |
| 237 | + |
| 238 | +- `next/link` |
| 239 | +- `next/image` |
| 240 | + |
| 241 | +In those cases, manually prefixing the path usually duplicates work and can create incorrect URLs. |
| 242 | + |
| 243 | +### Fumadocs and MDX behavior |
| 244 | + |
| 245 | +Fumadocs-backed pages generally already live inside a base-path-aware environment, so helpers are usually not needed for normal framework-managed navigation. |
| 246 | + |
| 247 | +In this repo, that means: |
| 248 | + |
| 249 | +- blog MDX exposes `Link` and `Image` from Next.js in `apps/blog/src/mdx-components.tsx` |
| 250 | +- docs MDX uses Fumadocs defaults from `fumadocs-ui/mdx` in `apps/docs/src/mdx-components.tsx` |
| 251 | + |
| 252 | +The important exception is raw `img` handling. Both apps override MDX `img` rendering and explicitly prefix raw image sources before passing them to `ImageZoom`, because plain image source strings are not automatically rewritten the way `next/image` and `next/link` inputs are: |
| 253 | + |
| 254 | +- `apps/docs/src/mdx-components.tsx` uses `withDocsBasePathForImageSrc(...)` |
| 255 | +- `apps/blog/src/mdx-components.tsx` uses `withBlogBasePathForImageSrc(...)` |
| 256 | + |
| 257 | +So the practical rule is: |
| 258 | + |
| 259 | +- if you are using Next or Fumadocs navigation/image components, you usually do not need the helper |
| 260 | +- if you are emitting a raw path string or raw HTML tag, you usually do need the helper |
| 261 | + |
| 262 | +### OG image and metadata patterns |
| 263 | + |
| 264 | +Open Graph and other metadata fields are a common place where the helper is still required, even in Next apps. |
| 265 | + |
| 266 | +That is because metadata APIs are string-based configuration, not rendered `next/link` or `next/image` components. |
| 267 | + |
| 268 | +The repo already uses the helpers this way: |
| 269 | + |
| 270 | +- docs metadata prefixes canonical URLs and OG image paths with `withDocsBasePath(...)` in `apps/docs/src/app/(docs)/(default)/[[...slug]]/page.tsx` |
| 271 | +- blog metadata prefixes canonical URLs with `withBlogBasePath(...)` and image paths with `withBlogBasePathForImageSrc(...)` in `apps/blog/src/app/(blog)/[slug]/page.tsx` |
| 272 | +- blog home metadata also prefixes its OG image path in `apps/blog/src/app/(blog)/page.tsx` |
| 273 | + |
| 274 | +One extra nuance for blog is that some metadata image paths are converted all the way to absolute URLs for JSON-LD and article metadata by combining: |
| 275 | + |
| 276 | +- `withBlogBasePathForImageSrc(...)` to add `/blog` |
| 277 | +- `toAbsoluteUrl(...)` to anchor the result to the app's base URL |
| 278 | + |
| 279 | +That pattern is important whenever a consumer expects a fully qualified URL instead of a root-relative path. |
| 280 | + |
| 281 | +## Search Nuance |
| 282 | + |
| 283 | +The site-level search endpoint in `apps/site/src/app/api/search/route.ts` normalizes blog and docs results back to `https://www.prisma.io`, not to the zone origins. |
| 284 | + |
| 285 | +That is important because it confirms the public contract of the system: |
| 286 | + |
| 287 | +- docs search results are surfaced as `https://www.prisma.io/docs/...` |
| 288 | +- blog search results are surfaced as `https://www.prisma.io/blog/...` |
| 289 | + |
| 290 | +So even when content is physically served by different apps, search treats the main site host as the canonical public URL namespace. |
| 291 | + |
| 292 | +## Local Development Nuance |
| 293 | + |
| 294 | +Root `pnpm dev` starts all relevant apps together: |
| 295 | + |
| 296 | +- site on `http://localhost:3000` |
| 297 | +- docs on `http://localhost:3001` |
| 298 | +- blog on `http://localhost:3002` |
| 299 | + |
| 300 | +The key caveat is that `apps/site` defaults `NEXT_DOCS_ORIGIN` and `NEXT_BLOG_ORIGIN` to the production subdomains if they are not explicitly set. |
| 301 | + |
| 302 | +So a developer visiting `http://localhost:3000/docs` or `http://localhost:3000/blog` may accidentally proxy to production content unless those origin variables are pointed at local services. |
| 303 | + |
| 304 | +That is the most important operational caveat in the whole setup. |
| 305 | + |
| 306 | +## Deployment Notes |
| 307 | + |
| 308 | +Deployment behavior is split across a few layers: |
| 309 | + |
| 310 | +- Root `vercel.json` defines the install command for the monorepo. |
| 311 | +- `apps/site/vercel.json` adds site-level redirects. |
| 312 | +- `apps/docs/vercel.json` adds docs-specific redirects for legacy content. |
| 313 | + |
| 314 | +But the architectural composition of the zones is still defined primarily in `apps/site/next.config.mjs`, not in `vercel.json`. |
| 315 | + |
| 316 | +That distinction is important: |
| 317 | + |
| 318 | +- `vercel.json` mostly handles deployment- and edge-level redirects. |
| 319 | +- `next.config.mjs` defines how the apps compose into one public multi-zone site. |
| 320 | + |
| 321 | +## Mental Model For Maintainers |
| 322 | + |
| 323 | +When working in this repository, the safest mental model is: |
| 324 | + |
| 325 | +- treat `apps/site`, `apps/blog`, and `apps/docs` as separate Next.js applications |
| 326 | +- treat `packages/ui` and `packages/eclipse` as the shared presentation layer |
| 327 | +- treat `apps/site` as the public traffic router for docs and blog |
| 328 | +- treat `/docs` and `/blog` as canonical public paths, even when their content is served by separate origins |
| 329 | + |
| 330 | +If a change affects navigation, canonical URLs, asset paths, or local dev routing, verify it in both contexts: |
| 331 | + |
| 332 | +- when the user is on `www.prisma.io` through `apps/site` |
| 333 | +- when the zone is accessed directly on its own app origin |
0 commit comments