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.
- 01Send 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.
- 02Know 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.
- 03Keep 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:
proxy.tsimport { 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).*)"],};- 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.
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:
bensteabar.exampleafter step 1A blank page
React isn't running yet: clicks do nothing.
The page isn't complete yet.
The browser asks the server that bensteabar.example points to for /. The request carries the name the visitor typed, in its Host header.
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
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:
| Question | A subdomain | A 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
Mapfrom 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-Hostonly whenTRUST_FORWARDED_HOSTis1. - Line 22 cleans the name:
toLowerCase()makesASHA.teahub.examplethe same asasha.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.exampleoff the end and look for a shop with that slug. - Anything else ends at line 32:
undefined, no shop. That includesteahub.exampleitself, andzed.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:
proxy.tsimport { 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).*)"],};$ 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:3000Lines 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 startfilled it in. - The fourth asked for
asha.teahub.example., with a trailing dot, andpublicHostgaveasha.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.publicHostignored 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.
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.tsxis the root layout, the one that writes<html>and<body>(Layouts and pages). There's noapp/layout.tsxabove it, because every page belongs to a shop.- Line 13 of the layout finds the shop from
params, and line 14 callsnotFound()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 showsAsha's Tea Roomnext to the address. app/[shop]/page.tsxis each shop's home page. Its line 4 asksteasForfor the teas of the shop inparams.
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:
Route (app)
┌ ○ /_not-found
└ /[shop]
├ ◐ /[shop]
├ ○ /asha
├ ○ /ben
└ ○ /chaigenerateStaticParams, 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:
app/[shop]/shop-footer.tsximport { 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>;}- 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.
[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]inapp/[shop]/teas/[id]isn't: a page under[shop]may have noidat all, so it stays inparams. - Names must be JavaScript names.
[shop]works;[shop-id]doesn't, becauseshop-idcan'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:
app/[shop]/layout.tsximport { 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▲ 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:
app/[shop]/teas/[id]/page.tsximport { 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> );}$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.
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.
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:
| Value | Who chose it | How to use it |
|---|---|---|
shop in params | proxy.ts, from the Host header, after finding it in the list of shops. | Pick the shop with it, in every query. |
id in params | The 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 cookie | The 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:
"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:
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");}- The rose oolong is lovely.
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.
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:
| Input | Part of the key? | What that means |
|---|---|---|
Its arguments, like getSpecial(shop) | Yes | Each value gets an entry of its own. |
| Values it uses from a function around it (a closure) | Yes | They count as arguments. |
Root params it reads, like await shop() | Yes | Next.js adds the ones it reads, and only those. |
| A variable at the top of a module | No | Every caller shares one entry, whatever the variable holds. |
headers() or cookies() | Can't be read | The 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:
lib/specials.tsimport { 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];}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.
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.
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.
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?
- The Host header, found in Teahub's list of shops
params.shopin a page underapp/[shop]await shop()fromnext/root-paramsin a Server Component- A hidden
shopfield in a form ?shop=benin the address- A
shopcookie 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
Sort each place a shop could come from: may Teahub pick whose data to read or change with it?
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.hostis the address the visitor typed." Don't count on it. In this recording, withnext start, it waslocalhost:3000for 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:
/ashaon 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.
Which shop does shopForRequest find for each of these requests? Write a slug, or 404, for each line.
Host: ASHA.teahub.example
Host: chai.teahub.example:443
Host: shop.bensteabar.example
Host: teahub.exampleAsha's shop, Chai Corner, then no shop twice. Line 16 turns ASHA.teahub.example into asha.teahub.example and drops :443. shop.bensteabar.example isn't in the list of custom domains, and it doesn't end in .teahub.example. teahub.example doesn't either: there's nothing in front of it. proxy.ts answers the last two with a 404.
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?
/ben/about, which is app/[shop]/about/page.tsx with shop set to ben. The address bar still shows bensteabar.example/about: a rewrite never changes it.
On Asha's address, /teas/3 shows Ben's tea. Which call should line 9 make instead?
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>
);
}
import { teaInShop, 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 { shop, id } = await params;
const tea = await teaInShop(shop, 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>
);
}
teaInShop(shop, id). shop comes from params, which proxy.ts set from the Host header, so tea 3 is only found on Ben's addresses. The recording of this version says This shop doesn't sell that tea. on Asha's address, and shows Smoky lapsang on Ben's.
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?
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>
);
}
import { notFound } from "next/navigation";
import { findShop, shops } from "@/lib/shops";
import ShopFooter from "./shop-footer";
export function generateStaticParams() {
return shops.map((shop) => ({ shop: shop.slug }));
}
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>
);
}
generateStaticParams. With Cache Components, every root param needs it, returning at least one value; the recorded build stopped with A required root parameter (shop) was not provided in generateStaticParams. Returning every shop also prerenders each one.
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?
"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");
}
"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");
}
From the request's host: shopForRequest(await headers()), as proxy.ts does. Root params can't be called in a Server Action. The recorded post answered 500, and next start printed: was used inside a Server Action. This is not supported.
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?
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];
}
Smoky lapsang, Ben's special. The key is only the function, because currentShop() reads a module variable, which isn't part of it. Ben's visit filled the entry, and Chai Corner's found it, as the recording shows.
Check your understanding
For each question, ask where the shop came from, and who could have chosen it.
Question 1 of 7In Teahub's
proxy.ts, where does the shop for a request come from?Choose an answer to see the explanation.
Question 2 of 7A visitor opens
zed.teahub.example, and no shop is calledzed. What do they get?Choose an answer to see the explanation.
Question 3 of 7On
bensteabar.example, a visitor types/asha. What happens?Choose an answer to see the explanation.
Question 4 of 7Where can
shop()fromnext/root-paramsbe called?Choose an answer to see the explanation.
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 predictTypeScriptimport { 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.
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.
Question 7 of 7Why does
lib/reviews.tstag each shop's entry withreviews-${shop}, not onereviewstag?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 fromnext/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.
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.