Back to blog
·6 min read

Why Your Supabase + Next.js Users Keep Getting Logged Out (And How to Fix It)

Users randomly getting logged out in your Supabase + Next.js app? This happens because Server Component caching conflicts with auth state. Here's the exact pattern to fix it.

Why Your Supabase + Next.js Users Keep Getting Logged Out

You launch your Next.js app to production. Everything works on localhost. Then within hours, support tickets start rolling in: users are getting randomly logged out. They refresh the page and suddenly they're logged back in. They click a protected link, get bounced to login, then immediately redirected to the page they wanted.

This isn't a Supabase bug. It's not a Next.js bug. It's a session synchronization problem that happens when Server Component caching collides with authentication state, and it's destroying trust in your application faster than any other failure.

The good news: this is completely preventable, and the fix is straightforward once you understand what's happening under the hood.

The Root Cause: Caching vs. Auth State Refresh

Next.js Server Components cache data aggressively by default. When your middleware refreshes an auth token or detects a session has expired, that change doesn't automatically propagate to cached Server Components on the client. They still think the user is logged in (or logged out) based on stale data.

Here's the sequence that breaks:

  • User's session token is about 15 minutes from expiration
  • They make a request to a protected route
  • Middleware checks the token, sees it's stale, refreshes it silently
  • But a Server Component higher up in the tree is still serving cached data that says the user is a guest
  • The UI renders as logged out, even though the fresh token exists in the request context
  • User refreshes and sees the correct state
  • This mismatch happens because the server-side cache invalidation and the client-side auth state are operating on different timelines.

    The Fix: Using @supabase/ssr Package

    The Supabase team built @supabase/ssr specifically to handle this. It includes utilities that work with Next.js caching instead of against it.

    First, install the package:

    ```

    npm install @supabase/ssr @supabase/supabase-js

    ```

    Then create a utility file to initialize your Supabase client:

    ```javascript

    // lib/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 {

    // This happens if cookies() is called from a Server Component

    // outside the request context. Not a fatal error.

    }

    },

    },

    }

    )

    }

    ```

    This client handles cookie refresh automatically. Every time it's called, it checks if the session needs refreshing and updates the cookies if necessary.

    Middleware: The Critical Piece

    Your middleware is where session state is validated before any request reaches your application. This is where you prevent the desync from happening in the first place.

    ```javascript

    // middleware.ts

    import { createServerClient } from '@supabase/ssr'

    import { NextRequest, NextResponse } from 'next/server'

    export async function middleware(request: NextRequest) {

    let response = NextResponse.next({

    request: {

    headers: request.headers,

    },

    })

    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)

    response.cookies.set(name, value, options)

    })

    },

    },

    }

    )

    const { data: { user } } = await supabase.auth.getUser()

    // Redirect unauthenticated users trying to access protected routes

    if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {

    return NextResponse.redirect(new URL('/login', request.url))

    }

    // Redirect authenticated users away from login page

    if (user && request.nextUrl.pathname === '/login') {

    return NextResponse.redirect(new URL('/dashboard', request.url))

    }

    return response

    }

    export const config = {

    matcher: [

    '/((?!_next/static|_next/image|favicon.ico|.*\\.svg).*)',

    ],

    }

    ```

    This middleware runs on every request and ensures your auth state is fresh before the request hits your application. The key is that cookie updates from the middleware response are sent back to the client, keeping everything in sync.

    AuthProvider Pattern: Client-Side Sync

    For Client Components that need auth state, build an AuthProvider that listens to Supabase auth changes:

    ```javascript

    // app/providers.tsx

    'use client'

    import { createContext, useContext, useEffect, useState } from 'react'

    import { createClient } from '@/lib/supabase/client'

    import type { User, Session } from '@supabase/supabase-js'

    const AuthContext = createContext<{

    user: User | null

    session: Session | null

    isLoading: boolean

    }>({

    user: null,

    session: null,

    isLoading: true,

    })

    export function AuthProvider({ children }: { children: React.ReactNode }) {

    const [user, setUser] = useState<User | null>(null)

    const [session, setSession] = useState<Session | null>(null)

    const [isLoading, setIsLoading] = useState(true)

    const supabase = createClient()

    useEffect(() => {

    // Check current session

    supabase.auth.getSession().then(({ data: { session } }) => {

    setSession(session)

    setUser(session?.user ?? null)

    setIsLoading(false)

    })

    // Listen for auth changes

    const { data: { subscription } } = supabase.auth.onAuthStateChange(

    (event, session) => {

    setSession(session)

    setUser(session?.user ?? null)

    }

    )

    return () => subscription?.unsubscribe()

    }, [])

    return (

    <AuthContext.Provider value={{ user, session, isLoading }}>

    {children}

    </AuthContext.Provider>

    )

    }

    export function useAuth() {

    const context = useContext(AuthContext)

    if (!context) {

    throw new Error('useAuth must be used within AuthProvider')

    }

    return context

    }

    ```

    Wrap your app with this provider in your root layout, and any Client Component can listen to auth changes in real-time.

    Common Mistakes to Avoid

    Don't create a new Supabase client on every render. Reuse the same instance. Don't skip RLS on your tables even if it feels faster initially. Every public table without RLS is a security breach waiting to happen. Don't cache auth-dependent data with revalidateTag or revalidatePath without invalidating on auth state changes. The cache will outlive the session.

    Why This Matters for AI-Assisted Development

    When you're building a production app using Claude Code or another AI assistant, these patterns are easy to miss. An AI can scaffold the basic auth flow, but it won't catch the subtle timing issues between server caching and session refresh without explicit guidance.

    This is where structured development matters. Platforms like ZipBuild that scaffold production-ready apps use these exact patterns built in from the start, eliminating entire categories of bugs before you even write your first line of code.

    The Takeaway

    Session desync is one of the most expensive bugs in production Next.js apps because it destroys user trust silently. Users don't report a bug. They just stop using your app.

    The fix is using the @supabase/ssr package, a properly configured middleware that refreshes auth state on every request, and an AuthProvider that listens to real-time auth changes. With these three pieces, your users stay logged in when they should be, and logged out when they need to be.

    Try the free discovery chat at zipbuild.dev to see how structured scaffolding prevents these issues from the start.

    Written by ZipBuild Team

    Ready to build with structure?

    Try the free discovery chat and see how ZipBuild architects your idea.

    Start Building