This tutorial focuses on creating a micro-frontend architecture using NextJS and its built-in features — no extra frameworks, no module federation plugins. If you're looking to implement micro-frontends with different technologies, this post may not be directly applicable, but the concepts (routing boundaries, asset isolation, cross-app navigation) transfer everywhere.
Introduction to Micro-Frontends
Micro-frontends are an architectural style where independently deliverable frontend applications are composed into a larger, cohesive application. Instead of one team gate-keeping one giant repo, each team owns a slice of the product end to end — its own codebase, its own dependencies, its own deploy pipeline.
The benefits are mostly organizational, and it's worth being honest about that up front:
- Independent deploys — the admin team ships without waiting for the storefront team's release train.
- Isolated blast radius — a bad deploy of one zone doesn't take down the others.
- Incremental migration — new sections can use a newer Next.js (or a different stack entirely) while legacy sections stay untouched. This is the single most common good reason to adopt micro-frontends.
- Team autonomy — separate repos, separate upgrade schedules.
The cost is duplicated infrastructure, harder cross-app consistency, and full page loads at the boundaries. We'll deal with all three.
The approaches, briefly
There are three mainstream ways to do micro-frontends, and choosing wrong is expensive:
| Approach | How it composes | Best for |
|---|---|---|
| Multi-zones (this post) | path-based routing via rewrites; each zone is a full Next.js app | sections that are separate pages (shop vs admin vs blog) |
| Module federation | apps share components at runtime from separate bundles | mixing UI from several apps on ONE page |
| iframes | browser-level embedding | third-party or strongly sandboxed content |
The rule of thumb: if your micro-frontends are different pages, use multi-zones — it's dramatically simpler. If you need different teams' components composed on the same page, you're in module federation territory and should budget for the complexity. Never start with iframes unless isolation is the actual requirement.
Next.js's official answer is multi-zones, so that's what we'll build.
Step-by-Step: a Shop with a Separate Admin
We'll assume a main site at example-shopping.com and an admin section
served at example-shopping.com/admin — two separate Next.js apps that
look like one site:
- Main site (the "shell") — owns the domain, runs on port 3000 in dev.
- Admin zone — a completely separate Next.js app on port 3001.
Step 1: Route /admin traffic to the admin app
In the main site's next.config.js, add rewrites so any request under
/admin is proxied to the admin app:
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
const adminUrl = process.env.ADMIN_URL ?? "http://localhost:3001";
return [
{ source: "/admin", destination: `${adminUrl}/admin` },
{ source: "/admin/:path*", destination: `${adminUrl}/admin/:path*` },
// the admin app's static assets must be reachable too:
{
source: "/admin-static/:path*",
destination: `${adminUrl}/admin-static/:path*`,
},
];
},
};
module.exports = nextConfig;
What are rewrites? Rewrites map incoming request paths to different
destinations invisibly — they act as a URL proxy. The user's address bar
says example-shopping.com/admin, but the response is served by the admin
app. This is the crucial difference from redirects, which visibly change
the URL and bounce the browser to a new location. Rewrites are what make
two apps feel like one site.
Notice the ADMIN_URL environment variable: in dev it falls back to
localhost:3001, in production it points at wherever the admin app is
deployed (e.g. https://admin-internal.example.com). Hard-coding
localhost in the config is the #1 "works on my machine" mistake in
multi-zone setups.
Step 2: Configure the admin app to live under /admin
The admin app needs two settings in its next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
basePath: "/admin",
assetPrefix: "/admin-static",
};
module.exports = nextConfig;
These two are commonly confused, and the distinction matters:
basePathprefixes every page route in the app. The admin'sapp/dashboard/page.tsxnow serves at/admin/dashboard, and its internal<Link href="/dashboard">automatically renders as/admin/dashboard. Without this, the admin app thinks it owns/and every internal link breaks when proxied.assetPrefixprefixes the app's static assets — the/_next/static/...JS and CSS bundles. Both zones would otherwise serve their assets from the same/_next/...paths through the same domain, and the shell would try to answer requests for the admin's chunks with its own (missing) files. Giving each zone a unique prefix (/admin-static) keeps the bundles from colliding — that's also why Step 1 added a rewrite for it.
If you've ever set up a multi-zone and seen the admin pages render as unstyled HTML with 404s in the network tab for every chunk — this is the asset-prefix collision, and now you know the fix.
Restart both apps, visit localhost:3000/admin, and the shell proxies you
into the admin app. Your micro-frontend is running — but it isn't
production-ready yet. The rest of this post is what the short version of
this tutorial leaves out.
Navigation Across Zones: the Detail Everyone Misses
Inside one Next.js app, <Link> gives you fast client-side navigation.
Across zones, that's impossible — the shell's JavaScript bundle knows
nothing about the admin's pages. Cross-zone navigation must be a full page
load, and each app needs to know which URLs are "foreign":
// In the SHELL, linking into the admin zone — use a plain anchor:
<a href="/admin">Go to admin</a>
// In the shell, linking within the shell — normal Link:
<Link href="/products">Products</Link>
If you use <Link href="/admin"> from the shell, Next.js attempts a
client-side transition, finds no such route in its own manifest, and you
get a 404 or a hard error. The mental model: <Link> within a zone,
<a> between zones. Some teams wrap this in a <SmartLink> component
that checks the target against a list of foreign path prefixes and picks
the right element — worth doing once you have more than two zones.
This is also the honest UX cost of multi-zones: boundary crossings are full page loads. Design your zone boundaries so users cross them rarely (shop → admin: fine; every product page → cart: don't split those).
Sharing Auth and State Between Zones
Zones are separate apps, but they share an origin — and that's your communication channel:
- Cookies set on the shared domain are visible to every zone. Standard practice: a session cookie (issued by your auth provider or the shell) that each zone independently validates in its middleware. Don't try to share in-memory state or React context across zones — there is no shared runtime.
- A shared UI package (
@acme/uion a private registry, or a package in a monorepo with Turborepo) keeps the design system consistent. Each zone bundles its own copy, so version skew between zones is possible — acceptable for buttons, painful for anything stateful. - localStorage/sessionStorage are shared per-origin and work across zones, but treat them as a cache, not a source of truth.
Deploying to Production
Each zone deploys independently — that was the whole point. The shape:
- Deploy the admin app anywhere (Vercel, a container, a VM). It gets its
own internal URL, e.g.
https://shop-admin.vercel.app. - Deploy the shell with
ADMIN_URLpointing at that internal URL. - Only the shell's domain is public. The admin app should ideally reject
direct traffic to its internal URL (check the
Hostheader or a shared secret header in middleware) so/adminis the only door in.
On Vercel specifically, multi-zones are first-class: two projects, and the shell's rewrites do exactly what they do locally. If you self-host, the same pattern works with nginx doing the path-based proxying instead of (or in front of) Next.js rewrites — moving the routing table out of app code and into infrastructure, which ops teams often prefer.
Cache carefully at the CDN layer: the two zones have different deploy
cadences, so a CDN caching /admin/* under the shell's cache rules can
serve stale admin HTML after an admin deploy. Scope cache keys and purges
per zone.
Pitfalls Checklist
Collected from real multi-zone setups — check these before calling it done:
- Asset prefix collisions — every zone needs a distinct
assetPrefix, and the shell needs a rewrite for each one. Symptom: unstyled pages, 404s on/_next/static/*. <Link>across zones — must be<a>. Symptom: 404s on client-side navigation that work on hard refresh.- Hard-coded zone URLs — use env vars per environment. Symptom: prod
shell proxying to
localhost:3001. - Both zones fighting over a path — the shell's own router wins before rewrites are consulted for matching pages; keep zone path spaces disjoint.
- Middleware overlap — the shell's middleware runs for
/admin/*requests too (it's the app doing the proxying). Scope itsmatcheraccordingly. - Duplicated dependencies — each zone ships its own React. That's by design (it's what makes them independent) — resist the urge to "share" runtime chunks across zones; that road leads to module federation complexity without its tooling.
When Not to Use Micro-Frontends
A single Next.js app with a well-organized app/ directory, route
groups, and a monorepo already gives small-to-mid teams independent
development. Micro-frontends earn their complexity when you have
multiple teams shipping on independent schedules, or a migration
where sections must move to a new stack one at a time. If neither is true,
one app deployed as one unit will be simpler, faster (no boundary page
loads), and easier to keep consistent. Architecture should follow team
boundaries — not the other way around.
Wrap-up
Next.js multi-zones give you micro-frontends with the tools already in the
framework: rewrites in the shell make separate apps answer under one
domain, basePath + assetPrefix in each zone keep routes and
bundles from colliding, plain <a> tags handle the boundary crossings,
and cookies on the shared origin carry auth across. Deploy each zone
independently, wire the zone URLs through environment variables, and keep
zone boundaries where users rarely cross. Start with two zones, feel the
operational cost, and only then decide how micro your frontends really
need to be.