All articlesNext.js

Two-sided auth in Next.js: role-based routing patterns for SaaS

June 25, 20267 min read

A lot of products have exactly two kinds of users looking at the same app from opposite sides — a coach and their client, a landlord and a tenant, a recruiter and a candidate. They share one sign-in page and one User collection, but everything past login diverges. Getting this right in Next.js means enforcing the split in at least two places, not one.

Here's the pattern I used building Reppod, a coaching platform where trainers build workouts and clients follow them, on Next.js 16 and Auth.js v5.

One user model, one field that branches everything

Resist the urge to create separate Trainer and Client collections. It duplicates auth logic and makes the eventual "what if a trainer is also someone else's client" case painful. Instead, one User document carries a role field, and routing branches on it:

const userSchema = new Schema({
  email: { type: String, required: true, unique: true },
  passwordHash: String,
  role: { type: String, enum: ["trainer", "client"], required: true },
});

/dashboard is the trainer's home. /app is the client's. The wrong role hitting either path gets redirected, not shown an error page — a client who fat-fingers /dashboard should land back on their own home, not a 403.

Edge-safe middleware first, server components second

Next.js 16 renamed middleware to proxy.ts, but the principle is unchanged: it runs on the edge, before a page ever renders, so it's the cheapest place to block a request. The catch is that anything edge-safe can't touch your database driver directly — so the auth config gets split:

// auth.config.ts — edge-safe, no database access
export const authConfig = {
  callbacks: {
    authorized({ auth, request }) {
      const isTrainerRoute = request.nextUrl.pathname.startsWith("/dashboard");
      const isClientRoute = request.nextUrl.pathname.startsWith("/app");
      if (isTrainerRoute) return auth?.user?.role === "trainer";
      if (isClientRoute) return auth?.user?.role === "client";
      return true;
    },
  },
} satisfies NextAuthConfig;
// auth.ts — full config, only imported in Node runtime code
export const { auth, handlers } = NextAuth({
  ...authConfig,
  providers: [Credentials({ /* bcrypt + Mongoose lookup here */ })],
});

The middleware imports only auth.config.ts. The API routes and server components import the full auth.ts. That split is what lets the same logical auth system run both at the edge and in Node without bundling a database driver into a runtime that can't support it.

Defense in depth: check again in server components

Middleware is fast but not infallible — cache edge cases, redirects, and route groups can create gaps. Every server component under /dashboard and /app calls a small helper that re-checks the role server-side before rendering anything sensitive:

export async function requireTrainer() {
  const session = await auth();
  if (session?.user?.role !== "trainer") redirect("/login");
  return session.user;
}

It's a few extra lines per page, but it means a bug in the middleware config degrades to "redirected to login," not "client sees another trainer's dashboard." For anything involving another person's data, that's worth the redundancy.

The invite flow ties the two sides together

Two-sided products usually need one side to onboard the other. A trainer adds a client by email; the system generates an invite token; the client opens a link and sets their own password — at which point their User record gets created and linked back to the Client record the trainer already made:

Trainer adds client → inviteToken generated → link copied/sent
                                                      ↓
                                    Client opens link, sets password
                                                      ↓
                        User created (role: client), Client.user linked

The token is generated on demand and stored on the Client document, not the User document — the client doesn't exist as a user until they accept. This avoids half-created accounts sitting in your database from invites that never get opened.

The pattern generalizes

Swap "trainer/client" for "landlord/tenant" or "recruiter/candidate" and the same three pieces hold: one user model with a role field, edge middleware for cheap routing decisions, and a server-side re-check for anything that touches another person's data. The invite flow is optional — plenty of two-sided products have both sides sign up independently — but when one side needs to bring the other in, generating the token before the account exists keeps the data model honest about who's actually real yet.


Farhan Shafi
Farhan Shafi
Full-Stack Developer