Claude Code Boilerplate
FeaturesPricingDocsBlog
Get started →

Product

  • Features
  • Pricing
  • Skills
  • Roadmap

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Refund Policy
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

JWT auth in Next.js App Router without NextAuth

May 18, 2026
authnextjsjwt

JWT auth in Next.js App Router without NextAuth

NextAuth (now Auth.js) is the default recommendation for Next.js authentication, but it comes with significant complexity: database adapters, provider configuration, session callbacks, and a rigid mental model that fights you when your requirements diverge from its defaults.

If you control your own database and just need email/password auth with JWTs, you do not need NextAuth at all. This guide shows the full implementation -- about 150 lines of code across four files.

What we are building

  • POST /api/auth/register -- create account, return JWT
  • POST /api/auth/login -- verify credentials, return JWT
  • getUserFromRequest(req) -- extract and verify JWT in any route
  • Middleware that blocks unauthenticated requests to protected paths
  • Client-side token storage and auth state via a custom hook

Dependencies

npm install jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs

Set your secret in .env:

JWT_SECRET=your-secret-here

The auth utility

Create lib/auth.ts. This is the only place that touches JWT -- everything else imports from here.

import jwt, { JwtPayload } from "jsonwebtoken";
import { HttpError } from "./errors";
 
const JWT_SECRET = process.env.JWT_SECRET!;
 
export function signToken(userId: string): string {
  return jwt.sign({ userId }, JWT_SECRET, { expiresIn: "7d" });
}
 
export function getUserFromRequest(req: Request) {
  const authorization = req.headers.get("Authorization");
  if (!authorization?.startsWith("Bearer "))
    throw new HttpError(401, "Unauthorized");
 
  const token = authorization.slice(7);
  try {
    const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
    return { id: payload.userId as string };
  } catch {
    throw new HttpError(401, "Invalid token");
  }
}

HttpError is a simple class that carries an HTTP status code. handleError in your route catches it and sends the right response.

// lib/errors/index.ts
export class HttpError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}
 
export function handleError(error: unknown): Response {
  if (error instanceof HttpError)
    return Response.json({ error: error.message }, { status: error.status });
  console.error(error);
  return Response.json({ error: "Internal server error" }, { status: 500 });
}

Register route

// app/api/auth/register/route.ts
import { z } from "zod";
import bcrypt from "bcryptjs";
import { db } from "@/db/drizzle";
import { userTable } from "@/db/schema";
import { signToken } from "@/lib/auth";
import { handleError } from "@/lib/errors";
 
const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().min(1),
});
 
export async function POST(req: Request) {
  try {
    const body = await req.json();
    const parsed = schema.safeParse(body);
    if (!parsed.success)
      return Response.json({ error: parsed.error.flatten() }, { status: 400 });
 
    const { email, password, name } = parsed.data;
 
    const existing = await db.query.userTable.findFirst({
      where: (u, { eq }) => eq(u.email, email),
    });
    if (existing)
      return Response.json({ error: "Email already in use" }, { status: 409 });
 
    const passwordHash = await bcrypt.hash(password, 12);
    const [user] = await db
      .insert(userTable)
      .values({ email, name, passwordHash })
      .returning();
 
    const token = signToken(user.id);
    const { passwordHash: _, ...safeUser } = user;
    return Response.json({ user: safeUser, token }, { status: 201 });
  } catch (error: unknown) {
    return handleError(error);
  }
}

Key points:

  • Always hash with bcrypt, cost factor 12 minimum
  • Never return passwordHash -- destructure it out before responding
  • Return the token in the body so the client can store it immediately

Login route

// app/api/auth/login/route.ts
import { z } from "zod";
import bcrypt from "bcryptjs";
import { db } from "@/db/drizzle";
import { userTable } from "@/db/schema";
import { signToken } from "@/lib/auth";
import { handleError } from "@/lib/errors";
import { HttpError } from "@/lib/errors";
 
const schema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});
 
export async function POST(req: Request) {
  try {
    const body = await req.json();
    const parsed = schema.safeParse(body);
    if (!parsed.success)
      return Response.json({ error: parsed.error.flatten() }, { status: 400 });
 
    const { email, password } = parsed.data;
 
    const user = await db.query.userTable.findFirst({
      where: (u, { eq }) => eq(u.email, email),
    });
 
    const valid = user && (await bcrypt.compare(password, user.passwordHash));
    if (!valid) throw new HttpError(401, "Invalid credentials");
 
    const token = signToken(user.id);
    const { passwordHash: _, ...safeUser } = user;
    return Response.json({ user: safeUser, token });
  } catch (error: unknown) {
    return handleError(error);
  }
}

Note that we check user && bcrypt.compare(...) together. This avoids a timing difference that could leak whether an email exists.

Protecting routes

In any route that requires authentication, call getUserFromRequest at the top:

import { getUserFromRequest } from "@/lib/auth";
import { handleError } from "@/lib/errors";
 
export async function GET(req: Request) {
  try {
    const { id } = getUserFromRequest(req); // throws 401 if missing/invalid
    // use id to fetch user-specific data
    return Response.json({ id });
  } catch (error: unknown) {
    return handleError(error);
  }
}

Middleware for page-level protection

To block unauthenticated access to dashboard pages, use Next.js middleware:

// middleware.ts (repo root)
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
 
const PROTECTED = ["/dashboard", "/settings", "/profile"];
 
export function middleware(req: NextRequest) {
  const isProtected = PROTECTED.some((path) =>
    req.nextUrl.pathname.startsWith(path)
  );
  if (!isProtected) return NextResponse.next();
 
  const token = req.cookies.get("token")?.value;
  if (!token) return NextResponse.redirect(new URL("/login", req.url));
 
  try {
    jwt.verify(token, process.env.JWT_SECRET!);
    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL("/login", req.url));
  }
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*", "/profile/:path*"],
};

This reads the token from a cookie. On login, set the cookie alongside returning the token in the body:

// In the login route, after generating the token:
const response = Response.json({ user: safeUser, token });
response.headers.set(
  "Set-Cookie",
  `token=${token}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`
);
return response;

Client-side auth state

Store the token in memory (not localStorage, which is XSS-accessible) and expose it via a hook:

// hooks/useAuth.ts
"use client";
import { useState, useCallback } from "react";
 
let _token: string | null = null;
 
export function useAuth() {
  const [token, setTokenState] = useState<string | null>(_token);
 
  const setToken = useCallback((t: string | null) => {
    _token = t;
    setTokenState(t);
  }, []);
 
  const clearToken = useCallback(() => {
    _token = null;
    setTokenState(null);
  }, []);
 
  return { token, setToken, clearToken, isAuthed: !!token };
}
 
export function getToken() {
  return _token;
}

On login success:

const { setToken } = useAuth();
// after successful POST /api/auth/login:
setToken(data.token);

On API calls, attach the token as a header:

fetch("/api/some-protected-route", {
  headers: { Authorization: `Bearer ${getToken()}` },
});

Takeaway

JWT auth in Next.js App Router requires four pieces: a sign/verify utility, a register route, a login route, and middleware for page protection. No dependencies beyond jsonwebtoken and bcryptjs. The entire implementation is under 200 lines and you own every part of it -- no magic, no adapters, no breaking changes when Auth.js releases a new major version.