One login, two Next.js apps
This site and the chat app at /bmgpt are two separate Next.js projects, deployed independently,
sharing one session. Getting there took less code than I expected and one idea that does all the
work.
The idea
A Vercel multi-zone setup is a rewrite. The browser only ever talks to one origin; requests matching a path prefix are proxied to a second deployment. The second app never sees the browser directly.
That single fact solves the hard part of shared auth. Cookies are scoped to an origin, and there is only one origin, so the session cookie set at the root is automatically present on every request the second app receives.
Why not subdomains
*.vercel.app sits on the public suffix list, so a.vercel.app and b.vercel.app cannot share a
cookie no matter what you set Domain to. Path-based zones sidestep the problem entirely.
Wiring the rewrite
The parent app owns the gate. Middleware verifies the session, and only then rewrites into the child zone — injecting a shared secret so the child can refuse anything that did not come through the front door.
export async function middleware(req: NextRequest) {
const claims = await getClaims(req);
if (!claims) return redirectToLogin(req);
const headers = new Headers(req.headers);
headers.set("x-zone-secret", process.env.ZONE_SHARED_SECRET!);
headers.set("x-user-id", claims.sub);
const destination = new URL(req.nextUrl.pathname, process.env.BMGPT_ZONE_URL);
return NextResponse.rewrite(destination, { request: { headers } });
}The child sets basePath: "/bmgpt" so its own assets resolve under the same prefix. Without that,
/_next/* requests fall through to the parent and you get a blank page with a full set of 404s.
One service that knows about passwords
The users table moved to its own database, reachable only by the parent. The child app keeps its own data and verifies tokens, nothing more.
Verification in the child is deliberately minimal — signature and expiry, no issuer or audience check:
const { payload } = await jwtVerify(token, secretKey);Checking iss made sense when one app both minted and consumed the token. Once minting moved out,
the claim only encoded which service happened to be running, which is not something the child
should have an opinion about.
What it cost
| Piece | Before | After |
|---|---|---|
| Places that hash passwords | 1 | 1 |
| Places that verify tokens | 2 apps | 2 apps |
| Places that can read a password hash | 2 | 1 |
| Login pages | 2 | 1 |
The honest summary is that centralising auth did not reduce the amount of code much. It reduced the number of places that can leak a credential, which is the number that mattered.
two deploys, one front door