Let's talk
← All posts

2026-10-05 · 5 min read

Many Next.js apps, one address

How we run separate Next.js portals under one domain with a reverse proxy, an asset prefix and a small Docker trick, and why the hardest part was people.

On this page

When people say microfrontends, they usually mean iframes or module federation. We took a plainer route. Every portal is its own Next.js app, and a reverse proxy puts them all under one domain. It is how 15+ engineers across different teams build and ship their own portals independently.

What the user sees

yourdomain.com/            → the main portal (the parent)
yourdomain.com/app-one/*   → a tenant portal
yourdomain.com/app-two/*   → another tenant portal

Users sign in once to the main portal and open any other portal from an icon in the sidebar. Behind that click, the main portal sends them to the shared domain plus the tenant's path, and the proxy takes over. The address in the browser stays on the shared domain and only the path changes. Moving between portals is a full page load, not an in-app transition.

Sort out login first

This pattern only works if every app accepts the same login token. Because the user never leaves your domain, there is no separate sign-in step when they cross from one app to another. A token from one app has to be accepted by all of them, it has to live in the same place (a cookie or storage on the shared domain), and every backend has to validate that same token, usually through a shared identity provider.

The infrastructure is the easy part. The agreement about authentication between the apps is the hard part, so settle it first.

Three pieces that make it work

1. A reverse proxy routes by path

A proxy (Nginx, or a Kubernetes Ingress) sits in front of everything. It reads the URL path, strips the prefix, and forwards the request to the right app. The tenant app sees normal paths like /users, not /app-one/users, so it does not need a basePath. The path is the routing key, which means each path can belong to only one app.

# Simplified: send /app-one/* to the tenant app, minus the prefix
location /app-one/ {
    rewrite ^/app-one/(.*)$ /$1 break;
    proxy_pass http://tenant_a_app;
}

2. Each tenant sets its asset prefix

A page served behind the proxy contains script and style tags. By default they point to /_next/static/... on whatever domain the browser is on, which is the parent's, and the parent does not have the tenant's files. Setting assetPrefix on the tenant tells Next.js to point those tags at the tenant's own address instead, so the browser fetches its JavaScript and CSS from the right place.

// next.config.mjs in a tenant app (simplified)
const isProd = process.env.NODE_ENV === "production";

export default {
  output: "standalone",
  // An origin only (scheme and host), no path.
  // Left undefined locally so hot reload keeps working.
  assetPrefix: isProd ? process.env.ASSET_PREFIX : undefined,
};

3. Environment variables are replaced when the container starts

This one deserves its own section.

The environment-variable trap

The first time we implemented this, our apps were not picking up environment variables at run time. The reason is how Next.js works: NEXT_PUBLIC_ variables are written into the JavaScript at build time, so changing them afterwards has no effect. We wanted one Docker image to serve every environment, so we did two things.

At build time, we pass each variable's own name as its value, so the compiled files contain a recognisable placeholder. Then a small script runs when the container starts and swaps each placeholder for the real value.

#!/bin/sh
# entrypoint.sh (simplified): runs before the app starts
find /app/.next -type f -print0 \
  | xargs -0 sed -i "s#NEXT_PUBLIC_API_URL#$NEXT_PUBLIC_API_URL#g"
exec "$@"   # hand over to: node server.js

Two details cost us time. Every variable needs both a placeholder in the Dockerfile's build step and a replacement line in the script, spelled exactly the same. And the asset prefix is the exception: Next.js checks that it looks like a real URL during the build, so we pass a representative URL there and let the script override it at startup.

Things that go wrong, and where to look

The one that bit us was the environment variables, covered above. These are the others our internal guide tells people to check:

  • The wrong API address, or placeholders left in the files: the startup script is not running, or a placeholder is spelled differently in the Dockerfile and the script. This is the one we hit.
  • A blank or broken page, with 404s for /_next/static/...: the asset prefix is not taking effect. Check inside the container that the placeholder was really replaced.
  • A 404 at /app-one/page but not at /: the proxy is not stripping the prefix.
  • Cross-origin errors on assets: the tenant's domain needs to allow cross-origin requests for its static files.

The hardest part was people

Teams have their own roadmaps and their own sprint dates. When the shared design system arrived, some teams adopted it early and others could not until their next planning cycle, so the interfaces stayed different from one portal to the next for a while.

What fixed it was not a technical change. We worked with the product teams to put the migration into their sprints, so engineers had the time to do it. Architecture can make teams independent, but it cannot make them move at the same speed. Plan for the slower ones.

If I were starting again

  • Agree on shared authentication before anything else.
  • Treat the URL path as the routing key, and keep it unique to one app.
  • Decide who owns what: the tenant team owns the asset prefix and the Dockerfile, DevOps owns the routing.
  • Build one image that works in every environment, instead of one image per environment.
  • Put adoption of shared pieces into each team's sprint, with the product team's agreement.

Working on something similar?

I'm open to contracts, consulting and collaborations.

Get in touch →