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

Multi-tenant apps

Serve many customers from one Next.js app on their own subdomains and domains: proxy.ts rewrites, root params, and data and caches kept apart.

By the end, you can
  • 01
    Send each address to its shopRead the Host header in proxy.ts, check it against your list of shops, rewrite to an app/[shop] route, and answer 404 for the rest.
  • 02
    Know the shop anywhere on the serverUse params, root params (new in Next.js 16.3) and a Host lookup in Server Actions, and know where each one works.
  • 03
    Keep shops apartFilter every query by the shop the server looked up, never by an id the visitor sent, and put the shop in every cache key and tag.

One app, many tea shops

The tea shop has grown into Teahub: a service where anyone can open a small tea shop of their own. Asha runs Asha's Tea Room at asha.teahub.example. Ben runs Ben's Tea Bar, and brought his own domain, bensteabar.example. Each shop has its own name, its own teas and its own customers, and each one should feel like a site of its own.

Teahub doesn't build a new app for every shop. It runs one Next.js app, and every shop's address points at it. An app like this is called multi-tenant: one app that serves many customers, its tenants, and keeps them apart. Here a tenant is a shop. The app needs to answer one question for every request: which shop is this for? The answer is in the request itself. Every request a browser sends carries a Host header: the name the visitor typed in the address bar.

Here is Teahub, a real Next.js 16.4.3 project, built with next build and run with next start. The recorder asked for / five times, each time with the Host header a browser at that address would send:

One app, three shops, one proxy.tsNext.js
app/[shop]/lib/
proxy.ts
import { NextResponse, type NextRequest } from "next/server";import { shopForRequest } from "./lib/shops"; export function proxy(request: NextRequest) {  const shop = shopForRequest(request.headers);  if (!shop) {    return new NextResponse("There's no shop at this address.", { status: 404 });  }  const url = request.nextUrl.clone();  url.pathname = `/${shop.slug}${request.nextUrl.pathname}`;  return NextResponse.rewrite(url);} export const config = {  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],};
Recorded from Next.js 16.4.3
asha.teahub.example/Asha's Tea Room
Asha's Tea Room
Our teas
  • Rose oolong: $6
  • Mint green: $4

Sent with host: asha.teahub.example. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

proxy.ts, the file shown first, runs before every request reaches a route (proxy.ts teaches it). Line 5 hands the request's headers to shopForRequest, from lib/shops.ts, which reads the Host header and looks the name up. Lines 6 to 8 answer 404 when no shop has that name. Line 10 puts the shop's slug, its short name in addresses like asha, in front of the path, and line 11 rewrites the request there. So / on Asha's address is answered by the route /asha: the folder app/[shop], a dynamic segment, with shop set to asha.

Pick each request above the page in the Browser tab. The address bar shows the address the recorder asked for. On asha.teahub.example, the page says Asha's Tea Room and lists Asha's two teas. On ben.teahub.example and on bensteabar.example, it's Ben's shop both times, with his one tea. On zed.teahub.example, there's no page at all: the answer is a 404 with one line of text, There's no shop at this address.

Three requests reach the same server, each with its own Host header: asha.teahub.example, bensteabar.example and zed.teahub.example. Arrows lead from all three into one card for proxy.ts, which looks each name up in the list of shops. From proxy.ts, three arrows lead out: asha.teahub.example to the route app/[shop] with shop set to asha, bensteabar.example to app/[shop] with shop set to ben, and zed.teahub.example to a 404, because no shop has that name.the Host headerasha.teahub.examplebensteabar.examplezed.teahub.exampleproxy.tsfinds the shopwhat runsapp/[shop]: ashaapp/[shop]: ben404: no shopproxy.ts answers this one itself
One app, one proxy.ts. It reads each request's Host header and looks it up: Asha's subdomain and Ben's own domain are rewritten to app/[shop] with their shop, and a name that isn't in the list gets a 404.

Step through the visit to Ben's domain, and the one to a shop that doesn't exist:

Playground: two addresses, one proxy.ts
Who does whatbensteabar.example
What the visitor seesafter step 1
bensteabar.example/

A blank page

React isn't running yet: clicks do nothing.

The page isn't complete yet.

Step 1 of 4
Browser: The visitor opens Ben's domain

The browser asks the server that bensteabar.example points to for /. The request carries the name the visitor typed, in its Host header.

Each step is one thing that happens, in order, with the overview's proxy.ts. The screens are the recorded answers for bensteabar.example and zed.teahub.example. Reset goes back to the first journey's first step.
Real-life analogyOne kitchen, several restaurants

Some kitchens cook for several restaurant brands at once. Each brand has its own name, menu, bags and phone number. When an order comes in, the screen shows which number the customer called, and the cook takes that brand's recipes and that brand's bags. An order for one brand never goes out in another brand's bag. And a call to a number that isn't theirs gets no answer.

In real life: One kitchen cooks for several restaurant brands
In Next.js: One Next.js app serves every shop
In real life: Each brand has its own phone number
In Next.js: Each shop has its own address: a subdomain or a domain
In real life: The phone shows which number was called
In Next.js: Each request carries the address in its Host header
In real life: The cook reads the screen and takes that brand's recipes
In Next.js: proxy.ts looks up the shop and rewrites to app/[shop]
In real life: A number that isn't on the list gets no answer
In Next.js: An unknown host gets a 404
In real life: Each brand's food goes in its own bags
In Next.js: Every query is filtered by that shop
The main idea

A multi-tenant app is one app that serves many tenants, each on its own address, and keeps them apart. In Next.js, proxy.ts reads each request's Host header, finds the tenant in a list it trusts, and rewrites the request to a route like app/[shop]. From there, every page, action and cached function gets the tenant from that server-side lookup, never from anything the visitor sends.

Subdomains and custom domains

Teahub gives shops two kinds of address. A subdomain is a name under Teahub's own domain: asha.teahub.example is the label asha in front of teahub.example. A custom domain is a domain the shop owns, like bensteabar.example. To the app they look the same: a request whose Host header holds a name. What differs is who owns the name, and how it reaches Teahub's server. Each row asks one question of both:

A subdomain and a custom domain
QuestionA subdomainA custom domain
Who owns the name?Teahub. asha is one label under Teahub's own domain.The shop. Ben bought bensteabar.example himself.
What sends it to Teahub's server?One wildcard DNS record, *.teahub.example, covers every shop at once.A DNS record that Ben adds at his domain's registrar, pointing at Teahub's server.
How does shopForRequest find the shop?It takes the part before .teahub.example and looks for a shop with that slug.It looks the whole name up in customDomains.
What does HTTPS need?One wildcard certificate for *.teahub.example covers every shop.A certificate for each domain. Many hosting platforms issue one when you add the domain to the project.

DNS, the internet's address book, is what turns a name into the address of a server. Both kinds of name must point at Teahub's server before a single request arrives. A custom domain needs one more step first: Teahub adds bensteabar.example to its list only after Ben proves he owns it, usually by adding a DNS record Teahub asks for, or through the hosting provider's own check. Teahub keeps checking, and switches the mapping off if the proof goes away, for example when Ben lets the domain lapse and someone else buys it.

After that, the app reads the name and decides. That's the job of lib/shops.ts, and proxy.ts and every Server Action call the same two functions in it. Open it in the example above:

  • Lines 2 to 6 are the list of shops. A real platform keeps it in its database, and a shop that signs up today is in it tomorrow.
  • Line 9 is the list of custom domains: a Map from each domain to the shop that owns it.
  • Line 13 decides which header to trust, from the deployment's settings. Line 21 reads the Host header, or X-Forwarded-Host only when TRUST_FORWARDED_HOST is 1.
  • Line 22 cleans the name: toLowerCase() makes ASHA.teahub.example the same as asha.teahub.example, because domain names don't care about case, and it drops a port, like :3000, and a trailing dot (asha.teahub.example. is the same name, written fully).
  • Lines 27 and 28 look for the whole name among the custom domains first. Lines 29 to 31 handle a subdomain: they cut .teahub.example off the end and look for a shop with that slug.
  • Anything else ends at line 32: undefined, no shop. That includes teahub.example itself, and zed.teahub.example.

Never use the name without that lookup. A browser sends the name the visitor typed, but any other program can send any Host header it likes. The list is what decides. A name that isn't in it gets the 404, so nobody reaches a shop that doesn't exist, or a part of the app that isn't a shop.

Which header holds the visitor's address?

On the recorded server, the Host header does: the requests reach next start directly. Many deployments put a load balancer or another reverse proxy in front: a server that receives every request first and passes it on. Some of them pass it on with their own name in Host, and the visitor's in X-Forwarded-Host. But a visitor can send X-Forwarded-Host too. Here is the same app, with two lines added to proxy.ts that write down what arrived. The example also kept what next start printed. Open the Terminal tab:

What proxy.ts sees, and the host it picksNext.js
app/[shop]/lib/
proxy.ts
import { NextResponse, type NextRequest } from "next/server";import { publicHost, shopForRequest } from "./lib/shops"; export function proxy(request: NextRequest) {  const sent = `host: ${request.headers.get("host")} | x-forwarded-host: ${request.headers.get("x-forwarded-host")}`;  console.log(sent, "| publicHost:", publicHost(request.headers), "| nextUrl.host:", request.nextUrl.host);  const shop = shopForRequest(request.headers);  if (!shop) {    return new NextResponse("There's no shop at this address.", { status: 404 });  }  const url = request.nextUrl.clone();  url.pathname = `/${shop.slug}${request.nextUrl.pathname}`;  return NextResponse.rewrite(url);} export const config = {  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],};
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (6/6)  Finalizing page optimization ... Route (app)┌ ○ /_not-found└   /[shop]  ├ ◐ /[shop]  ├ ○ /asha  ├ ○ /ben  └ ○ /chai ƒ Proxy (Middleware) ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local:         http://localhost:3000- Network:       http://localhost:3000✓ Readyhost: asha.teahub.example | x-forwarded-host: asha.teahub.example | publicHost: asha.teahub.example | nextUrl.host: localhost:3000host: ben.teahub.example | x-forwarded-host: ben.teahub.example | publicHost: ben.teahub.example | nextUrl.host: localhost:3000host: bensteabar.example | x-forwarded-host: bensteabar.example | publicHost: bensteabar.example | nextUrl.host: localhost:3000host: asha.teahub.example. | x-forwarded-host: asha.teahub.example. | publicHost: asha.teahub.example | nextUrl.host: localhost:3000host: asha.teahub.example | x-forwarded-host: ben.teahub.example | publicHost: asha.teahub.example | nextUrl.host: localhost:3000

Lines 5 and 6 log both headers, the name publicHost picked, and request.nextUrl.host. Under $ next start, five lines:

  • For the first three, nobody sent X-Forwarded-Host, and it holds the Host header's name anyway: next start filled it in.
  • The fourth asked for asha.teahub.example., with a trailing dot, and publicHost gave asha.teahub.example: Asha's shop.
  • The fifth went to Asha's address but claimed x-forwarded-host: ben.teahub.example. That made-up value arrived as it was sent. publicHost ignored it, because nothing on this server sets that header, and the page is Asha's.

That's the trust decision in plain words: believe a header only if something you control wrote it. Set TRUST_FORWARDED_HOST=1 only where your own load balancer sets X-Forwarded-Host and replaces whatever the visitor sent; then it holds the name they typed. Anywhere else, trusting it would let a request that reached Asha's address act for Ben's shop: a review posted there would land in Ben's. Your hosting provider's docs say which header carries the public host on their servers.

And nextUrl.host was localhost:3000 all five times. That's what this recording saw with next start, not a rule: other hosts and adapters build the request's URL their own way. Don't count on it for the visitor's address; read the header your deployment trusts.

Shops on your own computer

While you build, the shops' real names don't point at your computer. Chrome and Firefox send every name that ends in .localhost to your own computer, so asha.localhost:3000 reaches next dev with that Host header. Teach shopForRequest to read the label in front of .localhost too, only in development.

Rewriting each request to its shop's route

A rewrite answers a request with another route, while the address bar keeps what the visitor typed. That's what lets one folder of pages serve every shop. Teahub's pages all live under app/[shop]:

  • app/[shop]/layout.tsx is the root layout, the one that writes <html> and <body> (Layouts and pages). There's no app/layout.tsx above it, because every page belongs to a shop.
  • Line 13 of the layout finds the shop from params, and line 14 calls notFound() for a slug that isn't a shop. proxy.ts already stops unknown names, so this is a second check, in case a request ever reaches the route another way.
  • Line 18 writes the shop's name into every page, and generateMetadata, on lines 8 to 10, makes it the page's title: the Browser tab shows Asha's Tea Room next to the address.
  • app/[shop]/page.tsx is each shop's home page. Its line 4 asks teasFor for the teas of the shop in params.

Every path gets the shop in front

Line 10 of proxy.ts puts the slug in front of every path, not only /. That's what keeps a visitor from reaching another shop through the path. Pick the last request in the example above: on bensteabar.example, the recorder asked for /asha. proxy.ts rewrote it to /ben/asha, no route matches that, and the answer is 404, This page could not be found. Asha's shop never shows on Ben's domain.

It also means links inside a shop are written without the slug. A link to /about on Ben's page stays on bensteabar.example/about, and proxy.ts turns it into /ben/about on the server. The matcher on lines 14 to 16 lets every path through except Next.js's own files, under /_next/static and /_next/image, and the icon: those must never get a shop in front.

What the build made

Open the Terminal tab of the overview's example. The route table has a group for the folder:

The routes next build printed for TeahubText
Route (app)
┌ ○ /_not-found
└   /[shop]
  ├ ◐ /[shop]
  ├ ○ /asha
  ├ ○ /ben
  └ ○ /chai

generateStaticParams, on lines 4 to 6 of the layout, lists every shop, so the build made /asha, /ben and /chai ahead of time: ○, prerendered as static content. The Network tab shows what that means: each shop's page came with x-nextjs-cache: HIT, the HTML the build made. The line ◐ /[shop] is a shell the build made for shops it didn't list, like one that signed up after the build. That shop's own page is made the first time someone visits it (generateStaticParams shows how).

Root params: the shop anywhere on the server

Pages and layouts get the shop in params. Components deeper down don't: you'd pass it as a prop, through every component in between. Since Next.js 16.3, there's another way. A dynamic segment that sits above the root layout, like [shop], is a root param, and the module next/root-params gives each one as a function. Any Server Component under that root layout can call it. Here is a footer that reads the shop itself:

A footer that reads the shop with no propNext.js
app/[shop]/lib/
app/[shop]/shop-footer.tsx
import { shop } from "next/root-params";import { findShop } from "@/lib/shops"; export default async function ShopFooter() {  const current = findShop(await shop());  return <footer>{current?.name} is a tea shop on Teahub.</footer>;}
Recorded from Next.js 16.4.3
asha.teahub.example/Asha's Tea Room
Asha's Tea Room
Our teas
  • Rose oolong: $6
  • Mint green: $4

Sent with host: asha.teahub.example. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Line 1 of app/[shop]/shop-footer.tsx imports shop from next/root-params. The name comes from the folder: [shop] gives a function called shop. Line 5 waits for it and finds the shop. In the layout, line 21 places <ShopFooter /> with no props at all. The Browser tab shows each shop's own footer: Asha's Tea Room is a tea shop on Teahub. on Asha's address, Ben's on both of Ben's.

An editor window for the app folder lists four files: [shop]/layout.tsx, the root layout, then [shop]/page.tsx, [shop]/shop-footer.tsx and [shop]/teas/[id]/page.tsx. An arrow from the layout row leads to a card: shop, a root param, which any Server Component under it can read. An arrow from the last row leads to a card: id, a params value, which only the page in the [id] folder gets.app[shop]/layout.tsx[shop]/page.tsx[shop]/shop-footer.tsx[shop]/teas/[id]/page.tsxshop: a root paramany Server Component can read itid: a params valueonly the page in [id] gets it
[shop] is a folder above the root layout, so shop is a root param: any Server Component under it can call await shop(), like the footer does. [id] is deeper, so only the page in its folder gets it, through params.

Root params came out in Next.js 16.3, in August 2026. In Next.js 16.4.3 they need no setting in next.config.ts: an old experimental one, experimental.rootParams, now only earns a warning that it's no longer needed. Next.js's docs give a few rules:

  • Only Server Components. A Client Component can't import it: the build fails. Server Actions can't call it, and Route Handlers can't yet; the docs say support for them is planned.
  • Only segments above the root layout. [shop] is one. [id] in app/[shop]/teas/[id] isn't: a page under [shop] may have no id at all, so it stays in params.
  • Names must be JavaScript names. [shop] works; [shop-id] doesn't, because shop-id can't be a function's name.

With Cache Components on, there's one more: every root param needs generateStaticParams, with at least one value. Here is the same app with lines 4 to 6 of the layout taken out:

The layout without generateStaticParams (an error on purpose)Next.js
app/[shop]/lib/
app/[shop]/layout.tsx
import { notFound } from "next/navigation";import { findShop } from "@/lib/shops";import ShopFooter from "./shop-footer"; export async function generateMetadata({ params }: LayoutProps<"/[shop]">) {  return { title: findShop((await params).shop)?.name };} export default async function ShopLayout({ children, params }: LayoutProps<"/[shop]">) {  const shop = findShop((await params).shop);  if (!shop) notFound();  return (    <html lang="en">      <body>        <header>{shop.name}</header>        {children}        <ShopFooter />      </body>    </html>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...Error: A required root parameter (shop) was not provided in generateStaticParams for /[shop], please provide at least one value.    at ignore-listed frames > Build error occurredError: Failed to collect page data for /[shop]    at ignore-listed frames {  type: 'Error'}

The build stopped while it collected the pages' data: A required root parameter (shop) was not provided in generateStaticParams for /[shop], please provide at least one value. Put the list back, even if it only names one shop.

Keeping each shop's data apart

Teahub keeps every shop's teas in one table, with a shop column. That's common: one database for all tenants. It also means one forgotten filter shows one shop's data on another shop's site. Every query must say whose data it wants, and that "whose" must come from the server's lookup. Here is a page for one tea, /teas/[id], in two versions. Each was asked for tea 3, which is Ben's, on Asha's address:

Playground: Ben's tea on Asha's address
Teahub with a page for one tea, at /teas/[id]Next.js
app/[shop]/teas/[id]/lib/
app/[shop]/teas/[id]/page.tsx
import { teaById, teasFor } from "@/lib/db"; export async function generateStaticParams({ params }: { params: { shop: string } }) {  return (await teasFor(params.shop)).map((tea) => ({ id: tea.id }));} export default async function TeaPage({ params }: PageProps<"/[shop]/teas/[id]">) {  const { id } = await params;  const tea = await teaById(id);  if (!tea) return <p>This shop doesn't sell that tea.</p>;  return (    <main>      <h1>{tea.name}</h1>      <p>${tea.price} a box</p>    </main>  );}
Recorded from Next.js 16.4.3
asha.teahub.example/teas/3Asha's Tea Room
Asha's Tea Room
Smoky lapsang

$5 a box

Sent with host: asha.teahub.example. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Try it yourself
By the id alone

Line 9 looks for tea 3 in the whole table. On Asha's address it finds Ben's tea, and shows it under Asha's name: Smoky lapsang, $5 a box.

Both versions were built and run with Next.js 16.4.3, and asked for /teas/1 and /teas/3 on Asha's address, then /teas/3 on Ben's. Only line 9 of the page and the line above it change. Reset goes back to the first version.

The first version calls teaById(id) on line 9: tea 3 from the whole table, whoever owns it. The Browser tab shows Ben's Smoky lapsang, at $5 a box, under Asha's Tea Room. Nothing broke, and no error was printed. The page just showed one shop's data on another shop's site. If the tea were one Ben hadn't launched yet, his secret would be out.

The second version takes shop from params on line 8 too, and line 9 calls teaInShop(shop, id): tea 3, but only inside Asha's shop. There's none, so the page says This shop doesn't sell that tea. On Ben's address, tea 3 is still his. Each row is one value the page uses, and who chose it:

Who chose each value the tea page uses
ValueWho chose itHow to use it
shop in paramsproxy.ts, from the Host header, after finding it in the list of shops.Pick the shop with it, in every query.
id in paramsThe visitor: it's whatever they typed after /teas/.Look it up only inside the shop the server picked.
?shop=ben, a hidden form field, a shop cookieThe visitor. They can send any value they like.Never pick the shop with it.

The id is fine to take from the visitor. It's only ever looked for inside the shop the server picked, so the worst a made-up id can do is find nothing.

Server Actions look the shop up themselves

A Server Action writes data, so it matters even more where its shop comes from. And it can't use root params. So Teahub's review form does the same lookup proxy.ts does:

app/[shop]/reviews/actions.ts: the review goes to the shop the address namesTypeScript
"use server"; import { headers } from "next/headers";import { redirect } from "next/navigation";import { updateTag } from "next/cache";import { saveReview } from "@/lib/reviews";import { shopForRequest } from "@/lib/shops"; export async function leaveReview(formData: FormData) {  const shop = shopForRequest(await headers());  if (!shop) throw new Error("There's no shop at this address.");  const text = String(formData.get("text") ?? "").trim().slice(0, 200);  if (text) {    await saveReview(shop.slug, text);    updateTag(`reviews-${shop.slug}`);  }  redirect("/reviews");} 

Line 10 gets the request's headers with headers() (Request-time APIs) and hands them to the same shopForRequest that proxy.ts uses, so one function decides which host to trust, everywhere. Line 11 stops when there's no shop. Line 12 takes the review's text from the form, which is fine: that's what the visitor is meant to choose. Line 14 saves it under the shop from line 10, and line 15 expires that shop's cached reviews, which the caching section explains. There's no shop field in the form at all. A hidden field would be easy to add, and just as easy for anyone to change before sending.

Here it is recorded. The recorder opened /reviews on Asha's address, posted the form there the way a browser does before the page's JavaScript loads, then opened /reviews on Asha's and on Ben's:

A review posted on Asha's addressNext.js
app/[shop]/reviews/lib/
app/[shop]/reviews/actions.ts
"use server"; import { headers } from "next/headers";import { redirect } from "next/navigation";import { updateTag } from "next/cache";import { saveReview } from "@/lib/reviews";import { shopForRequest } from "@/lib/shops"; export async function leaveReview(formData: FormData) {  const shop = shopForRequest(await headers());  if (!shop) throw new Error("There's no shop at this address.");  const text = String(formData.get("text") ?? "").trim().slice(0, 200);  if (text) {    await saveReview(shop.slug, text);    updateTag(`reviews-${shop.slug}`);  }  redirect("/reviews");}
Recorded from Next.js 16.4.3
asha.teahub.example/reviewsAsha's Tea Room
Asha's Tea Room
Reviews
  • The rose oolong is lovely.
Post review

Sent with host: asha.teahub.example. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

The post was answered 303 with location: /reviews, the redirect on line 17. Afterwards, Asha's page shows The rose oolong is lovely., and Ben's still says No reviews yet. The reviews are a stand-in for a database, kept in the server's memory for the recording. Anyone may post a review here, so the action checks nothing else. An action that changes a shop's own data, like its prices, must also check who is signed in and whether they run that shop; Authorization and the data access layer builds that.

A second wall in the database

Filtering every query is the first wall. Some teams add a second one in the database, so a forgotten filter returns nothing instead of another shop's rows. PostgreSQL's row-level security can refuse rows whose shop doesn't match a value the app sets for each request, and some platforms give each tenant a database or a schema of its own. Either way, the shop the app passes in still comes from the server's lookup.

Caching each shop's results

A cached function stores its result under a key, and gives the same key the same stored result, to every request that asks (The 'use cache' directive). In a multi-tenant app, the shop must be part of that key. If it isn't, the first shop to ask fills the entry, and every other shop gets that shop's result. Each row is one thing that could go into a key:

What a "use cache" function's key is made of
InputPart of the key?What that means
Its arguments, like getSpecial(shop)YesEach value gets an entry of its own.
Values it uses from a function around it (a closure)YesThey count as arguments.
Root params it reads, like await shop()YesNext.js adds the ones it reads, and only those.
A variable at the top of a moduleNoEvery caller shares one entry, whatever the variable holds.
headers() or cookies()Can't be readThe build stops; read them outside and pass a value in.

The module-level variable is the row to watch. It looks like a handy way to skip props: the layout writes the current shop into a variable at the top of lib/current-shop.ts, and any function reads it. Here is today's special, in three versions. In all three the layout sets that variable, and generateStaticParams lists only Asha's shop, so the others are made the first time someone visits. The recorder visited Ben's shop first, then Chai Corner's, then Asha's:

Playground: whose special is in the cache?
Teahub's special page, with this version of lib/specials.tsNext.js
app/[shop]/special/lib/
lib/specials.ts
import { currentShop } from "./current-shop"; const specials: Record<string, string> = {  asha: "Rose oolong",  ben: "Smoky lapsang",  chai: "Masala chai",}; export async function getSpecial() {  "use cache";  const shop = currentShop();  console.log("getSpecial ran for", shop);  return specials[shop];}
Recorded from Next.js 16.4.3
chai.teahub.example/specialChai Corner
Chai Corner
Today's special

Smoky lapsang

Sent with host: chai.teahub.example. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Try it yourself
The shop from a module variable

The key is only getSpecial with no arguments: line 11 reads the shop from a module variable, which isn't part of it. Ben's visit filled the entry (one getSpecial ran for ben line), and Chai Corner's visit got it: its page says Smoky lapsang under Chai Corner.

Every version was built and run with Next.js 16.4.3. The recorder asked for /special on Ben's address, then Chai Corner's, then Asha's, whose page the build made. The Terminal shows each time getSpecial really ran, during the build and under next start. Reset goes back to the first version.

The first version reads the shop from the module variable on line 11. Its key is only getSpecial, with no arguments. Under $ next start, the Terminal has one line: getSpecial ran for ben. Chai Corner's visit found that entry, so its page says Smoky lapsang, Ben's special, under Chai Corner. Asha's page is right only because the build made it, and the build's own call printed getSpecial ran for asha above the route table.

The second version takes shop as an argument, and the third reads the root param inside the cached function. Both print two lines under $ next start, one for ben and one for chai, and every page shows its own special. For the third, Next.js notices which root params the function reads and adds only those to its key. A module variable has a second problem, with or without a cache: the server renders many requests at the same time, and they all share that one variable.

Top: the shop comes from a module variable. A visit from ben and a visit from chai both lead to one key, getSpecial with no arguments, and one entry, Smoky lapsang, served to both. Bottom: the shop is an argument or a root param. Ben's visit leads to the key getSpecial(ben) and the entry Smoky lapsang; chai's visit leads to getSpecial(chai) and Masala chai.The shop from a module variablebenchaigetSpecial()Smoky lapsangone entry for both shopsThe shop as an argument, or a root parambengetSpecial(ben)Smoky lapsangchaigetSpecial(chai)Masala chai
A cached function's key is made of what goes in. On top, the shop comes from a module variable, so Ben and Chai Corner share the one entry Ben's visit made. Below, the shop is an argument or a root param, so each shop gets an entry of its own.

One tag for each shop

Tags follow the same rule. In lib/reviews.ts, from the review example, the cached reviewsFor calls cacheTag(`reviews-${shop}`) on line 9, and the action calls updateTag with the same name (Cache lifetimes and tags). One shared reviews tag would expire every shop's reviews whenever any shop got one. In the recording, Asha's page after the review came with x-nextjs-cache: MISS: made again, with the new review. Ben's came with HIT: still the stored page, untouched.

Any cache in front of the server, like a CDN, needs the same care: it must keep pages apart by host as well as by path, or / on one shop could be stored and sent for / on another.

Where may the shop come from?

Each leak in Teahub came from one question answered wrongly: where did this code get the shop? Sort each source. Can it pick whose data a request reads or changes?

Can it pick the shop?
  • The Host header, found in Teahub's list of shops
  • params.shop in a page under app/[shop]
  • await shop() from next/root-params in a Server Component
  • A hidden shop field in a form
  • ?shop=ben in the address
  • A shop cookie the browser sends back
  • X-Forwarded-Host, on a server where no load balancer sets it
  • The Host header, used as the slug without a lookup
Try it yourself
0 of 8 correct

Sort each place a shop could come from: may Teahub pick whose data to read or change with it?

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

In short: the Host header, found in your own list, picks the shop, once, on the server. Pages read it from params or a root param, Server Actions look it up again, and everything the visitor sends is searched for only inside that shop.

Common mix-ups

  • "Each subdomain is a separate app." No. Every shop's name points at one app, built once. proxy.ts tells them apart by the Host header.
  • "request.nextUrl.host is the address the visitor typed." Don't count on it. In this recording, with next start, it was localhost:3000 for every shop, and other hosts build it their own way. Read the host from the header your deployment trusts.
  • "The Host header can be used as the shop's name." No. Any program can send any Host header. Look it up in your list, and answer 404 when it isn't there.
  • "A visitor can open another shop by typing its slug in the path." Not when proxy.ts puts the shop in front of every path: /asha on Ben's domain became /ben/asha, a 404.
  • "Root params work everywhere on the server." No. Server Components only: a Server Action threw, and Route Handlers aren't supported yet.
  • "A cached function knows which shop it's for." Only through its key: arguments, closures and the root params it reads. A module variable isn't part of it.
  • "Filtering on the page is enough." Every query needs the shop: in pages, Server Actions, Route Handlers and cached functions.

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-upRead four Host headers

Which shop does shopForRequest find for each of these requests? Write a slug, or 404, for each line.

Starter codeText
Host: ASHA.teahub.example
Host: chai.teahub.example:443
Host: shop.bensteabar.example
Host: teahub.example

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

    Exercise 2 · Warm-upFollow a link on Ben's domain

    Teahub adds app/[shop]/about/page.tsx. A visitor on bensteabar.example clicks a link to /about. Which route does Next.js render on the server?

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

      Exercise 3 · PracticeKeep Ben's tea off Asha's site

      On Asha's address, /teas/3 shows Ben's tea. Which call should line 9 make instead?

      Starter codeTSX
      import { teaById, teasFor } from "@/lib/db";
      
      export async function generateStaticParams({ params }: { params: { shop: string } }) {
        return (await teasFor(params.shop)).map((tea) => ({ id: tea.id }));
      }
      
      export default async function TeaPage({ params }: PageProps<"/[shop]/teas/[id]">) {
        const { id } = await params;
        const tea = await teaById(id);
        if (!tea) return <p>This shop doesn't sell that tea.</p>;
        return (
          <main>
            <h1>{tea.name}</h1>
            <p>${tea.price} a box</p>
          </main>
        );
      }
      

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

        Exercise 4 · PracticeFix a build that stopped

        This layout builds no more. next build prints: A required root parameter (shop) was not provided in generateStaticParams for /[shop]. What does the layout need?

        Starter codeTSX
        import { notFound } from "next/navigation";
        import { findShop } from "@/lib/shops";
        import ShopFooter from "./shop-footer";
        
        export async function generateMetadata({ params }: LayoutProps<"/[shop]">) {
          return { title: findShop((await params).shop)?.name };
        }
        
        export default async function ShopLayout({ children, params }: LayoutProps<"/[shop]">) {
          const shop = findShop((await params).shop);
          if (!shop) notFound();
          return (
            <html lang="en">
              <body>
                <header>{shop.name}</header>
                {children}
                <ShopFooter />
              </body>
            </html>
          );
        }
        

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

          Exercise 5 · ChallengeAn action that asks for the root param

          A teammate shortened the review action. Posting a review on Asha's address now fails with a 500. Where should the action get the shop instead?

          Starter codeTypeScript
          "use server";
          
          import { redirect } from "next/navigation";
          import { updateTag } from "next/cache";
          import { shop } from "next/root-params";
          import { saveReview } from "@/lib/reviews";
          
          export async function leaveReview(formData: FormData) {
            const slug = await shop();
            const text = String(formData.get("text") ?? "").trim().slice(0, 200);
            if (text) {
              await saveReview(slug, text);
              updateTag(`reviews-${slug}`);
            }
            redirect("/reviews");
          }
          

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

            Exercise 6 · ChallengePredict a shared entry

            With this lib/specials.ts, Ben's shop asks for /special, then Chai Corner's. Neither page was made by the build. What does Chai Corner's page show?

            Starter codeTypeScript
            import { currentShop } from "./current-shop";
            
            const specials: Record<string, string> = {
              asha: "Rose oolong",
              ben: "Smoky lapsang",
              chai: "Masala chai",
            };
            
            export async function getSpecial() {
              "use cache";
              const shop = currentShop();
              console.log("getSpecial ran for", shop);
              return specials[shop];
            }
            

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

              Check your understanding

              For each question, ask where the shop came from, and who could have chosen it.

              The multi-tenant apps quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In Teahub's proxy.ts, where does the shop for a request come from?

                Choose an answer to see the explanation.

              2. Question 2 of 7A visitor opens zed.teahub.example, and no shop is called zed. What do they get?

                Choose an answer to see the explanation.

              3. Question 3 of 7On bensteabar.example, a visitor types /asha. What happens?

                Choose an answer to see the explanation.

              4. Question 4 of 7Where can shop() from next/root-params be called?

                Choose an answer to see the explanation.

              5. Question 5 of 7This is lib/specials.ts. Ben's shop asks for the special first, then Chai Corner's. What does Chai Corner's page show?

                Read the code, then predictTypeScript
                import { currentShop } from "./current-shop";
                
                const specials: Record<string, string> = {
                  asha: "Rose oolong",
                  ben: "Smoky lapsang",
                  chai: "Masala chai",
                };
                
                export async function getSpecial() {
                  "use cache";
                  const shop = currentShop();
                  console.log("getSpecial ran for", shop);
                  return specials[shop];
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 7A Server Action saves a review. How should it know which shop the review is for?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why does lib/reviews.ts tag each shop's entry with reviews-${shop}, not one reviews tag?

                Choose an answer to see the explanation.

              Key takeaways

              • A multi-tenant app is one app for many tenants. Each tenant's subdomain or custom domain points at it, and every request names its address in the Host header.
              • proxy.ts reads the Host header, looks it up in a list you control, answers 404 for anything else, and rewrites every path to app/[shop]/…. One function decides which header to trust, and proxy.ts and every Server Action use it.
              • Pages and layouts get the shop in params. Since Next.js 16.3, any Server Component can call it from next/root-params; Server Actions look it up from the Host header again.
              • Every query takes the shop from that server-side lookup. Ids and other values from the visitor are searched for only inside it.
              • A cached function's key is its arguments, closures and the root params it reads. Put the shop in it, and in its tags; a module variable isn't part of it.

              Remember the one-liner.
              The address picks the shop once, on the server; every page, action, query and cache key carries that shop, and nothing the visitor sends can change it.

              Read it in Next.js's own code

              Each function from next/root-params calls getRootParam, in lines 48 to 56 of root-params.ts, which throws the error the review action hit when it runs during a Server Action. Inside a cached function, lines 65 to 71 add the param's name to readRootParamNames: that's how the cache learns which root params to put in its key. And the warning for the old setting is in lines 241 to 246 of config.ts.

              Coming next: Authentication. The tea shop gets accounts: passwords stored safely, a session in a signed cookie, and signing out.

              CompleteFrontend Clear concepts. Working examples.