Building Authentication in Next.js Using Context API - Step by Step
Next.jsAuthenticationContext API

Building Authentication in Next.js Using Context API - Step by Step

January 25, 2025
12 min read
Salman Izhar

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}
)}
setEmail(e.target.value)} className="w-full px-3 py-2 border rounded" required />
setPassword(e.target.value)} className="w-full px-3 py-2 border rounded" required />
) }

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.
Get More Like This

Want articles like this in your inbox?

Join developers and founders who get practical insights on frontend, SaaS, and building better products.

S

Written by Salman Izhar

Frontend Developer specializing in React, Next.js, and building high-converting web applications.

Learn More