cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Multi-zones

Serve several Next.js apps on one domain: rewrites in the main app, assetPrefix in each zone, plain links between zones, shared cookies, and when to split.

By the end, you can
  • 01
    Route one domain to two appsSend a zone's paths and its files to it with rewrites in the main app and assetPrefix in the zone.
  • 02
    Link and share across zonesLink between zones with a plain <a>, and share a session cookie and Server Actions on the same domain.
  • 03
    Decide when to splitWeigh team boundaries and separate deploys against full page loads, repeated JavaScript and shared state.

One address, two Next.js apps

The tea shop has a blog now. A content team writes it and wants to publish on its own schedule, without waiting for the shop's team to build and deploy. But customers should find it at the shop's own address, under /blog, with the same look. One answer is to make the blog a Next.js app of its own, and put it behind the shop's address.

Next.js calls this multi-zones. A zone is a normal Next.js app that serves some of a domain's paths. Here the blog zone serves /blog and everything under it, and the shop, the main zone, serves the rest. Each zone is built and deployed on its own, and the visitor sees one site. Both apps live in the repository from Monorepos, beside a shared package, @tea/session, used further down.

The example below is that repository, recorded with Next.js 16.4.3. The recorder ran npm install, built the blog and started it on its own port, then built and started the shop, and gave it the blog's address in BLOG_URL, as a deployment sets an environment variable. Every request went to the shop.

The shop and the blog on one addressNext.js
apps/blog/app/blog/[slug]/shop/app/packages/session/src/
apps/shop/next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  async rewrites() {    return [      { source: "/blog", destination: `${process.env.BLOG_URL}/blog` },      { source: "/blog/:path+", destination: `${process.env.BLOG_URL}/blog/:path+` },      { source: "/blog-static/:path+", destination: `${process.env.BLOG_URL}/blog-static/:path+` },    ];  },}; export default nextConfig;
Recorded from Next.js 16.4.3

GET /blog200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1, 1
x-nextjs-stale-time 300
x-powered-by Next.js
cache-control s-maxage=31536000
content-type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 16 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/blog-static/_next/static/chunks/1x6obpmlu1_c2.js">
    <script src="/blog-static/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
    <script src="/blog-static/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/blog-static/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
    <script src="/blog-static/_next/static/chunks/turbopack-3_hx5-791cmba.js" async=""></script>
    <script src="/blog-static/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <script src="/blog-static/_next/static/chunks/21poqdgvjdll4.js" async=""></script>
    <title>Tea blog</title>
    <script src="/blog-static/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <nav><a href="/">Teas</a> <a href="/blog">Blog</a></nav>
    <!--&-->
    <!--&-->
    <main>
      <h1>Tea blog</h1>
      <ul>
        <li><a href="/blog/brewing-mint">How to brew mint tea</a></li>
        <li><a href="/blog/green-tea-water">Why green tea needs cooler water</a></li>
      </ul>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/blog-static/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

The Network tab opens on /blog, asked of the shop. It answered 200 with the blog's page: Tea blog and its two posts. Now open the Terminal tab and find the shop's build, the last one: its routes are ◐ / and ○ /_not-found. The shop has no /blog page at all. The blog's build, above it, lists ○ /blog and the posts. Lines 8 to 10 of apps/shop/next.config.ts did it: they are rewrites, which answer a path with another address's answer while the visitor's address stays the same (Next.js as a backend for your frontend used one for a service).

Three windows. Left, the browser asks one address for /, /blog/… and /blog-static/…. An arrow goes to the shop, on port 3000, which answers / itself and passes /blog/… and /blog-static/… on, shaded, with an arrow to the blog, on port 3001, which makes the blog's pages and serves its files.The browserbrowserGET /GET /blog/…GET /blog-static/…The shop, port 3000shop/: its page/blog/…/blog-static/…The blog, port 3001blogits pagesits filesrewrites pass these onits own build and deploy
The browser only ever talks to the shop's address. The shop answers its own paths, and its rewrites pass the blog's paths (shaded) to the blog app, which runs on its own port and has its own build.
Real-life analogyOne market hall, many stalls

A market hall has one address, and many stalls inside. Each stall has its own owner, who restocks and changes the prices without asking the others. Signs at the entrance say which stall sells what. Walking from the tea stall to the bakery means leaving one counter and starting again at the next.

In real life: The market hall's one street address
In Next.js: The domain visitors type
In real life: A stall run by its own owner, on its own hours
In Next.js: A zone: a Next.js app with its own build and deploy
In real life: The signs at the entrance that send you to the right stall
In Next.js: The main zone's rewrites
In real life: Walking from one stall to another, and putting your bag down at each
In Next.js: A full page load when you cross into another zone
The main idea

Multi-zones split one domain between several Next.js apps. In this setup, the main zone answers every request and sends each other zone's paths, and its files, to that zone with rewrites; a reverse proxy in front of all the zones can do that routing instead. Each zone is built and deployed on its own. Moving between zones is a full page load, so pages that people visit together belong in the same zone.

Routing between zones: rewrites and assetPrefix

A Next.js page is more than its HTML. Its JavaScript and CSS files, the ones next build writes into .next/static, come from /_next/static/… on the same address. Both apps write files there, and the browser asks the shop for all of them. So each zone besides the main one moves its files to a path of its own:

apps/blog/next.config.tsTypeScript
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  assetPrefix: "/blog-static",  experimental: {    serverActions: {      // The domain visitors use, as the multi-zones guide asks each zone to list.      allowedOrigins: ["tea-shop.example"],    },  },}; export default nextConfig; 

Line 6 sets assetPrefix to /blog-static. The blog's pages now ask for /blog-static/_next/static/…, and the blog serves its files there; in Next.js 15 and later it needs no extra rewrite for that. The main zone doesn't need an assetPrefix. Lines 7 to 12 are for Server Actions, and come back further down. The shop's three rules then send the blog its paths:

  1. Line 8: /blog itself goes to BLOG_URL + /blog.
  2. Line 9: /blog/:path+, one or more parts after /blog/, like /blog/brewing-mint, goes to the same path on the blog.
  3. Line 10: /blog-static/:path+, the blog's files, goes to the blog too.

Pick /blog/brewing-mint, its first script in the overview's Network tab: the recorder asked the shop for the first script that page loads, /blog-static/_next/static/chunks/…, and the shop passed it to the blog, which answered 200, with Cache-Control: public, max-age=31536000, immutable. A rewrite's destination is read when next build runs, so the shop is built again when BLOG_URL changes. The docs recommend rewrites, which add the least delay; when the routing has to be decided per request, like during a migration behind a feature flag, they suggest proxy.ts instead. The main zone doesn't have to route at all: any reverse proxy in front of the zones, like a load balancer or a CDN, can send each path to its zone, with the same paths and files.

Here is the blog's post page and its Like button, a Client Component. Until React has hydrated it in the browser, it says Loading likes… and can't be clicked:

apps/blog/app/blog/[slug]/like-button.tsxTSX
"use client"; import { useEffect, useState } from "react"; export default function LikeButton() {  const [ready, setReady] = useState(false);  const [likes, setLikes] = useState(0);  // Effects run only in the browser, once React has hydrated this button.  useEffect(() => setReady(true), []);  return (    <button disabled={!ready} onClick={() => setLikes(likes + 1)}>      {ready ? `Likes: ${likes}` : "Loading likes…"}    </button>  );} 

Line 9 is an Effect, and Effects only run in the browser, after hydration: it turns ready on. Line 11 keeps the button disabled until then, and line 12 shows Loading likes…. So the button tells you whether the blog's JavaScript arrived and ran. Real Chrome opened the post on the shop's address, in three versions:

Playground: where do the blog's files come from?
The tea shop's repository, with this version of the zones' configsNext.js
apps/blog/app/blog/[slug]/shop/app/packages/session/src/
apps/blog/next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  assetPrefix: "/blog-static",  experimental: {    serverActions: {      // The domain visitors use, as the multi-zones guide asks each zone to list.      allowedOrigins: ["tea-shop.example"],    },  },}; export default nextConfig;
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/blog/brewing-mintTea blog
Teas Blog
How to brew mint tea

Use water just off the boil, and steep it for five minutes.

Likes: 0

Not signed in.

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (8), files and other sites included

  1. GET/blog/brewing-mint200a whole page
  2. GET/blog-static/_next/static/chunks/1x6obpmlu1_c2.js200application/javascript
  3. GET/blog-static/_next/static/chunks/06bs-nf19b2js.js200application/javascript
  4. GET/blog-static/_next/static/chunks/2ki52vdnbo2ft.js200application/javascript
  5. GET/blog-static/_next/static/chunks/3l_qmp-61z4yc.js200application/javascript
  6. GET/blog-static/_next/static/chunks/turbopack-3_hx5-791cmba.js200application/javascript
  7. GET/blog-static/_next/static/chunks/1tq6ppe__m8m-.js200application/javascript
  8. GET/blog-static/_next/static/chunks/2xqtv5yei7lzi.js200application/javascript
Try it yourself
assetPrefix, and a rewrite for it

Line 6 puts the blog's files under /blog-static, and the shop's third rule passes that path on. Every file the post asked for answered 200 through the shop. The button changed from Loading likes… to Likes: 0, and a click made it Likes: 1.

Every version was installed with npm install, the blog built and started first, then the shop, with Next.js 16.4.3. Chrome opened /blog/brewing-mint on the shop's address and listed every file the page asked for. Reset goes back to the first version.

With both settings, every file answered 200, and the button worked. Without assetPrefix, the shop answered the blog's requests from its own build: the files that hold the same Next.js and React code have the same names in both builds, so they came back 200, but the file with the post's own code answered 404. Without the third rule, every file answered 404. Either way, the HTML arrived and nothing on the page ever ran.

Two rows. With assetPrefix: the blog's page asks for /blog-static/_next/…, the shop's rewrite passes it to the blog, and the blog answers 200. Without assetPrefix: the page asks for /_next/static/…, the shop looks in its own files, and for the blog's own file answers 404.With assetPrefix/blog-static/_next/…shop: rewriteblog: 200the button worksWithout assetPrefix/_next/static/…shop: own files404Loading likes…
With assetPrefix, every file the blog's page asks for starts with /blog-static, a path the shop passes on. Without it, the page asks for /_next/ paths, the shop answers them from its own build, and the blog's own code isn't there.

Linking across zones: a plain <a>

Inside one app, Link makes a soft navigation: the router fetches the next page's RSC payload, the description of what its Server Components rendered, and swaps in only what changed, without loading the page again (Linking and navigating). Another zone's page is another app's, built separately. The shop's nav uses a plain <a> for it, on line 10:

Clicking through both zones in real ChromeNext.js
apps/blog/app/blog/[slug]/shop/app/packages/session/src/
apps/shop/app/layout.tsx
import Link from "next/link"; export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body>        <nav>          <Link href="/">Teas</Link> <a href="/blog">Blog</a>        </nav>        {children}      </body>    </html>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/blogTea blog
Teas Blog
Tea blog
  • How to brew mint tea
  • Why green tea needs cooler water
Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (12), files and other sites included

  1. GET/blog200a whole page
  2. GET/blog-static/_next/static/chunks/1x6obpmlu1_c2.js200application/javascript
  3. GET/blog-static/_next/static/chunks/06bs-nf19b2js.js200application/javascript
  4. GET/blog-static/_next/static/chunks/2ki52vdnbo2ft.js200application/javascript
  5. GET/blog-static/_next/static/chunks/3l_qmp-61z4yc.js200application/javascript
  6. GET/blog-static/_next/static/chunks/turbopack-3_hx5-791cmba.js200application/javascript
  7. GET/blog-static/_next/static/chunks/1tq6ppe__m8m-.js200application/javascript
  8. GET/blog-static/_next/static/chunks/21poqdgvjdll4.js200application/javascript
  9. GET/blog/brewing-mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /blog
  10. GET/blog/green-tea-water200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /blog
  11. GET/blog/brewing-mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /blog/$d$slug/__PAGE__
    next-url
    /blog
  12. GET/blog-static/_next/static/chunks/2xqtv5yei7lzi.js200application/javascript

The Steps tab opens on the click on Blog: one document request, the blog's whole page, then each of its scripts from /blog-static/_next/. The next steps stay in the blog. Its home page links to each post with Link, so the blog's router prefetched them, and clicking How to brew mint tea made one fetch for the post's RSC payload and no document: a soft navigation inside the zone. The last step clicks the blog's Teas, a plain <a href="/">: a document again, and the shop's scripts again. Now the same nav with a Link to the blog:

The shop's nav with a Link to the blogNext.js
apps/blog/app/blog/[slug]/shop/app/packages/session/src/
apps/shop/app/layout.tsx
import Link from "next/link"; export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body>        <nav>          <Link href="/">Teas</Link> <Link href="/blog">Blog</Link>        </nav>        {children}      </body>    </html>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/blogTea blog
Teas Blog
Tea blog
  • How to brew mint tea
  • Why green tea needs cooler water
Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (5), without the files it loaded

  1. GET/blog200text/x-component
    rsc
    1
    next-router-state-tree
    %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2Cnull%2C82%5D
    next-url
    /
  2. GET/blog200a whole page
  3. GET/blog/brewing-mint200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /blog
  4. GET/blog/green-tea-water200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /blog
  5. GET/blog/green-tea-water200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /blog/$d$slug/__PAGE__
    next-url
    /blog

On the first step, the Link prefetched /blog: the shop passed the router's request to the blog, and the blog answered with its RSC payload. On the click, the router asked for /blog's RSC payload again, then made a document request for /blog: a full page load after all. The payload came from another build, and in Next.js 16.4.3 the router loads the page in full when the build in a response doesn't match its own (lines 259 to 265 of fetch-server-response.ts). Each row compares the two links to the blog, as recorded:

A plain <a> and a Link to another zone
WhenWith <a href="/blog">With <Link href="/blog">
Before the clickNothing: a plain link loads nothing ahead of time.A prefetch of /blog's RSC payload, which the blog answered through the shop's rewrite.
On the clickOne request: the blog's HTML page, a full page load.A request for /blog's RSC payload, then the blog's HTML page: a full page load anyway.
After the clickThe blog, with its own JavaScript.The same blog, after two more requests.

The Link worked, but made extra requests, and only because the router fell back to a full page load. The docs ask for a plain <a> between zones, and so does the blog's own layout:

apps/blog/app/layout.tsxTSX
export const metadata = { title: "Tea blog" }; export default function BlogLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body>        <nav>          <a href="/">Teas</a> <a href="/blog">Blog</a>        </nav>        {children}      </body>    </html>  );} 

Line 8 links to the shop and to the blog's home with plain <a> tags. The blog's home is in its own zone, so a Link would work there too; the blog's home page uses Link for its posts, on line 11:

apps/blog/app/blog/page.tsxTSX
import Link from "next/link";import { posts } from "./posts"; export default function BlogHome() {  return (    <main>      <h1>Tea blog</h1>      <ul>        {posts.map((post) => (          <li key={post.slug}>            <Link href={`/blog/${post.slug}`}>{post.title}</Link>          </li>        ))}      </ul>    </main>  );} 
Three rows of requests. From the shop to the blog with a plain a: one request, the page /blog, a full page load. From the shop to the blog with a Link: an RSC request for /blog, then the page /blog, a full page load. From the blog to a post with a Link: one RSC request, a soft navigation.Shop to blog, <a>page: /blogfull page loadShop to blog, LinkRSC: /blogpage: /blogfull page loadBlog to a post, LinkRSC: the postsoft navigation
Each card is one request the click made, in the recordings. Crossing into another zone loads the whole page, with a Link one request later. Inside one zone, a Link makes a soft navigation.

One domain, one set of cookies

The browser keeps each cookie for a host and a path, not for an app. Both zones answer on one host, the shop's, so a cookie set for that host with Path=/ goes with every request to either zone, and the shop's rewrite passes it on. Here is how a sign-in service would set the session cookie; it isn't part of the recorded repository:

Setting the session cookie, in the sign-in service (not recorded)TypeScript
// In the sign-in service, once it has checked who is signing in. Not recorded.(await cookies()).set("session", `${name}.${signature}`, {  path: "/",  httpOnly: true,  sameSite: "lax",  secure: process.env.NODE_ENV === "production",  maxAge: 60 * 60 * 8,});
  • There's no domain option, so it's a host-only cookie: only this exact host gets it, not other subdomains like jobs.tea-shop.example.
  • Line 3, Path=/: every path on the host, /blog too. With Path=/blog, only the blog would get it.
  • Line 4, HttpOnly: the page's own JavaScript can't read it. That doesn't stop a forged request, and it doesn't hide the cookie from any zone's server, which gets it with every request.
  • Line 5, SameSite=Lax: the browser leaves it off requests that other sites' pages send, like a form posted from another site, and sends it when the visitor follows a link to the shop.
  • Line 6, Secure in production: only sent over HTTPS. Line 7: it expires after eight hours.
Every zone can use the session, so decide which ones can make one

Every zone gets every cookie, so a zone that's broken into can use a visitor's session until it expires. If the zones checked sessions with one shared secret, like an HMAC key, every zone could also make one: a broken blog could sign in as any customer of the shop, and all the zones would be one security boundary. Two safer designs: give the cookie an id with no meaning of its own, which each zone checks with one sign-in service, or sign it with a key pair, where only the sign-in service has the private key that signs and the zones get the public key, which can only check. The example does the second.

packages/session/src/index.tsTypeScript
import { createPublicKey, verify } from "node:crypto";import { cache } from "react";import { cookies } from "next/headers"; // Zones get only the sign-in service's public key: it checks a signature but can't make one.function publicKey() {  const x = process.env.SESSION_PUBLIC_KEY ?? "";  if (!/^[\w-]{43}$/.test(x)) throw new Error("SESSION_PUBLIC_KEY must be an Ed25519 public key in base64url");  return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" });} // The cookie is "name.signature": the sign-in service's Ed25519 signature of the name.export const readSession = cache(async () => {  const [name = "", signature = ""] = ((await cookies()).get("session")?.value ?? "").split(".");  if (!name || !/^[\w-]{86}$/.test(signature)) return null;  return verify(null, Buffer.from(name), publicKey(), Buffer.from(signature, "base64url")) ? { name } : null;}); 

The cookie is Asha's name, a dot, and a signature: an Ed25519 signature of the name, which only the private key can make. Line 7 reads the public key from SESSION_PUBLIC_KEY, line 8 refuses one that isn't 43 base64url characters (32 bytes), and line 9 turns it into a key object. Line 14 splits the cookie, line 15 checks that the signature is 86 characters (64 bytes), and line 16 checks it with the public key. cache on line 13 runs it once per render pass. A real session also carries an expiry and an id that the sign-in service can revoke (Authentication). Each zone gets the public key; for the recording, from a file:

apps/shop/.env and apps/blog/.env (recording only)Shell
# Recording only: a key pair made for this example. Public, so it can be committed:
# it checks signatures and can't make them. The private key stays in the sign-in service.
SESSION_PUBLIC_KEY=YvLjkzkP-c0RGUQ7hvYV8jowOjJ-hmZ0Eb7FfcSQ_X0

That key pair is a recording-only fixture, made for this example, and Asha's cookie, signed with it once, never expires. A public key is no secret: a zone that leaks it leaks nothing that lets anyone sign. The blog's post page reads the session in a part made for each request:

apps/blog/app/blog/[slug]/page.tsxTSX
import { Suspense } from "react";import { cookies } from "next/headers";import { notFound } from "next/navigation";import { readSession } from "@tea/session";import { posts } from "../posts";import { savePost } from "./actions";import LikeButton from "./like-button"; export function generateStaticParams() {  return posts.map((post) => ({ slug: post.slug }));} async function Reader({ slug }: { slug: string }) {  const session = await readSession();  if (!session) return <p>Not signed in.</p>;  const saved = (await cookies()).get("saved")?.value === slug;  return (    <form action={savePost.bind(null, slug)}>      <p>Signed in as {session.name}.</p>      {saved ? <p>Saved for later.</p> : <button>Save for later</button>}    </form>  );} export default async function Post({ params }: PageProps<"/blog/[slug]">) {  const { slug } = await params;  const post = posts.find((item) => item.slug === slug);  if (!post) notFound();  return (    <main>      <h1>{post.title}</h1>      <p>{post.body}</p>      <LikeButton />      <Suspense fallback={<p>Checking your session…</p>}>        <Reader slug={slug} />      </Suspense>    </main>  );} 

Line 14 reads the session with the shared code. Line 15 answers Not signed in. without one. Line 16 reads a second cookie, saved. Line 18 posts the form to a Server Action, savePost, with the post's slug bound to it, and line 20 shows Saved for later. or the button. The action checks the session again, because a Server Action is a public endpoint that anyone can call (Securing Server Actions):

apps/blog/app/blog/[slug]/actions.tsTypeScript
"use server"; import { cookies } from "next/headers";import { redirect } from "next/navigation";import { readSession } from "@tea/session";import { posts } from "../posts"; export async function savePost(slug: string) {  // An action is a public endpoint: check who is asking, and what they ask for.  if (!(await readSession())) return;  if (!posts.some((post) => post.slug === slug)) return;  // A stand-in for saving it in a database: one cookie, for this recording.  (await cookies()).set("saved", slug, { path: "/", httpOnly: true, sameSite: "lax" });  redirect(`/blog/${slug}`);} 

Line 10 stops without a valid session, and line 11 for a slug that isn't a post. Line 13 saves the post in a cookie: a stand-in for a database, for the recording. Line 14 sends the visitor back to the post. The recorder sent these requests to the shop's address, without a cookie and then as Asha, with her signed cookie (the sign-in service isn't part of the example). The overview's Network tab holds each one:

Asha's requests to the shop's address, as recorded
RequestCookies sentWhat came back
GET /None.Welcome, guest.
GET / as Ashasession.Welcome back, Asha.
GET /blog/brewing-mintNone.Not signed in.
GET /blog/brewing-mint as Ashasession.Signed in as Asha. and a Save for later button.
POST /blog/brewing-mint: Save for latersession.303 to /blog/brewing-mint, with Set-Cookie: saved=brewing-mint.
GET /blog/brewing-mint after savingsession and saved.Signed in as Asha. and Saved for later.

The shop and the blog both checked the same session. The form was posted without JavaScript, so the answer was a 303 redirect, with the new cookie. It came from the blog, but the browser keeps it for the shop's host, with Path=/, and sends it with every later request there, to both zones: the next request showed Saved for later. Both zones share every cookie name, so agree on them, as you agree on paths.

Server Actions behind another zone

Before it runs an action, Next.js 16.4.3 compares the host in the form's Origin header, the address of the page that sent it, with the request's x-forwarded-host header, or with host when there's none. A different host is refused, unless it's in serverActions.allowedOrigins. A request with no Origin at all is let through, with a warning in the server's log (lines 729 to 745 of action-handler.ts). The multi-zones guide asks each zone to list the domain visitors use, and lines 7 to 12 of the blog's next.config.ts do:

apps/blog/next.config.ts: the visitors' domain for Server ActionsTypeScript
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  assetPrefix: "/blog-static",  experimental: {    serverActions: {      // The domain visitors use, as the multi-zones guide asks each zone to list.      allowedOrigins: ["tea-shop.example"],    },  },}; export default nextConfig; 

In the recording, the visitors' host was 127.0.0.1, which that list doesn't name, and the action still ran. The shop's rewrite had set x-forwarded-host to the host the visitor used (lines 30 to 41 of proxy-request.ts), the same host as the Origin. That's what this setup did when it was recorded, not something to rely on. x-forwarded-host is only worth trusting when the server at your edge checks the Host header and writes the forwarded headers itself, replacing any a visitor sent; when a proxy between the visitor and a zone puts the zone's own address there, only allowedOrigins lets the action through. And the origin check only stops other sites' pages: the action still checks the session itself, as line 10 of actions.ts does.

When to split an app, and when not to

A zone is a second app to build, deploy, watch and upgrade. It pays off when the split follows a real boundary: a team that ships on its own schedule, pages that have little to do with the rest, or a part that uses another framework, which a zone may. It costs something on every crossing: the recordings show a full page load, and the next zone's scripts downloaded and run. Each row is one thing a split changes:

Two zones, or one app
What changesTwo zonesOne app
Who can deploy itIts own team, on its own schedule, with its own build.Every team waits for one build and one deploy.
Moving between areasA full page load each time you cross into another zone.A soft navigation: only what changed is fetched.
Shared layout and stateEach zone has its own root layout; state in React is lost on crossing.One layout, and state that survives a navigation.
Framework JavaScriptEach zone sends its own copy of React and Next.js's router.Sent once, then reused by every page.
Build size and timeEach app builds only its own pages.Every page is in one build.

Keep pages that people move between often in one zone: the teas, their reviews, the cart and checkout. React state, like a half-typed search, doesn't survive a crossing. The zones also have to agree on what they share: which paths each one owns, where its files live, the cookie names and how sessions are checked, and the shared UI that makes them look like one site. They don't need the same Next.js version, or even the same framework. If the reason to split is a slow build, or a big folder, a monorepo with shared packages and Turborepo's cache solves that inside one app first.

Planning the tea shop's zones

The shop is growing. Sort each part: does it stay in the shop's zone, or get a zone of its own?

Same zone, or a zone of its own?
  • The cart and checkout, which customers reach from every tea page.
  • The tea blog, written and deployed by a content team on its own schedule.
  • Search, with filters that change as you type, on the same pages as the teas.
  • Brewing guides at /docs, built by the support team with a different framework.
  • A tea's page and its reviews, which customers open one after the other.
  • A careers site at /jobs, run by another department, that nobody visits while shopping.
Try it yourself
0 of 6 correct

Sort each part of the tea shop's site by where it should live.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Zones are routes inside one app." No. Each zone is a whole Next.js app, with its own build. The shop's build has no /blog route at all.
  • "The rewrites for the pages are enough." No. The zone's files need a path of their own (assetPrefix) and a rule in the main zone; without either, the recorded blog page never ran.
  • "Link makes moving between zones fast." No. It prefetched, asked again, and then loaded the whole page anyway. Use a plain <a>.
  • "Each zone needs its own sign-in." No. One host means one set of cookies, and each zone can check the same session. Give the zones a key that can check sessions but not make them, so a broken zone can't forge one.
  • "Splitting into zones makes the site faster." Not for visitors who cross between them: each crossing is a full page load. Zones make teams independent; they don't make navigation faster.
  • "A zone has to be a Next.js app." Not the other zones: the main zone's rewrites can send paths to an app built with anything.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upWho made the blog's page?

The recorder asked the shop's address for /blog/brewing-mint. Which app made the page?

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPick the link

    The shop's footer gets a link to the blog. Should it be a plain <a> or a Link?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the missing rule

      With this apps/shop/next.config.ts, the blog's pages load but none of its JavaScript does. Which rule is missing?

      Starter codeTypeScript
      import type { NextConfig } from "next";
      
      const nextConfig: NextConfig = {
        cacheComponents: true,
        partialPrefetching: true,
        async rewrites() {
          return [
            { source: "/blog", destination: `${process.env.BLOG_URL}/blog` },
            { source: "/blog/:path+", destination: `${process.env.BLOG_URL}/blog/:path+` },
          ];
        },
      };
      
      export default nextConfig;
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeAdd a docs zone

        The support team adds brewing guides as a third zone: a Next.js app at /docs, with assetPrefix: "/docs-static", whose address is in DOCS_URL. How many rules does the shop add, and what are they?

        Starter codeTypeScript
        import type { NextConfig } from "next";
        
        const nextConfig: NextConfig = {
          cacheComponents: true,
          partialPrefetching: true,
          async rewrites() {
            return [
              { source: "/blog", destination: `${process.env.BLOG_URL}/blog` },
              { source: "/blog/:path+", destination: `${process.env.BLOG_URL}/blog/:path+` },
              { source: "/blog-static/:path+", destination: `${process.env.BLOG_URL}/blog-static/:path+` },
              // Add the docs zone's rules here
            ];
          },
        };
        
        export default nextConfig;
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWhy did the form pass?

          The blog's allowedOrigins lists only tea-shop.example, the recording's visitors used 127.0.0.1, and the blog's host header was its own address. Yet the Save for later form passed Next.js's origin check. Which header did the check compare with the form's Origin?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe cookie the blog set

            After Save for later, the browser sends the shop's address two cookies. One is the session. What's the other?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, think about which app answers, and what the browser sees.

              The Multi-zones quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What is a zone in Next.js's multi-zones?

                Choose an answer to see the explanation.

              2. Question 2 of 7The blog zone sets this. What does line 6 change?

                Read the code, then predictTypeScript
                import type { NextConfig } from "next";
                
                const nextConfig: NextConfig = {
                  cacheComponents: true,
                  partialPrefetching: true,
                  assetPrefix: "/blog-static",
                  experimental: {
                    serverActions: {
                      // The domain visitors use, as the multi-zones guide asks each zone to list.
                      allowedOrigins: ["tea-shop.example"],
                    },
                  },
                };
                
                export default nextConfig;
                

                Choose an answer to see the explanation.

              3. Question 3 of 7The shop's rewrites have no rule for /blog-static. What does the browser get for the blog's scripts?

                Read the code, then predictTypeScript
                import type { NextConfig } from "next";
                
                const nextConfig: NextConfig = {
                  cacheComponents: true,
                  partialPrefetching: true,
                  async rewrites() {
                    return [
                      { source: "/blog", destination: `${process.env.BLOG_URL}/blog` },
                      { source: "/blog/:path+", destination: `${process.env.BLOG_URL}/blog/:path+` },
                    ];
                  },
                };
                
                export default nextConfig;
                

                Choose an answer to see the explanation.

              4. Question 4 of 7The shop links to the blog with <Link href="/blog">. What happens when the visitor clicks it?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why does the blog know the visitor signed in on the shop?

                Choose an answer to see the explanation.

              6. Question 6 of 7The blog's serverActions.allowedOrigins lists only tea-shop.example, and the recording's visitors used 127.0.0.1. Why did Save for later still pass the origin check?

                Choose an answer to see the explanation.

              7. Question 7 of 7Which part of the tea shop should NOT get a zone of its own?

                Choose an answer to see the explanation.

              Key takeaways

              • A zone is a Next.js app that serves some of a domain's paths. The main zone answers everything and sends other zones their paths with rewrites.
              • Every zone besides the main one sets assetPrefix, and the main zone rewrites that path too; otherwise the zone's JavaScript never arrives.
              • Link to another zone with a plain <a>. Crossing is a full page load either way; a Link only adds requests.
              • One host means one set of cookies: every zone gets the session. Let zones check it with a key that can't sign, so a broken zone can't forge one.
              • List the visitors' domain in each zone's serverActions.allowedOrigins. Trust x-forwarded-host only when your edge writes it, and check the session in every action.
              • Split along team boundaries and separate deploys, not through pages that people move between all the time.

              Remember the one-liner.
              One domain, several apps: the main zone routes, each zone ships on its own, and every crossing is a full page load.

              Coming next: Project: a full-stack app. An invoices dashboard from start to finish: database, sign-in, Server Actions, caching, streaming, tests and deployment.

              CompleteFrontend Clear concepts. Working examples.