In my last post I put an Angular app inside a page on this site: a micro frontend composed within the page. That's one model. Another is simpler to describe: split by route. A path of the site belongs to a different app, with its own build and its own deploy, and the domain stays the same.
So I did it here. The Uses page is now served by a second Next.js app. Open DevTools on it and the response carries a header this site doesn't send anywhere else: x-zone: uses.
The page was the cheap part
The page itself is 514 lines: a list of tools in five languages. Moving it to another app should be a copy and paste.
It isn't, because the page doesn't live alone. It sits inside the sidebar, the mobile header, the footer, the search shortcut, the language and theme switchers, the design tokens and the translations. Counted, that frame is about 2,560 lines, five times the page. A route-based micro frontend doesn't need a page; it needs the whole frame around it, identical, in two apps.
That's where the real decision was. Copying the frame means two sidebars drifting apart. Giving the new app a minimal frame means a page that looks like a different site. The answer that scales is a monorepo with the frame as a shared package.
Step one: a monorepo that changes nothing
Before creating any new app, I turned the repository into a pnpm and Turborepo monorepo: the site moved to apps/portfolio, and the frame, the design system and the translations became packages. The rule was that visitors couldn't see any of it.
"Identical" had to be measured, not assumed. Before moving a single file, I saved the HTML of all 122 pages and the full set of CSS selectors, and compared them after every step. That comparison earned its keep twice:
- 58 CSS classes disappeared. Tailwind used to scan the whole repository, including code examples in documentation, and generated CSS for them. Once it scanned only the app, that dead CSS went away. I checked every one of them before accepting it.
- The frame lost its styles when I removed one line. Tailwind only generates classes it finds, and the packages live outside the app. Without an explicit
@sourcepointing at them, the sidebar renders unstyled and every check stays green.
An independent review then caught one more: Turborepo didn't know the app depended on the packages, so a change only in the frame reused the app's cached test results. CI would have passed a missing translation. One dependency rule fixed it.
Step two: the zone
With the frame as a package, the new app is small. It serves one route, uses the same frame, and prefixes its assets so they don't collide with the portfolio's on the same domain:
const nextConfig: NextConfig = {
// The zone's assets must not collide with the portfolio's on the same domain.
assetPrefix: "/uses-static",
transpilePackages: ["@repo/i18n", "@repo/ui", "@repo/shell"],
async headers() {
return [{ source: "/(.*)", headers: [{ key: "x-zone", value: "uses" }] }]
},
}The portfolio forwards the route to it. If the zone's URL is missing in a production build, the build fails instead of shipping a rewrite to nowhere:
export function usesRewrites(env: { USES_ZONE_URL?: string; NODE_ENV?: string }) {
const raw = env.USES_ZONE_URL ?? (env.NODE_ENV === "production" ? undefined : "http://localhost:3001")
if (!raw) throw new Error("USES_ZONE_URL is required in production builds")
const zone = raw.replace(/\/$/, "")
return [
{ source: `/${USES_LOCALE_PARAM}/uses`, destination: `${zone}/:locale/uses` },
{ source: `/${USES_LOCALE_PARAM}/uses/:path*`, destination: `${zone}/:locale/uses/:path*` },
{ source: "/uses", destination: `${zone}/uses` },
{ source: "/uses-static/:path*", destination: `${zone}/uses-static/:path*` },
]
}The zone's own domain serves a robots.txt that blocks everything. Search engines reach the page through the main domain, and the canonical URL already points there.
A link that knows where it's going
The subtle part was navigation. Client-side navigation only works inside the same app: from the Uses page, going to the home page with a client-side route would ask an app that doesn't have that route. So the frame learned which paths belong to which app:
export type Zone = "portfolio" | "uses"
const LOCALE_GROUP = `(?:${LOCALES.join("|")})`
const USES = new RegExp(`^(?:/${LOCALE_GROUP})?/uses(?:/.*)?$`)
export function zoneFor(pathname: string): Zone {
return USES.test(pathname) ? "uses" : "portfolio"
}
export function linkMode(current: Zone, href: string): "client" | "document" | "external" {
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) return "external"
const pathname = href.split(/[?#]/)[0] || "/"
return zoneFor(pathname) === current ? "client" : "document"
}Every link and every programmatic navigation in the frame goes through that decision. Switching language inside Uses stays client-side. Going from Uses to the home page, or to search, loads a new document, on purpose.
What it cost
- The extra hop. Served directly, the zone answered in 224 ms; through the portfolio's rewrite, in 267 ms.
- Client-side is not automatically faster. Switching language inside the zone (client-side) took 534 ms, while crossing from the zone to the home page (a full document) took 318 ms. The client-side transition fetches the page's data through the rewrite: one extra round trip on every switch.
- Deploys really are independent. A commit only in the zone rebuilds only the zone; a commit only in the portfolio rebuilds only the portfolio; a change in the frame rebuilds both, because both depend on it.
- Failure has a different shape. If the zone goes down,
/useswould fail while the rest of the site keeps working: a rewrite has no fallback. In the Angular experiment, a failing micro frontend took down a block and left the page standing.
What only production showed
Everything passed locally. Two things only appeared once both apps were deployed:
- Prefetches failed through the rewrite. Next.js 16 prefetches a route segment by segment, with a special header. Called directly, the zone answered those requests. Through the portfolio, the same request crossed the rewrite and reached the zone in a form it couldn't serve: 404. Full requests for the page went through fine; only the prefetch broke. Navigation kept working, just without prefetching. My first fix, moving the rewrite to the platform layer (
vercel.json), changed nothing. What worked was turning prefetching off for links inside the zone: the request that failed simply stopped happening. - The revalidation response had no headers. In the Angular experiment, the panel showed 0.3 KB as the module's cost for returning visitors. The 304 the platform sends on revalidation carries none of the CORS and timing headers, so the browser only reports the size of the headers. The panel now treats that as cache.
What the managed product would have done
Vercel has a Microfrontends product that does this routing at the edge, from a single config file, with prefetching between zones and a local proxy. I did it with plain Next.js rewrites so an experiment wouldn't depend on a product that could cost money. The difference is who operates the plumbing, not whether the model works.
One repository, two owners
A fair question is whether the new app shouldn't live in its own repository. Micro frontends are about independent deploys and ownership, not repositories. Here the zone has its own app, its own Vercel project and its own deploy, and a CODEOWNERS file makes ownership explicit. A separate repository would have forced me to publish the frame as a versioned package and keep two apps in sync with it, which is the drift I was trying to avoid. The Angular experiment did get its own repository, because it shared nothing with the site.
When it isn't worth it
For one person and one page, this is overkill, and that was the point. The cost isn't the page: it's the frame, the monorepo, the rewrites and a new failure mode. It starts to pay when several teams need to ship parts of the same site on their own schedule. Now I know what the first step of that looks like.
