DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Authentication in Next.js 15 The Pattern I Use for Every SaaS

I used to reach for NextAuth on every project by default.

Then I built a few dashboards where I needed full control over the token — custom claims, a specific cookie strategy, no extra abstraction layer to fight with. Rolling my own JWT auth turned out simpler than expected, and it's what I use now unless a client specifically wants OAuth providers out of the box.

Here's the exact setup.


1. The Auth Helpers

// lib/auth.ts
import jwt from 'jsonwebtoken';
import { cookies } from 'next/headers';

const JWT_SECRET = process.env.JWT_SECRET as string;
const COOKIE_NAME = 'auth-token';

export interface SessionPayload {
  userId: string;
  email: string;
  role: 'admin' | 'user';
}

export function signToken(payload: SessionPayload): string {
  return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
}

export function verifyToken(token: string): SessionPayload | null {
  try {
    return jwt.verify(token, JWT_SECRET) as SessionPayload;
  } catch {
    return null;
  }
}

export async function setSessionCookie(payload: SessionPayload) {
  const token = signToken(payload);
  const cookieStore = await cookies();

  cookieStore.set(COOKIE_NAME, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: '/',
  });
}

export async function getSession(): Promise<SessionPayload | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get(COOKIE_NAME)?.value;
  if (!token) return null;
  return verifyToken(token);
}

export async function clearSession() {
  const cookieStore = await cookies();
  cookieStore.delete(COOKIE_NAME);
}
Enter fullscreen mode Exit fullscreen mode

httpOnly is non-negotiable — it's what stops the token from being readable by client-side JavaScript, which closes off the most common XSS token-theft path.


2. Login as a Server Action

// actions/auth.ts
'use server';
import { connectDB } from '@/lib/db';
import { User } from '@/models/User';
import { setSessionCookie } from '@/lib/auth';
import bcrypt from 'bcryptjs';
import { redirect } from 'next/navigation';

export async function login(prevState: any, formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  await connectDB();
  const user = await User.findOne({ email }).select('+password');

  if (!user || !(await bcrypt.compare(password, user.password))) {
    return { success: false, message: 'Invalid email or password' };
  }

  await setSessionCookie({
    userId: user._id.toString(),
    email: user.email,
    role: user.role,
  });

  redirect('/dashboard');
}
Enter fullscreen mode Exit fullscreen mode

Notice .select('+password') — the schema should exclude password from queries by default (select: false), and you opt back in only where you actually need to compare it. One less way to accidentally leak a password hash into a response.


3. Protecting Routes with Middleware

Middleware runs before the page renders, so it's the right place to block unauthenticated requests before any server component even starts fetching data:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';

const protectedRoutes = ['/dashboard', '/settings'];
const authRoutes = ['/login', '/signup'];

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  const session = token ? verifyToken(token) : null;
  const { pathname } = request.nextUrl;

  const isProtected = protectedRoutes.some((route) => pathname.startsWith(route));
  const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));

  if (isProtected && !session) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  if (isAuthRoute && session) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/login', '/signup'],
};
Enter fullscreen mode Exit fullscreen mode

The matcher config is what keeps middleware from running on every single request — static assets, images, and unrelated routes never touch it.


4. Reading the Session in Server Components

// app/(dashboard)/layout.tsx
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();

  if (!session) {
    redirect('/login');
  }

  return (
    <div className="flex">
      <Sidebar role={session.role} />
      <main className="flex-1">{children}</main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Middleware handles the redirect for most cases, but checking again in the layout is cheap insurance — it also gives you the session data itself, not just a yes/no on whether one exists.


5. Role-Based Access on Top of Auth

Being logged in and being allowed to see a page aren't the same check. Keep them separate:

// lib/authorize.ts
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';

export async function requireRole(allowedRoles: Array<'admin' | 'user'>) {
  const session = await getSession();

  if (!session) {
    redirect('/login');
  }

  if (!allowedRoles.includes(session.role)) {
    redirect('/dashboard'); // logged in, just not allowed here
  }

  return session;
}
Enter fullscreen mode Exit fullscreen mode
// app/(dashboard)/admin/page.tsx
import { requireRole } from '@/lib/authorize';

export default async function AdminPage() {
  const session = await requireRole(['admin']);

  return <div>Welcome, admin {session.email}</div>;
}
Enter fullscreen mode Exit fullscreen mode

This separates "not logged in" (→ login page) from "logged in but not authorized" (→ somewhere safe) instead of collapsing both into one redirect, which is a common source of confusing UX for users who are authenticated but hit a page above their role.


6. Logging Out

// actions/auth.ts
'use server';
import { clearSession } from '@/lib/auth';
import { redirect } from 'next/navigation';

export async function logout() {
  await clearSession();
  redirect('/login');
}
Enter fullscreen mode Exit fullscreen mode
// components/layout/LogoutButton.tsx
import { logout } from '@/actions/auth';

export function LogoutButton() {
  return (
    <form action={logout}>
      <button type="submit">Log out</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

No client JavaScript needed for something this simple — the form posts straight to the server action.


The Mistakes I See Most

Storing the JWT in localStorage. It's readable by any script on the page, which means any XSS vulnerability becomes a full account takeover. httpOnly cookies aren't perfectly immune to everything, but they close off this specific and very common attack path.

Checking auth only in middleware. Middleware can be bypassed in edge cases (direct server action calls, certain caching scenarios). Always re-check the session in the layout or page itself for anything sensitive.

One giant role check. Don't sprinkle if (user.role === 'admin') across a dozen components. Centralize it in one requireRole helper so the logic only has to be correct once.


Summary

Layer Responsibility
lib/auth.ts Sign, verify, and store the JWT in an httpOnly cookie
middleware.ts Block unauthenticated requests before rendering starts
Layout-level getSession() Second check + gives you the session data itself
requireRole() Separates "not logged in" from "not authorized"
Server Actions Login/logout with no client-side token handling at all

I use this exact auth setup — middleware, httpOnly cookies, role helper — across the dashboards and SaaS templates I build.

See it in a real codebase:

Get the templates: https://pixelanas.gumroad.com

Do you roll your own auth or reach for NextAuth/Clerk by default? Drop it below 👇


Anas — full-stack Next.js developer building SaaS products and premium templates. X: @pixelanas

Top comments (0)