Skip to main content
Writing

Making monolithic-first auth work in a split client-server architecture

Why auth systems built for a monolith struggle once the client and server are split, and how a proxy bridges the gap.

  • authentication
  • nextjs
  • architecture
  • backend
  • frontend

If you've ever used a Next.js-first auth library with a decoupled frontend and backend and felt like you were fighting the tooling, this post is for you.

Modern web applications increasingly use decoupled architectures: a separate frontend, a separate backend, independent deployments, and clean API boundaries. This model scales well, enforces ownership, and avoids framework lock-in. Not all tooling is designed with that separation in mind, though.

This post is about monolithic-first authentication systems, which are auth solutions that assume a shared client and server runtime, and the friction they cause in a split architecture. I'll also cover why I ended up using a proxy to make things work without giving up developer experience.

The setup: a cleanly decoupled architecture

The architecture was simple and intentional:

  • Client: a standalone frontend application (Vite or Next.js)
  • Server: a separate backend API (Node.js + Express)
  • Independent deployments
  • A clear HTTP boundary between frontend and backend

This setup is common when:

  • Teams want flexibility
  • The backend needs to serve multiple clients
  • Infrastructure must scale independently

On paper, authentication should fit neatly into this boundary. That's where the friction began.

What "monolithic-first authentication" means

By monolithic-first authentication, I mean auth systems that:

  • Are optimized for frameworks where client and server share the same runtime
  • Assume same-origin requests
  • Rely heavily on framework-level features (cookies, middleware, server components)

Tools like Better-Auth fall into this category. They are designed primarily for frameworks like Next.js, where frontend and backend logic live in the same application. That's a design choice, not a flaw, and inside a monolithic Next.js app it works extremely well.

Why monolithic-first auth breaks down in a split architecture

Features like a unified auth client, automatic cookie handling, and middleware-level session access depend on a shared runtime. In a decoupled setup that shared context disappears, and auth state has to cross an explicit HTTP boundary. Backend frameworks may still be supported, but the developer experience degrades: auth logic fragments, session handling diverges, and the abstractions that made the system valuable start to leak.

The core issue: authClient assumes a shared runtime

The problem comes down to one assumption:

Monolithic-first auth systems assume that the client and server live in the same framework boundary.

authClient assumes:

  • Same-origin cookies
  • Framework-level request context
  • Middleware-level session access

Once you split the app:

  • Those assumptions break
  • Auth stops being "plug and play"
  • You either give up DX or start bending the architecture

The conventional approach: server-only auth integration

The most straightforward way to use monolithic-first authentication in a split setup is to push all authentication logic to the backend.

In this model:

  • The auth service (for example Better Auth) is integrated only on the server
  • Auth routes are exposed as backend APIs (login, logout, session, refresh, etc.)
  • The frontend communicates with these APIs over HTTP
  • The frontend treats authentication as another remote service

This approach is architecturally sound. It preserves the clean client-server boundary, avoids cross-runtime coupling, and works reliably in production. Many teams adopt it by default.

Conventional method

Why this still falls short

It works functionally, but it has a major trade-off: you lose the full value of the auth client the service provides.

Most monolithic-first auth systems ship with a rich client abstraction (authClient) designed to:

  • Automatically manage sessions
  • Handle cookie-based auth seamlessly
  • Expose typed helpers like useSession, signIn, signOut
  • Integrate deeply with framework features

When auth is pushed entirely to the backend:

  • The frontend can no longer use authClient directly
  • You end up re-wrapping auth flows with custom fetch calls
  • Session state must be manually synced and cached
  • Auth logic becomes fragmented between client and server

At that point, the frontend is no longer consuming an auth SDK. It is consuming a set of bespoke APIs.

The core trade-off

So the choice becomes:

  • Architectural purity, with a worse developer experience
  • Great DX, but only inside a monolithic framework boundary

Neither option is ideal when you want a cleanly decoupled architecture and the productivity of a first-class auth client. That tension led me to look for a different approach.

The proxy-based solution: recreating the shared runtime boundary

The way out was to reframe the problem. Instead of forcing the auth system to adapt to a split architecture, I asked:

What if the client could still believe it was talking to a monolithic backend?

The idea is simple:

  • Keep the auth service integrated where it works best (the backend)
  • Add a thin proxy layer that mirrors the auth service's expected routes
  • Forward requests from the client to the backend without breaking assumptions

The proxy does not implement authentication logic. It preserves the contract that monolithic-first auth systems expect.

// src/app/api/auth/[...all]/route.ts

import { NextRequest, NextResponse } from "next/server";

const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL!;

async function proxy(req: NextRequest) {
  const url = new URL(req.url);
  const target = BACKEND_URL + url.pathname + url.search;

  const res = await fetch(target, {
    method: req.method,
    headers: {
      cookie: req.headers.get("cookie") ?? "",
      authorization: req.headers.get("authorization") ?? "",
      "content-type": req.headers.get("content-type") ?? "",
    },
    body: req.body,
    redirect: "manual",
  });

  return new NextResponse(res.body, {
    status: res.status,
    headers: res.headers,
  });
}

export const GET = proxy;
export const POST = proxy;

Security note: this API route is a transparent auth proxy. All request headers, including cookies and authorization, are forwarded as-is because the backend authentication service already enforces security guarantees such as CSRF protection, origin validation, session verification, and secure cookie configuration.

The proxy itself is not a security boundary and does not perform trust checks.

Do not copy this pattern unless your backend authentication layer is properly hardened. Forwarding headers blindly without strong backend security controls can introduce serious vulnerabilities.

What the proxy actually does

At a high level, the proxy:

  • Exposes the same auth endpoints authClient expects
  • Forwards requests to the real backend auth service
  • Preserves headers, cookies, and request context
  • Returns responses untouched

From the client's perspective:

  • authClient works as-is
  • Sessions behave like a monolithic app
  • No custom wrappers or fetch logic are required

From the backend's perspective:

  • Auth remains fully server-controlled
  • No client secrets or logic leak to the frontend
  • The existing auth integration stays intact

Why this works

Monolithic-first auth systems don't actually require a monolith. They require:

  • Stable routes
  • Predictable request/response shapes
  • Consistent cookie behavior
  • A shared mental model of where auth lives

The proxy recreates that without collapsing the architecture. You're not merging client and server. You're simulating the boundary they expect.

The resulting architecture

With the proxy in place:

  • The frontend stays framework-agnostic
  • The backend remains independently deployable
  • authClient retains its full DX benefits
  • No auth logic is duplicated or rewritten

The result is decoupled infrastructure with a monolithic-grade developer experience.

Proxy method

An important constraint

This approach works best when:

  • The auth system is HTTP-based
  • Client-side auth relies on predictable routes
  • Cookie or header-based sessions are used

If the auth system relies on deep framework internals or compile-time hooks, a proxy won't help. For most modern auth providers, though, this pattern works well.

Trade-offs to be aware of

This approach isn't free:

  • You introduce one extra network hop
  • Proxy routes must stay in sync with backend auth routes
  • Debugging auth may involve two layers instead of one

For most teams, this is a reasonable trade-off for preserving DX and architectural clarity.

When this pattern makes sense

This proxy-based approach is not a default recommendation. It is a deliberate architectural compromise.

It makes sense when:

  • You want a fully decoupled frontend-backend architecture
  • Your auth provider offers a rich client SDK optimized for monolithic frameworks
  • Reimplementing auth flows would significantly hurt DX
  • You are willing to own a thin proxy layer as part of your infrastructure

It is likely not worth it when:

  • Your frontend and backend already live in the same framework
  • You are using token-only auth with no client SDK
  • Your team prefers explicit APIs over SDK abstractions
  • The auth system relies on deep framework internals

This pattern exists to resolve a very specific tension, not to replace simpler setups.

Closing thoughts

Monolithic-first authentication systems are not "bad". They are optimized for a different set of assumptions. Problems arise when those assumptions go unexamined and get forced onto architectures they were never designed for.

The proxy works because it respects both sides: the auth system's need for stable, predictable boundaries, and the application's need for decoupling, scalability, and ownership. Rather than bending the architecture to fit the tool, or abandoning the tool to preserve purity, it reframes the boundary itself.

The cleanest solution here wasn't choosing between a monolith and microservices. It was working out what the tooling actually needs and giving it just enough illusion to work, without lying to the architecture.

Have a role in mind?

Send me a message on X with the role and a link to the posting. My resume has the rest.