Next.js

Authentication in Next.js

30 min Lesson 17 of 40

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.

Important: NextAuth.js has been renamed to Auth.js. The package name is now @auth/nextjs-auth, but the functionality remains the same. We'll use the term "Auth.js" throughout this lesson.

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
Security Tip: Always store sensitive credentials like client IDs and secrets in environment variables, never commit them to version control.

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"
Important: Generate a secure NEXTAUTH_SECRET using: 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']
}
Middleware Benefits: Runs at the edge before the request is processed, providing fast authentication checks without hitting your database or API routes.

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>
  )
}
Practice Exercise:
  1. Set up Auth.js with at least two providers (Google and credentials)
  2. Create registration and login pages with proper validation
  3. Implement middleware to protect dashboard routes
  4. Add role-based access control for admin routes
  5. Create a user profile page where users can update their information
  6. 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.