Skip to content
3 min read Moustakime KIFIA

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/portal for cercly.co
  • apps/operator for operator.cercly.co
  • one operator-api
  • two separate cookies: tenant_session and operator_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_session from operator_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-access on /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.

Keep reading

A few related articles to extend the topic and keep the internal linking strong.

2 min

Building a full stack SaaS as lead tech

A look at the technical choices behind Cercly — from the dev environment to production, across microservices, mobile, and AI-assisted delivery.

  • Architecture
  • Engineering
  • DevOps
  • SaaS
Read the note