One of the most jarring breaking changes in Next.js 15 is the shift of standard request APIs—specifically cookies(), headers(), params, and searchParams—from synchronous to asynchronous functions.
If you recently upgraded a project using Supabase Auth, you likely encountered the runtime error: Error: cookies() should be awaited. Furthermore, even after patching the syntax, many developers find their authentication sessions fail to persist, resulting in infinite login loops or immediate logouts on page refresh.
This guide details the root cause of these issues in the Next.js 15 architecture and provides the robust, production-ready implementation required to fix Supabase authentication flows.
The Root Cause: Asynchronous Request IO
In Next.js 14 and earlier, cookies() allowed synchronous access to the request's cookie store. While convenient, this forced the underlying rendering engine to block execution until headers were fully processed, limiting the framework's ability to optimize rendering pipelines.
Next.js 15 makes these APIs asynchronous to support Partial Prerendering (PPR) and deeper optimization of the Request/Response cycle.
This breaks the standard @supabase/ssr implementation because the createServerClient factory expects immediate access to cookie values to initialize the authentication state. Additionally, Next.js 15 enforces stricter rules on where cookies can be set. Attempting to write cookies (e.g., refreshing a session token) during the rendering of a Server Component now throws an error, whereas previously it might have been silently ignored or handled differently.
To fix this, we must bifurcate our Supabase client creation strategy: one specialized for Server Actions/Components and another strictly for Middleware.
Solution Part 1: The Asynchronous Server Client
We need to update our server-side utility to await the cookie store before initializing Supabase. We also need to implement a safety mechanism for cookie writes.
Create or update utils/supabase/server.ts:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}
Why this code works
await cookies(): We resolve the promise before passing data to Supabase.- The
try/catchBlock: This is critical for Next.js 15. Supabase's client attempts to refresh sessions (write cookies) automatically. However, Next.js throws an error if you attempt to set a cookie inside a Server Component (e.g., apage.tsx). We catch this error specifically to allow the render to proceed. The actual session maintenance is delegated to the Middleware.
Solution Part 2: The Middleware Fix
The Middleware is the only place where we can intercept a request, refresh the Auth token via Supabase, and pass the updated cookie back to the browser and the server components before rendering starts.
The middleware implementation requires a specific approach to handle request/response objects correctly.
Create or update utils/supabase/middleware.ts:
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// IMPORTANT: Avoid writing logic between createServerClient and
// getUser(). A simple getToken() is not enough; we need to read
// from the auth state to trigger the refresh logic if needed.
const {
data: { user },
} = await supabase.auth.getUser()
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// IMPORTANT: You *must* return the supabaseResponse object as it contains
// the fresh cookies.
return supabaseResponse
}
Now, hook this into your root middleware.ts:
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Deep Dive: Handling Data in Server Components
With the infrastructure fixed, usage in your UI components changes slightly. You must now invoke the client within an async context.
Here is an example of a protected dashboard page (app/dashboard/page.tsx):
import { createClient } from '@/utils/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
// 1. Await the client creation
const supabase = await createClient()
// 2. Fetch authenticated user data
const {
data: { user },
error,
} = await supabase.auth.getUser()
if (error || !user) {
redirect('/login')
}
return (
<section className="p-8">
<h1 className="text-2xl font-bold">Welcome, {user.email}</h1>
<p className="mt-4 text-gray-600">
Your session is secure and compatible with Next.js 15.
</p>
</section>
)
}
Important: getUser vs getSession
In Server Components, always prioritize supabase.auth.getUser().
getSession: May return a cached session that is technically expired but hasn't been validated against the database. It's faster but less secure.getUser: Validates the JWT with the Supabase Auth server. This is critical for security gates in server components.
Common Pitfalls & Edge Cases
1. Infinite Redirect Loops
If you experience infinite redirects between /login and /dashboard, check your middleware matcher config. Ensure static assets and Auth API routes (/auth/callback) are excluded from the protection logic.
2. Layout vs. Page Data Fetching
Avoid fetching the user in the root layout.tsx if possible. Fetching dynamic data (like headers/cookies) in the root layout opts the entire application out of Static Rendering optimizations. Fetch user data in page.tsx or specific sub-layouts (e.g., app/dashboard/layout.tsx) to keep your marketing pages static.
3. Server Actions
When using Server Actions (e.g., submitting a login form), the createClient we defined in utils/supabase/server.ts works perfectly. Because Server Actions are POST requests, the try/catch block in setAll allows the cookies to be set successfully, persisting the new session to the browser.
Conclusion
The shift to asynchronous cookies in Next.js 15 is a move toward better performance, but it requires strict discipline in how we handle authentication state. By separating the Middleware client (responsible for session refresh) from the Server Component client (responsible for data fetching), we ensure a robust authentication flow that leverages the latest framework features without sacrificing stability.