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 JWTPOST /api/auth/login-- verify credentials, return JWTgetUserFromRequest(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/bcryptjsSet 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.