Building Authentication in Next.js Using Context API - Step by Step
Authentication is one of those things that sounds simple but gets messy fast.
You need to check if a user is logged in. Protect certain routes. Show/hide UI elements. Handle tokens. Refresh sessions. Redirect after login.
And you need to do all this across your entire app without passing props through every component.
That's where Context API comes in.
Let me show you how to build a clean, production-ready auth system in Next.js using Context API.
What We're Building
By the end of this guide, you'll have:
- Login/logout functionality
- Protected routes
- User state across the app
- Automatic session checks
- Loading states
- Redirect handling
All with clean, maintainable code.
Step 1: Create the Auth Context
First, let's set up the context structure.
// contexts/AuthContext.tsx
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { useRouter } from 'next/navigation'
interface User {
id: string
email: string
name: string
}
interface AuthContextType {
user: User | null
loading: boolean
login: (email: string, password: string) => Promise
logout: () => Promise
isAuthenticated: boolean
}
const AuthContext = createContext(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const router = useRouter()
// Check if user is logged in on mount
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/me')
if (response.ok) {
const userData = await response.json()
setUser(userData)
} else {
setUser(null)
}
} catch (error) {
console.error('Auth check failed:', error)
setUser(null)
} finally {
setLoading(false)
}
}
const login = async (email: string, password: string) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Login failed')
}
const userData = await response.json()
setUser(userData)
router.push('/dashboard')
} catch (error) {
throw error
}
}
const logout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' })
setUser(null)
router.push('/login')
} catch (error) {
console.error('Logout failed:', error)
}
}
const value = {
user,
loading,
login,
logout,
isAuthenticated: !!user,
}
return (
{children}
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
This sets up everything we need:
- User state
- Loading state (for initial check)
- Login/logout functions
- Auth check on mount
Step 2: Wrap Your App
Add the provider to your root layout.
// app/layout.tsx
import { AuthProvider } from '@/contexts/AuthContext'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
{children}
)
}
Now any component can access auth state.
Step 3: Create the Login Page
// app/login/page.tsx
'use client'
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
} catch (err: any) {
setError(err.message || 'Login failed')
} finally {
setLoading(false)
}
}
return (
Login
{error && (
{error}
)}
)
}
Clean. Simple. User-friendly.
Step 4: Protecting Routes in Next.js
Create a component to protect routes from non-authenticated users.
// components/ProtectedRoute.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/contexts/AuthContext'
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth()
const router = useRouter()
useEffect(() => {
if (!loading && !isAuthenticated) {
router.push('/login')
}
}, [isAuthenticated, loading, router])
if (loading) {
return (
Loading...
)
}
if (!isAuthenticated) {
return null
}
return <>{children}>
}
Use it to wrap protected pages:
// app/dashboard/page.tsx
'use client'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { useAuth } from '@/contexts/AuthContext'
export default function DashboardPage() {
return (
)
}
function Dashboard() {
const { user, logout } = useAuth()
return (
Dashboard
Welcome, {user?.name}!
{user?.email}
)
}
Step 5: API Routes for Authentication
Create the backend API routes.
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
// In production, validate against your database
// This is a simplified example
if (email === 'demo@example.com' && password === 'password') {
const user = {
id: '1',
email: 'demo@example.com',
name: 'Demo User',
}
// Set auth cookie (in production, use JWT or session token)
cookies().set('auth-token', 'demo-token-123', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
})
return NextResponse.json(user)
}
return NextResponse.json(
{ message: 'Invalid credentials' },
{ status: 401 }
)
} catch (error) {
return NextResponse.json(
{ message: 'Login failed' },
{ status: 500 }
)
}
}
// app/api/auth/logout/route.ts
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function POST() {
cookies().delete('auth-token')
return NextResponse.json({ success: true })
}
// app/api/auth/me/route.ts
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function GET() {
const token = cookies().get('auth-token')
if (!token) {
return NextResponse.json(
{ message: 'Not authenticated' },
{ status: 401 }
)
}
// In production, validate token and fetch user from database
const user = {
id: '1',
email: 'demo@example.com',
name: 'Demo User',
}
return NextResponse.json(user)
}
Step 6: Conditional UI Elements
Show/hide elements based on auth state.
// components/Header.tsx
'use client'
import Link from 'next/link'
import { useAuth } from '@/contexts/AuthContext'
export function Header() {
const { user, isAuthenticated, logout } = useAuth()
return (
MyApp
)
}
Best Practices & Tips
1. Handle Token Refresh
For long-lived sessions, implement token refresh:
useEffect(() => {
const interval = setInterval(async () => {
if (isAuthenticated) {
await checkAuth() // Refresh user data
}
}, 5 * 60 * 1000) // Every 5 minutes
return () => clearInterval(interval)
}, [isAuthenticated])
2. Persist Login State
The cookie-based approach we used persists across page refreshes automatically.
3. Loading States Matter
Always show loading states. Users shouldn't see protected content flash before redirect.
4. Error Handling
Always catch and display auth errors properly. Users need to know what went wrong.
5. Secure Your Cookies
cookies().set('auth-token', token, {
httpOnly: true, // Can't be accessed by JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24, // 1 day
})
Common Mistakes to Avoid
Mistake 1: Not Handling Loading State
Without proper loading states, users see flashes of wrong content.
Mistake 2: Client-Side Only Auth Checks
Always verify auth on the server too. Client checks are for UX, not security.
Mistake 3: Storing Sensitive Data in Context
Don't store tokens in Context. Use httpOnly cookies.
Mistake 4: Not Redirecting After Logout
Always redirect users to a public page after logout.
The Complete Flow
1. User lands on site → AuthProvider checks auth status 2. If not authenticated → Redirect to login 3. User logs in → Set cookie, update context, redirect to dashboard 4. User navigates → Auth state available everywhere 5. User logs out → Clear cookie, update context, redirect to home
Real Production Additions
In a real app, you'd also want:
- Password reset flow
- Email verification
- OAuth integration (Google, GitHub, etc.)
- Role-based permissions
- Session expiry warnings
- Concurrent session handling
But this foundation handles 80% of auth needs cleanly.
The Bottom Line
Context API for auth in Next.js works great when you:
- Keep auth logic in one place
- Handle loading states properly
- Use server-side validation
- Secure your cookies
This pattern scales well for most applications. Clean, maintainable, and production-ready.
---
Want to see how this fits into the bigger picture? Check out my guide on when to use Context API in Next.js vs other state management solutions.Want articles like this in your inbox?
Join developers and founders who get practical insights on frontend, SaaS, and building better products.
Written by Salman Izhar
Frontend Developer specializing in React, Next.js, and building high-converting web applications.
Learn More