Authentication in Next.js
Introduction to Authentication
Authentication is a critical feature of modern web applications. Next.js provides flexible options for implementing authentication, from simple JWT-based solutions to comprehensive providers like NextAuth.js (now Auth.js). This lesson covers complete authentication implementation.
Understanding Authentication Strategies
Before implementing authentication, understand the different strategies available:
- Session-based: Server stores session data, sends session ID to client via cookie
- Token-based (JWT): Client stores token containing user data, sends with each request
- OAuth/Social Login: Delegate authentication to third-party providers (Google, GitHub, etc.)
- Passwordless: Email magic links or SMS codes instead of passwords
Installing and Configuring Auth.js
Auth.js is the most popular authentication solution for Next.js, providing built-in support for many providers and strategies.
# Install Auth.js npm install next-auth@beta # Install additional providers if needed npm install @auth/prisma-adapter npm install bcryptjs npm install @types/bcryptjs -D
<!-- lib/auth.ts -->
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: {
strategy: 'jwt',
},
pages: {
signIn: '/login',
error: '/auth/error',
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error('Invalid credentials')
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email as string
}
})
if (!user || !user?.hashedPassword) {
throw new Error('Invalid credentials')
}
const isCorrectPassword = await bcrypt.compare(
credentials.password as string,
user.hashedPassword
)
if (!isCorrectPassword) {
throw new Error('Invalid credentials')
}
return user
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = user.role
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
}
}
})
<!-- app/api/auth/[...nextauth]/route.ts -->
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers
Environment Configuration
Create your .env file with the necessary authentication credentials:
# .env NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=your-super-secret-key-here # OAuth Providers GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret # Database DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
openssl rand -base64 32. Never use a simple string in production.
Creating Auth Providers
To make authentication available throughout your app, create a session provider:
<!-- app/providers/session-provider.tsx -->
'use client'
import { SessionProvider } from 'next-auth/react'
export function AuthProvider({
children,
}: {
children: React.ReactNode
}) {
return <SessionProvider>{children}</SessionProvider>
}
<!-- app/layout.tsx -->
import { AuthProvider } from './providers/session-provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
)
}
Building Login Components
Create a comprehensive login page with multiple authentication options:
<!-- app/login/page.tsx -->
import { LoginForm } from './login-form'
import { SocialLogins } from './social-logins'
export default function LoginPage() {
return (
<div className="max-w-md mx-auto mt-8 p-6">
<h1 className="text-2xl font-bold mb-6">Sign In</h1>
<SocialLogins />
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">
Or continue with email
</span>
</div>
</div>
<LoginForm />
</div>
)
}
<!-- app/login/social-logins.tsx -->
'use client'
import { signIn } from 'next-auth/react'
import { useState } from 'react'
export function SocialLogins() {
const [isLoading, setIsLoading] = useState<string | null>(null)
const handleSocialLogin = async (provider: string) => {
try {
setIsLoading(provider)
await signIn(provider, { callbackUrl: '/dashboard' })
} catch (error) {
console.error(`${provider} sign in error:`, error)
} finally {
setIsLoading(null)
}
}
return (
<div className="space-y-3">
<button
onClick={() => handleSocialLogin('google')}
disabled={isLoading === 'google'}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
<GoogleIcon />
{isLoading === 'google' ? 'Loading...' : 'Continue with Google'}
</button>
<button
onClick={() => handleSocialLogin('github')}
disabled={isLoading === 'github'}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
<GitHubIcon />
{isLoading === 'github' ? 'Loading...' : 'Continue with GitHub'}
</button>
</div>
)
}
<!-- app/login/login-form.tsx -->
'use client'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export function LoginForm() {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setError(null)
const formData = new FormData(e.currentTarget)
const email = formData.get('email') as string
const password = formData.get('password') as string
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
if (result?.error) {
setError('Invalid email or password')
return
}
router.push('/dashboard')
router.refresh()
} catch (error) {
setError('Something went wrong. Please try again.')
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-lg">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
)
}
Registration API Route
Create an API route to handle user registration:
<!-- app/api/register/route.ts -->
import { NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
export async function POST(request: Request) {
try {
const { name, email, password } = await request.json()
// Validation
if (!name || !email || !password) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
)
}
if (password.length < 8) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters' },
{ status: 400 }
)
}
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
)
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12)
// Create user
const user = await prisma.user.create({
data: {
name,
email,
hashedPassword,
}
})
return NextResponse.json(
{ message: 'User created successfully', userId: user.id },
{ status: 201 }
)
} catch (error) {
console.error('Registration error:', error)
return NextResponse.json(
{ error: 'Something went wrong' },
{ status: 500 }
)
}
}
Protecting Routes with Middleware
Use Next.js middleware to protect routes and redirect unauthenticated users:
<!-- middleware.ts -->
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard')
const isOnAdminPanel = req.nextUrl.pathname.startsWith('/admin')
const isOnAuthPage = req.nextUrl.pathname.startsWith('/login') ||
req.nextUrl.pathname.startsWith('/register')
// Redirect to login if accessing protected routes while not logged in
if ((isOnDashboard || isOnAdminPanel) && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.url))
}
// Redirect to dashboard if accessing auth pages while logged in
if (isOnAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard', req.url))
}
// Check admin role for admin routes
if (isOnAdminPanel && req.auth?.user?.role !== 'admin') {
return NextResponse.redirect(new URL('/dashboard', req.url))
}
return NextResponse.next()
})
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/login', '/register']
}
Server-Side Session Access
Access session data in Server Components and API routes:
<!-- app/dashboard/page.tsx (Server Component) -->
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<div>
<h1>Welcome, {session.user?.name}</h1>
<p>Email: {session.user?.email}</p>
</div>
)
}
<!-- app/api/user/profile/route.ts -->
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
}
})
return NextResponse.json({ user })
}
Client-Side Session Access
Use the useSession hook in Client Components:
<!-- app/components/user-menu.tsx -->
'use client'
import { useSession, signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
export function UserMenu() {
const { data: session, status } = useSession()
const router = useRouter()
if (status === 'loading') {
return <div>Loading...</div>
}
if (!session) {
return (
<button onClick={() => router.push('/login')}>
Sign In
</button>
)
}
return (
<div className="flex items-center gap-4">
<img
src={session.user?.image || '/default-avatar.png'}
alt={session.user?.name || 'User'}
className="w-8 h-8 rounded-full"
/>
<span>{session.user?.name}</span>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="text-red-600 hover:text-red-700"
>
Sign Out
</button>
</div>
)
}
Role-Based Access Control
Implement role-based authorization for fine-grained access control:
<!-- lib/auth-utils.ts -->
import { auth } from './auth'
import { redirect } from 'next/navigation'
export async function requireAuth() {
const session = await auth()
if (!session) {
redirect('/login')
}
return session
}
export async function requireAdmin() {
const session = await requireAuth()
if (session.user?.role !== 'admin') {
redirect('/dashboard')
}
return session
}
<!-- app/admin/page.tsx -->
import { requireAdmin } from '@/lib/auth-utils'
export default async function AdminPage() {
const session = await requireAdmin()
return (
<div>
<h1>Admin Dashboard</h1>
<p>Welcome, {session.user?.name}</p>
</div>
)
}
- Set up Auth.js with at least two providers (Google and credentials)
- Create registration and login pages with proper validation
- Implement middleware to protect dashboard routes
- Add role-based access control for admin routes
- Create a user profile page where users can update their information
- Implement password reset functionality using email magic links
Session Management Best Practices
- Always use HTTPS in production to protect session cookies
- Set appropriate cookie options (secure, httpOnly, sameSite)
- Implement session timeout and refresh mechanisms
- Store minimal data in JWTs to keep them small
- Use database sessions for sensitive applications requiring instant revocation
- Always hash passwords with bcrypt (cost factor 12+)
- Never log or expose sensitive authentication data
Summary
Authentication in Next.js is streamlined with Auth.js, providing OAuth, credentials, and passwordless authentication out of the box. Combine server-side protection with middleware, use sessions appropriately in both Server and Client Components, and implement role-based access control for secure, production-ready applications.