DEV Community

Anas Sheikh
Anas Sheikh

Posted on

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 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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*'],
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)