nextjs app router multi build
Multi-build Next.js App Router with env-driven config — SEO_SITE pattern, route conditionals, static export per env.

The first time you try to sell a Next.js codebase to a second customer, you have three choices and only one of them ages well. You can fork the repo and watch the two copies drift apart over the next six months as bug fixes land in one and not the other. You can dispatch a runtime tenant lookup that reads host on every request and forces you to keep a Node process alive forever, which is fine until you want CDN-only deploys or zero cold start. Or you can wire a build-time multi-build pipeline that ships one source tree as N independent static sites. Forks rot fast. Runtime tenants are operationally heavier than solo developers want to admit. The third option is the one that pays back every time you ship a new variant, and it asks for roughly thirty minutes of build-time wiring you will never have to revisit.
This tutorial walks four commits that turn a vanilla App Router scaffold into a three-site build pipeline. A single environment variable, SEO_SITE, picks one entry out of a typed registry; pages call notFound() based on what the entry enables; a 30-line build runner shells out next build once per site and renames out/ into out-alpha/, out-beta/, out-gamma/. The result is three static export directories you can hand to nginx, Cloudflare Pages, S3, or any plain object host. There is no runtime tenant detection. There is no process per site. The companion repo at vytharion/nextjs-app-router-multi-build-env carries the full code; every section below cites the commit hash you can git checkout to follow along step by step.
You will be comfortable with this pattern if you are running two or three landing pages that share 80% of their components, if you are operating an SEO content network where each subdomain is a different "site" in product terms, or if you are shipping a hosted product where each customer's brand needs its own deploy artifact but you refuse to fork. By the end you will have a registry, three conditional pages, and a build runner small enough to read in one sitting. The whole project is under 200 lines of TypeScript plus a single mjs script, and it ships exactly what you need without any framework on top.
Lesson 1: Boot the App Router with SEO_SITE wired into next.config.mjs
Try it: 278ec69.
The cheapest moment to fail the build is at config load. If you forget to set SEO_SITE on a CI runner or a teammate's laptop, you do not want to discover it three minutes into static export. Throwing in next.config.mjs fails the build before any pages render. The error message names the env var and shows the exact command to fix it, so a contributor who has never seen this repo before knows what to do without opening a wiki.
We also bake the value into the public env block. env: { SEO_SITE: seoSite } exposes the same string to every layer of the app, so when a registry helper later does process.env.SEO_SITE, it reads the value Next.js has already inlined at build time. That matters because in output: "export" mode the framework does not run a Node server at request time; the variable has to be baked into the artifact.
// next.config.mjs
const seoSite = process.env.SEO_SITE;
if (!seoSite) {
throw new Error(
"SEO_SITE env var is required. Run: SEO_SITE=alpha bun run build"
);
}
const config = {
reactStrictMode: true,
output: "export",
trailingSlash: true,
env: {
SEO_SITE: seoSite,
},
};
export default config;
Two design choices worth naming. First, output: "export" is the Next.js static export mode that emits a fully-static out/ directory. This is the mechanic that makes multi-build practical, because each out-*/ is independently deployable without running a Node process. Second, trailingSlash: true keeps the URL shape consistent with how nginx and most object stores serve directory index files. Without it you can get redirect cycles in production between /about and /about/, which is the kind of bug nobody enjoys diagnosing on a Friday.
The minimal app/page.tsx at this commit reads the env directly. It is intentionally crude. The next lesson replaces it with a typed registry, which is where the multi-site story actually starts.
// app/page.tsx (lesson 1)
export default function HomePage() {
return (
<main>
<h1>Site: {process.env.SEO_SITE}</h1>
</main>
);
}
Lesson 2: Typed site registry with currentSite() lookup
Try it: f28f90a.
Scattering process.env.SEO_SITE === "alpha" checks across components is exactly how a multi-site codebase drifts into chaos. The registry pattern centralises the contract: which sites exist, what they are called, what pages they enable, what theme they ship. The TypeScript narrowing pays off twice. Editor autocomplete prevents typos when you reference site.title somewhere deep in a layout, and the runtime isSiteId guard rejects unknown values with a clear error that lists the valid options.
// sites.ts
export const SITE_IDS = ["alpha", "beta", "gamma"] as const;
export type SiteId = (typeof SITE_IDS)[number];
export type Page = "home" | "about" | "pricing" | "blog";
export interface SiteConfig {
id: SiteId;
title: string;
tagline: string;
theme: "light" | "dark";
enabledPages: readonly Page[];
}
const REGISTRY: Record<SiteId, SiteConfig> = {
alpha: {
id: "alpha",
title: "Alpha (Developer Tools)",
tagline: "Sharp tools for solo developers.",
theme: "dark",
enabledPages: ["home", "about", "blog"],
},
beta: {
id: "beta",
title: "Beta (Indie Game Devs)",
tagline: "Ship small games. Fast.",
theme: "light",
enabledPages: ["home", "about", "pricing"],
},
gamma: {
id: "gamma",
title: "Gamma (Type-Safe APIs)",
tagline: "Pydantic-grade contracts in TypeScript.",
theme: "light",
enabledPages: ["home", "blog"],
},
};
function isSiteId(value: string | undefined): value is SiteId {
return typeof value === "string" && (SITE_IDS as readonly string[]).includes(value);
}
export function currentSite(): SiteConfig {
const raw = process.env.SEO_SITE;
if (!isSiteId(raw)) {
throw new Error(
`SEO_SITE="${raw}" is not a known site. Valid: ${SITE_IDS.join(", ")}`
);
}
return REGISTRY[raw];
}
SITE_IDS is the source of truth, and SiteId is derived from it via (typeof SITE_IDS)[number]. Adding a fourth site is a one-line edit in two places (the tuple plus a registry entry); TypeScript's exhaustiveness checks then flag every switch statement that needs an extra case. Using readonly Page[] instead of Set<Page> is a deliberate trade. Arrays travel cleanly through JSON if you later want to serialise the registry for a downstream tool, and enabledPages.includes(...) reads cleanly at the call site.
currentSite() is a function rather than a top-level constant for a subtle reason. Top-level constants get evaluated during module import, and Next.js imports modules at unpredictable points during the build graph. Wrapping the lookup in a function gives every caller a deterministic moment to evaluate it, which means a clearer stack trace when a misconfigured env var blows up.
app/layout.tsx now reads the registry instead of the raw env, and exposes site.title and site.tagline as page metadata:
// app/layout.tsx
import { currentSite } from "@/sites";
const site = currentSite();
export const metadata = {
title: site.title,
description: site.tagline,
};
Lesson 3: Route conditionals using notFound() per registry
Try it: 9e1c733.
Not every site ships every page. In the registry, alpha enables home + about + blog, beta enables home + about + pricing, and gamma enables only home + blog. The naive way to handle this is to gate the link in the nav: do not render <Link href="/pricing"> if pricing is disabled. That is necessary but not sufficient. A user who types the URL by hand, or a sitemap crawler that walks every route, will still hit /pricing on the gamma build and get whatever the page component happens to render. You want a server-side 404 too.
Next.js gives you exactly the right primitive. The notFound() function from next/navigation aborts rendering and serves the framework 404. Because we are running in output: "export" mode, the abort happens at build time, and the page does not get emitted as HTML at all. The static export simply does not contain a /pricing/index.html in the gamma build directory. A request for that URL falls through to whatever 404 the host returns, which is exactly what a sitemap auditor expects.
// app/pricing/page.tsx
import { notFound } from "next/navigation";
import { currentSite } from "@/sites";
export default function PricingPage() {
const site = currentSite();
if (!site.enabledPages.includes("pricing")) notFound();
return (
<main>
<h1>Pricing for {site.title}</h1>
<ul>
<li>Hobby plan: free</li>
<li>Pro plan: $19/mo</li>
</ul>
</main>
);
}
The same pattern lives in app/about/page.tsx and app/blog/page.tsx. Three lines apiece. The nav in app/page.tsx already filters enabledPages to skip rendering disabled links, so the user-facing UX stays consistent. The build output for gamma drops two routes (/about, /pricing) and emits 3 routes instead of 5, which you can verify by reading the Route (app) table that next build prints.
Compare the build output before and after the gate is added:
# BEFORE registry gate (lesson 1 state): every site emits every page
Route (app)
/ 9.25 kB
/about 145 B
/blog 145 B
/pricing 145 B
# AFTER registry gate (lesson 3 state): SEO_SITE=gamma emits only enabled pages
Route (app)
/ 9.25 kB
/blog 145 B
Smaller output, fewer 404-bait URLs in your sitemap, and the registry stays the single source of truth for what each site contains.
Lesson 4: Build runner that emits out-/ per site
Try it: 480a220.
next build writes its static export to out/. Run it again with a different env value and it overwrites the directory. To deploy three sites you need three named directories, and you need a deterministic way to produce them so the deploy script can rsync each one to its own target. A tiny runner is enough; this version is 30 lines and has no third-party dependencies.
// scripts/build-site.mjs
import { spawnSync } from "node:child_process";
import { rmSync, renameSync, existsSync } from "node:fs";
import { resolve } from "node:path";
const site = process.argv[2];
if (!site) {
console.error("usage: bun run scripts/build-site.mjs <site>");
process.exit(1);
}
const root = resolve(import.meta.dirname, "..");
const target = resolve(root, `out-${site}`);
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
const result = spawnSync("bun", ["run", "build"], {
cwd: root,
stdio: "inherit",
env: { ...process.env, SEO_SITE: site },
});
if (result.status !== 0) process.exit(result.status ?? 1);
const exported = resolve(root, "out");
if (!existsSync(exported)) {
console.error(`expected ${exported} after build, not found`);
process.exit(1);
}
renameSync(exported, target);
console.log(`exported site=${site} -> ${target}`);
A few notes on what is intentionally missing. There is no parallelism. next build is already CPU-bound on a single site, and running three in parallel on a 4-core dev laptop saved less than 10% in my measurements while making the logs unreadable. Sequential builds are slow but boring, which is the right trade for a step you run from a deploy script. If you want concurrency, GitHub Actions matrix is the right place to add it, not the runner.
There is also no per-site dotenv handling. Every config knob that has to vary per site lives in sites.ts, full stop. If you discover you need a per-site API base URL or analytics ID, add it to SiteConfig and read it through currentSite(). Dotenv files per site are tempting and quickly become the worst possible source of "why did this break in production" bugs.
The package.json scripts wrap the runner per site for ergonomics:
"build:alpha": "bun run scripts/build-site.mjs alpha",
"build:beta": "bun run scripts/build-site.mjs beta",
"build:gamma": "bun run scripts/build-site.mjs gamma",
"build:all": "bun run build:alpha && bun run build:beta && bun run build:gamma"
Approximate build times on an M2 MacBook Air with the cache warm: alpha 6.8 s, beta 6.5 s, gamma 6.1 s. Sequential build:all finishes in roughly 20 seconds. A cold cache on the first run adds about 15 seconds for dependency hoisting; subsequent invocations stay in the 20-second window. That is fast enough that you can keep the build inside a pre-commit hook for the variant you actively edited, without bouncing it to CI.
Comparison: pick the right multi-site shape
Three patterns solve the same business problem. Pick by what your hosting setup tolerates and how much drift you are willing to live with.
| Approach | When it fits | Output shape | Cold start | Drift risk |
|---|---|---|---|---|
| Fork the repo per site | Two sites, very different roadmaps | N independent codebases | None (static or runtime) | High after 6 months |
| Runtime tenant lookup | Hosting needs Node and you want one URL | One Node server, host-aware | 200 to 500 ms typical | Low (single source) |
| Build-time multi-build (this article) | Static-friendly hosting, 2 to 20 sites | N static out-*/ dirs | None (static) | Low (single source) |
The runtime tenant approach is fine when you actually need a single shared host and per-request branching. It costs you a long-running Node process per region and a cold-start budget per request, both of which are real money on serverless platforms. The fork approach is fine for exactly two sites that will diverge anyway. For the common case of "I have three landing pages that share most of the layout and I want each on its own subdomain," build-time multi-build is the lowest-overhead answer that still keeps the codebase honest.
When you outgrow build-time multi-build, the next stop is a per-site config repo that publishes JSON which the runner ingests; the registry moves out of sites.ts and into a fetched artifact. That is mostly an organisational change, not a Next.js change, and you will know it is time when adding a site is a pull request from someone who does not need to read the rest of the codebase.
Repository
Full source at https://github.com/vytharion/nextjs-app-router-multi-build-env.
- Lesson 0 -> 2a7ae35: project scaffold, gitignore, README
- Lesson 1 -> 278ec69: next.config.mjs throws if SEO_SITE unset; minimal App Router page
- Lesson 2 -> f28f90a: typed registry in sites.ts with currentSite() lookup
- Lesson 3 -> 9e1c733: per-page notFound() gates from enabledPages
- Lesson 4 -> 480a220: scripts/build-site.mjs renames out/ to out-/
To replay any lesson:
git clone https://github.com/vytharion/nextjs-app-router-multi-build-env
cd nextjs-app-router-multi-build-env
bun install
git checkout 278ec69
SEO_SITE=alpha bun run build
The build emits out/. Lesson 4 is what renames that into out-alpha/ so subsequent builds do not clobber it.
Next steps
Wire a per-site sitemap by adding a sitemap.ts that reads enabledPages from currentSite() and emits only those URLs; this keeps Google Search Console clean when the disabled routes silently disappear from the build. Add per-site Open Graph image generation by looping over SITE_IDS at build time and writing one PNG per site into the public folder. Hook the build runner into a deploy script that rsyncs each out-*/ to its target host; a one-line aws s3 sync out-alpha/ s3://your-alpha-bucket --delete per site closes the loop on object storage, or a per-directory nginx server block does the same on a single VPS.
None of those follow-ups need a runtime tenant. The registry stays the only source of per-site truth, the env stays the only switch, and the multi-build pipeline stays small enough to read at lunch. When it is time to add the fourth site, you edit two lines in sites.ts and one line in package.json. That is the payoff for the thirty minutes you spent wiring this.