DEV Community

Aaron K Saunders
Aaron K Saunders

Posted on

Authentication with Payload CMS and Next.js: Client vs Server Approaches

In this post, I'll explain how to we chose to implement authentication with Payload CMS in a Next.js application, and why we chose a client-side approach over server actions.

Video Tutorial with Integrated Authentication Solution

The Challenge with Server Actions

Initially, we tried implementing authentication using Next.js server actions. Here's what that looked like:

// src/lib/actions.ts
'use server'

import { getPayloadClient } from './getPayloadClient'
import { cookies } from 'next/headers'

export async function loginAction(email: string, password: string) {
  try {
    const payload = await getPayloadClient()
    const result = await payload.login({
      collection: 'users',
      data: { email, password },
    })

    // Manually setting cookies on the server
    const cookieStore = cookies()
    cookieStore.set('payload-token', result.token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/',
    })

    return { success: true, data: result }
  } catch (err) {
    return { error: 'Invalid email or password ' + (err as Error).message }
  }
}
Enter fullscreen mode Exit fullscreen mode

We chose this approach because we knew that the local API would appropriately set the cookies when used, then we realized it would not work with server to server and tried to manage the cookies myself. It quickly became obvious we needed a better solution

However, we encountered several issues:

  1. Cookie handling was problematic in server-to-server requests
  2. Server actions couldn't properly handle response headers
  3. Payload's REST API is optimized for client-side requests

The Better Solution: Client-Side Authentication

We moved to a client-side approach that works much more reliably. Here's our implementation:

// src/app/(app)/login/page.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

export default function LoginPage() {
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    try {
      const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/login`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email, password }),
        credentials: 'include', // Important for cookie handling
      })

      const result = await response.json()

      if (!response.ok) {
        throw new Error(result.message || 'Login failed')
      }

      router.refresh() // Refresh server components
      router.replace('/dashboard')
    } catch (err) {
      setError('Login failed')
    }
  }

  // ... rest of component
}
Enter fullscreen mode Exit fullscreen mode

And here's our logout implementation:

// src/components/LogoutButton.tsx
'use client'

import { useRouter } from 'next/navigation'

export default function LogoutButton() {
  const router = useRouter()

  const handleLogout = async () => {
    try {
      const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/logout`, {
        method: 'POST',
        credentials: 'include',
      })

      if (!response.ok) {
        throw new Error('Logout failed')
      }

      router.refresh()
      router.replace('/login')
    } catch (err) {
      console.error('Logout failed:', err)
    }
  }

  return (
    <button
      style={{
        padding: '8px',
        borderRadius: '5px',
        height: '40px',
      }}
      onClick={handleLogout}
    >
      Logout
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Why This Approach Works Better

  1. Automatic Cookie Handling: The browser automatically handles cookies when making client-side requests, eliminating the need for manual cookie management.

  2. Better Integration: Payload's REST API is designed to work seamlessly with client-side requests, making this approach more reliable.

  3. Simpler State Management: Using router.refresh() ensures server components are updated when auth state changes.

Important Configuration

Make sure to set up your environment variables:

NEXT_PUBLIC_SERVER_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Protecting Routes with Middleware

We still use server-side middleware to protect routes:

// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  // Redirect root to dashboard
  if (request.nextUrl.pathname === '/') {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  // Check for the payload authentication cookie
  const payloadToken = request.cookies.get('payload-token')

  if (!payloadToken) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/', '/dashboard/:path*', '/profile/:path*'],
}
Enter fullscreen mode Exit fullscreen mode

How the Middleware Works

  1. Route Matching

    • The matcher config specifies which routes the middleware protects
    • '/' - Catches the root route
    • '/dashboard/:path*' - Protects all dashboard routes and sub-routes
    • '/profile/:path*' - Protects all profile routes and sub-routes
  2. Authentication Check

   const payloadToken = request.cookies.get('payload-token')
Enter fullscreen mode Exit fullscreen mode
  • Checks for Payload's authentication cookie
  • This cookie is set during login and removed during logout
  • The cookie contains the JWT token that Payload uses for authentication
  1. Protection Logic
   if (!payloadToken) {
     return NextResponse.redirect(new URL('/login', request.url))
   }
Enter fullscreen mode Exit fullscreen mode
  • If no authentication cookie is found, redirects to login
  • This creates a security barrier around protected routes
  • Happens before any page code runs, providing early protection
  1. Allowing Access
   return NextResponse.next()
Enter fullscreen mode Exit fullscreen mode
  • If the authentication cookie exists, allows the request to proceed
  • The actual page component can then render

Benefits of This Approach

  1. Early Interception

    • Checks happen before any page code runs
    • Prevents unauthorized access efficiently
    • Reduces server load by catching unauthorized requests early
  2. Centralized Protection

    • Single place to manage route protection
    • Easy to modify protected routes using the matcher
    • Consistent authentication checks across the application
  3. Clean URLs

    • Handles root route redirection elegantly
    • Maintains clean URLs while protecting routes
    • Better user experience with automatic redirects
  4. Lightweight Checks

    • Only checks for cookie presence in middleware
    • Detailed authentication happens in the actual pages
    • Good balance between security and performance

Important Notes

  • The middleware only checks for the presence of the cookie, not its validity
  • Full token validation happens in the page components using Payload's auth
  • This creates a two-layer authentication system:
    1. Quick cookie check in middleware
    2. Full authentication verification in protected pages, see next section

This middleware approach provides a robust first line of defense while keeping the implementation simple and maintainable.

Protecting Route with Authentication Check

Page-Level Authentication

While our middleware provides a quick check for the authentication cookie, our protected pages do a more thorough verification. Using Payload's authentication system, each protected page validates the user's token, retrieves their complete profile data, and can enforce specific permissions - all happening securely on the server side before any content is rendered.

This server-side check ensures that even if someone manages to forge a cookie, they won't be able to access protected content without a valid Payload session.

Here's how it works:

  const payload = await getPayloadClient()
  const { user } = await payload.auth({ headers: await headers() })
  if (!user) {
    redirect('/login')
  }
Enter fullscreen mode Exit fullscreen mode

Benefits of Page-Level Checks

  1. Complete User Data

    • Access to the full user object
    • Can display user-specific information
    • Enables role-based content rendering
  2. Security

    • Validates token authenticity
    • Ensures token hasn't expired
    • Protects against tampered cookies
  3. Fresh Data

    • Gets current user state
    • Updates with each page load
    • Maintains session accuracy

Conclusion

While Next.js server actions are powerful, for authentication with Payload CMS, a client-side approach provides a more reliable solution with better cookie handling and state management. The key is letting the browser handle cookies and using router.refresh() to keep server components in sync with the authentication state.

Appendix

Helper Function to Get Payload Object

import { getPayload } from 'payload'
import config from '@/payload.config'

/**
 * Caches the Payload client instance in global scope.
 * This pattern ensures:
 * - Only one Payload instance is created
 * - Efficient connection pooling
 * - Proper handling of concurrent requests
 */
let cached = (global as any).payload

if (!cached) {
  cached = (global as any).payload = {
    client: null,
    promise: null,
  }
}

/**
 * Returns a cached instance of the Payload client.
 * Follows the singleton pattern to maintain a single connection to the database.
 *
 * @returns Promise<Payload> A promise that resolves to the Payload client instance
 *
 */
export const getPayloadClient = async () => {
  // Return existing client if available
  if (cached.client) {
    return cached.client
  }

  // Create new promise if none exists
  if (!cached.promise) {
    cached.promise = getPayload({
      config,
    })
  }

  try {
    cached.client = await cached.promise
  } catch (e) {
    cached.promise = null
    throw e
  }

  return cached.client
}

Enter fullscreen mode Exit fullscreen mode

Remember to handle errors appropriately and provide good user feedback in your implementation.

References

Top comments (0)