Build Authentication in Next.js 15 From Scratch — No Libraries
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 jsonwebtok
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
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.