Everyone reaches for NextAuth immediately.
But if you want to understand how authentication actually works — build it yourself first.
Here's complete JWT authentication in Next.js 15 with no libraries except jsonwebtoken and bcryptjs.
What We're Building
- User registration with hashed passwords
- Login with JWT token
- Protected API routes
- Middleware to protect pages
- Token refresh
1. Install Dependencies
npm install jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs
That's it. No NextAuth. No Auth.js. Just two battle-tested libraries.
2. Environment Variables
# .env.local
JWT_SECRET=your-super-secret-key-minimum-32-characters
MONGODB_URI=your-mongodb-connection-string
3. Auth Utilities
// lib/auth.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
const JWT_SECRET = process.env.JWT_SECRET!;
// Hash password before saving
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
// Compare password with hash
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// Generate JWT token
export function generateToken(userId: string): string {
return jwt.sign(
{ userId },
JWT_SECRET,
{ expiresIn: '7d' }
);
}
// Verify JWT token
export function verifyToken(token: string): { userId: string } | null {
try {
return jwt.verify(token, JWT_SECRET) as { userId: string };
} catch {
return null;
}
}
4. User Model
// models/User.ts
import mongoose, { Document, Model } from 'mongoose';
export interface IUser extends Document {
name: string;
email: string;
password: string;
createdAt: Date;
}
const UserSchema = new mongoose.Schema<IUser>(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true, minlength: 8 },
},
{ timestamps: true }
);
const User: Model<IUser> =
mongoose.models.User || mongoose.model<IUser>('User', UserSchema);
export default User;
5. Register API Route
// app/api/auth/register/route.ts
import { NextRequest } from 'next/server';
import { connectDB } from '@/lib/db';
import { hashPassword, generateToken } from '@/lib/auth';
import User from '@/models/User';
import { z } from 'zod';
const RegisterSchema = z.object({
name: z.string().min(2, 'Name too short'),
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be 8+ characters'),
});
export async function POST(request: NextRequest) {
try {
await connectDB();
const body = await request.json();
const result = RegisterSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: result.error.errors[0].message },
{ status: 400 }
);
}
const { name, email, password } = result.data;
// Check if user exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return Response.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
// Hash password and create user
const hashedPassword = await hashPassword(password);
const user = await User.create({ name, email, password: hashedPassword });
// Generate token
const token = generateToken(user._id.toString());
return Response.json(
{
success: true,
token,
user: { id: user._id, name: user.name, email: user.email },
},
{ status: 201 }
);
} catch (error) {
return Response.json({ error: 'Server error' }, { status: 500 });
}
}
6. Login API Route
// app/api/auth/login/route.ts
import { NextRequest } from 'next/server';
import { connectDB } from '@/lib/db';
import { verifyPassword, generateToken } from '@/lib/auth';
import User from '@/models/User';
export async function POST(request: NextRequest) {
try {
await connectDB();
const { email, password } = await request.json();
if (!email || !password) {
return Response.json(
{ error: 'Email and password required' },
{ status: 400 }
);
}
// Find user — include password field (excluded by default)
const user = await User.findOne({ email }).select('+password');
if (!user) {
return Response.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Verify password
const isValid = await verifyPassword(password, user.password);
if (!isValid) {
return Response.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
const token = generateToken(user._id.toString());
return Response.json({
success: true,
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (error) {
return Response.json({ error: 'Server error' }, { status: 500 });
}
}
Note: always return the same error message for wrong email OR wrong password. Never tell the user which one is wrong.
7. Protected API Route
// app/api/profile/route.ts
import { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';
import { connectDB } from '@/lib/db';
import User from '@/models/User';
export async function GET(request: NextRequest) {
// Get token from header
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
if (!token) {
return Response.json({ error: 'No token provided' }, { status: 401 });
}
// Verify token
const payload = verifyToken(token);
if (!payload) {
return Response.json({ error: 'Invalid token' }, { status: 401 });
}
try {
await connectDB();
const user = await User.findById(payload.userId).select('-password');
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}
return Response.json({ success: true, user });
} catch (error) {
return Response.json({ error: 'Server error' }, { status: 500 });
}
}
8. Middleware for Protected Pages
// middleware.ts (root of project)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';
// Pages that require authentication
const protectedRoutes = ['/dashboard', '/settings', '/profile'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtected = protectedRoutes.some(route =>
pathname.startsWith(route)
);
if (!isProtected) return NextResponse.next();
// Get token from cookie
const token = request.cookies.get('auth-token')?.value;
if (!token || !verifyToken(token)) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*'],
};
Store the token in a cookie (not localStorage) so middleware can access it:
// After successful login — set cookie
const response = NextResponse.json({ success: true });
response.cookies.set('auth-token', token, {
httpOnly: true, // JavaScript can't access it
secure: true, // HTTPS only
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return response;
Summary
| Step | File |
|---|---|
| Utilities | lib/auth.ts |
| User model | models/User.ts |
| Register | app/api/auth/register/route.ts |
| Login | app/api/auth/login/route.ts |
| Protected route | Check token in handler |
| Protected pages | middleware.ts |
Once you build this yourself you'll understand exactly what NextAuth does under the hood — and you'll know when to use it vs build your own.
I use these patterns building production Next.js SaaS apps.
My templates: https://pixelanas.gumroad.com
Questions? Drop them below 👇
Anas — full-stack Next.js developer. X: @pixelanas
Top comments (0)