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 }
}
}
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:
- Cookie handling was problematic in server-to-server requests
- Server actions couldn't properly handle response headers
- 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
}
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>
)
}
Why This Approach Works Better
Automatic Cookie Handling: The browser automatically handles cookies when making client-side requests, eliminating the need for manual cookie management.
Better Integration: Payload's REST API is designed to work seamlessly with client-side requests, making this approach more reliable.
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
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*'],
}
How the Middleware Works
-
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
- The
Authentication Check
const payloadToken = request.cookies.get('payload-token')
- 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
- Protection Logic
if (!payloadToken) {
return NextResponse.redirect(new URL('/login', request.url))
}
- 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
- Allowing Access
return NextResponse.next()
- If the authentication cookie exists, allows the request to proceed
- The actual page component can then render
Benefits of This Approach
-
Early Interception
- Checks happen before any page code runs
- Prevents unauthorized access efficiently
- Reduces server load by catching unauthorized requests early
-
Centralized Protection
- Single place to manage route protection
- Easy to modify protected routes using the matcher
- Consistent authentication checks across the application
-
Clean URLs
- Handles root route redirection elegantly
- Maintains clean URLs while protecting routes
- Better user experience with automatic redirects
-
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:
- Quick cookie check in middleware
- 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')
}
Benefits of Page-Level Checks
-
Complete User Data
- Access to the full user object
- Can display user-specific information
- Enables role-based content rendering
-
Security
- Validates token authenticity
- Ensures token hasn't expired
- Protects against tampered cookies
-
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
}
Remember to handle errors appropriately and provide good user feedback in your implementation.
Top comments (0)