Two Next.js apps on two domains, one shared API
Why we split the customer portal and the operator console into two Next.js apps, and what that separation removed from the codebase.
- Architecture
- Multi-tenant
- Auth
This is not really a story about splitting a repo. It is a story about the point where two product experiences stop being one frontend.
We started with one Next.js app that looked at the hostname to decide whether it should behave like a customer portal or an operator console.
That sounds efficient at first:
- one app
- one pipeline
- one shared layout
- a few host-based conditions
It works for a few days. Then if (host === ...) starts leaking into middleware, layouts, navigation, API calls and tests.
The real question was not “how do we support multiple domains in Next.js?”. The real question was: where do we want complexity to live.
Chosen solution
The final architecture is easy to describe:
apps/portalforcercly.coapps/operatorforoperator.cercly.co- one
operator-api - two separate cookies:
tenant_sessionandoperator_session
The real subject is not multi-domain architecture by itself. The real subject is where complexity lives.
With one app, complexity diffuses everywhere. With two apps, it moves back to the boundary between clients and the API.
Why this fits this kind of platform
This shape works well because it:
- isolates
tenant_sessionfromoperator_session - separates the end-user portal from the internal console
- allows independent deployment cycles
- makes middleware and permission logic easier to read
- keeps layouts predictable instead of host-driven
In other words, we did not duplicate one product. We gave each experience its own proper boundary.
How we implement it
The most useful code excerpts are not large diffs. They are the points where the split becomes visible:
- the portal middleware that reads the cookie and injects
x-cercly-tenant-accesson/api/* - the Traefik labels routing both domains to the right frontends
The goal is not to show lots of code. The goal is to show where complexity was moved.
Once the split exists, even simple logic becomes more honest:
if (!tenantSessionCookie) {
return NextResponse.redirect(new URL("/auth/login", request.url));
}
The important part is not that line itself. It is the fact that it no longer needs to ask whether it is currently acting as a portal or an operator console.
What the split removed from the codebase
The most underrated gain is negative: what we no longer needed.
We removed:
- host-driven branching
- layouts changing role depending on the domain
- cookies with different meanings depending on context
- tests that had to simulate two products inside one app
A healthy architecture often shows up in the amount of logic you no longer have to write.
What I learned while implementing it
The trap is simple: we thought one app would save time. In practice, it bought us debt that leaked across the whole codebase.
The key sentence here is this: splitting into two apps cost one weekend, not one month.
I would rather pay for a clean separation once than spend months carrying a frontend monolith that no longer matches the product reality.
When not to do this
This should not become a rule to apply everywhere.
If both experiences truly share:
- the same navigation
- the same permissions
- the same release pace
- the same session model
then one app may still be the right answer.
Here, that was no longer true. Once that was clear, the split became the most readable solution.