Two-sided auth in Next.js: role-based routing for SaaS
Contents6
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.
This is the pattern I used to build 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 mistypes /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 is 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 foolproof. 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 that point their User record is 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, because 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, since plenty of two-sided products have both sides sign up on their own. But when one side needs to bring the other in, generating the token before the account exists keeps the data model honest about which accounts exist yet.
Continue exploring
Read why Next.js middleware is not a security boundary, then compare this role model with multi-tenant SaaS membership. The working implementation is Reppod, with the code public on GitHub.