Skip to content

Client-side supabase.auth.getUser() hangs indefinitely when returning to page after inactivity (Next.js SSR) #35754

@PiotrDynia

Description

@PiotrDynia

Bug report

  • I confirm this is a bug with Supabase, not with my own application.
  • I confirm I have searched the Docs, GitHub Discussions, and Discord.

Describe the bug

I am experiencing an issue in my Next.js (App Router) application where the client-side supabase.auth.getUser() call hangs indefinitely under specific conditions. This occurs when a user, who has an active session, closes the browser tab/window and then returns to a page requiring authentication after a period of inactivity (e.g., 30 minutes but can be more than a day too). It doesn't happen every time, happens randomly.

Next.js version: 15.2.3
@supabase/supabase-js version: 2.49.2
@supabase/ssr version: 0.6.1
Browser: Google Chrome
Deployment: Vercel
Application URL if you want to check - https://www.marketingquest.dev/, I left the console logs present if you need to check (need to be on the authorised page such as /dashboard)

To Reproduce

User logs into the Next.js application.
User navigates to a page that is protected and requires an active session (e.g., /dashboard).
User closes the browser tab or window.
User waits for a significant period (e.g., 30+ minutes).
User reopens the browser and navigates back to the protected page (e.g., /dashboard but also happens in the state of the header which checks for the user).

Expected behavior

The client-side supabase.auth.getUser() call (or getSession()) should resolve relatively quickly, either successfully retrieving the user session (potentially after a refresh) or returning an error/null if the session is invalid or has expired.
The page should either load correctly with user data or redirect to login if the session cannot be re-established.

Actual Behavior:

The client-side supabase.auth.getUser() call, initiated from a useEffect hook in a layout component (app/(user)/layout.tsx), hangs indefinitely.
This prevents the rest of the component's data fetching and rendering logic from executing, leading to the UI being stuck in a loading state (e.g., showing skeletons).
We've confirmed this hang by wrapping the getUser() call in a Promise.race with a 10-second timeout, which consistently logs a timeout error for this specific call.
Clearing browser cookies for the site resolves the issue temporarily: the user is logged out, and can log back in successfully. This suggests the issue is related to the handling of an existing, potentially stale, session cookie.
The server-side middleware (middleware.ts) appears to be functioning correctly and redirects to /dashboard if a session cookie is present, implying the middleware considers the session valid initially. The hang occurs in the subsequent client-side hydration/data fetching.

Additional context

// root middleware.ts file

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(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)
          )
        },
      },
    }
  )

  // Do not run code between createServerClient and
  // supabase.auth.getUser(). A simple mistake could make it very hard to debug
  // issues with users being randomly logged out.

  // IMPORTANT: DO NOT REMOVE auth.getUser()
  const {
    data: { user },
  } = await supabase.auth.getUser()

  // Public routes that don't require authentication
  const isPublicRoute = 
    request.nextUrl.pathname.startsWith('/login') ||
    request.nextUrl.pathname.startsWith('/signup') ||
    request.nextUrl.pathname.startsWith('/auth') ||
    request.nextUrl.pathname.startsWith('/_next') ||
    request.nextUrl.pathname.startsWith('/api') ||
    request.nextUrl.pathname.startsWith('/forgot-password') ||
    request.nextUrl.pathname.startsWith('/reset-password') ||
    request.nextUrl.pathname === '/'

  // Protected routes that require both auth and completed onboarding
  const isProtectedRoute =
    request.nextUrl.pathname.startsWith('/dashboard') ||
    request.nextUrl.pathname.startsWith('/tasks') ||
    request.nextUrl.pathname.startsWith('/achievements') ||
    request.nextUrl.pathname.startsWith('/history') ||
    request.nextUrl.pathname.startsWith('/leaderboard') ||
    request.nextUrl.pathname.startsWith('/partners') ||
    request.nextUrl.pathname.startsWith('/settings')

  // If there's no user and the route requires auth, redirect to login
  if (!user && !isPublicRoute) {
    const redirectUrl = new URL('/login', request.url)
    redirectUrl.searchParams.set('redirectedFrom', request.nextUrl.pathname)
    // Ensure cookies are passed if supabaseResponse was modified
    const response = NextResponse.redirect(redirectUrl);
    supabaseResponse.cookies.getAll().forEach(cookie => {
      response.cookies.set(cookie.name, cookie.value, cookie);
    });
    return response;
  }

  // If there's a user, check their onboarding status
  if (user) {
    // Check if user has a profile
    const { data: profile } = await supabase
      .from('profiles')
      .select('username')
      .eq('id', user.id)
      .single()

    const needsOnboarding = !profile || !profile.username
    const isOnboardingRoute = request.nextUrl.pathname.startsWith('/onboarding')
    const isAuthRoute = request.nextUrl.pathname.startsWith('/login') || 
                       request.nextUrl.pathname.startsWith('/signup')

    // If user needs onboarding and tries to access a protected route, redirect to onboarding
    if (needsOnboarding && isProtectedRoute) {
      const redirectUrl = new URL('/onboarding', request.url);
      const response = NextResponse.redirect(redirectUrl);
      // Ensure cookies are passed if supabaseResponse was modified
      supabaseResponse.cookies.getAll().forEach(cookie => {
        response.cookies.set(cookie.name, cookie.value, cookie);
      });
      return response;
    }

    // If user is fully onboarded and tries to access auth pages or onboarding, redirect to dashboard
    if (!needsOnboarding && (isAuthRoute || isOnboardingRoute)) {
      const redirectUrl = new URL('/dashboard', request.url);
      const response = NextResponse.redirect(redirectUrl);
      // Ensure cookies are passed if supabaseResponse was modified
      supabaseResponse.cookies.getAll().forEach(cookie => {
        response.cookies.set(cookie.name, cookie.value, cookie);
      });
      return response;
    }
  }

  return supabaseResponse
}

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)
     * - public assets (images, audio, etc.)
     * Feel free to modify this pattern to include more paths.
     */
    '/((?!_next/static|_next/image|favicon.ico|sounds|.*\\.(?:svg|png|jpg|jpeg|gif|webp|mp3|wav)$).*)',
  ],
} 

// lib/supabase/client.ts

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

// app/(user)/layout.tsx

"use client"
import { createClient } from "@/lib/supabase/client";
import { useEffect, useState, useCallback, useRef } from "react";
// ... other imports ...

export default function UserLayout({ children }: UserLayoutProps) {
  const [userData, setUserData] = useState<UserData | null>(null);
  // ... other states ...
  const supabase = createClient();
  const isMountedRef = useRef(true);

  const refreshUserDataAndStatus = useCallback(async () => {
    console.log('[UserLayout] Initiating refreshUserDataAndStatus...');
    try {
      const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(); // This might also hang if called here
      // ... rest of the function ...
    } catch (e) {
        console.error('[UserLayout] CRITICAL ERROR in refreshUserDataAndStatus catch block:', e);
    }
  }, [supabase]);


  useEffect(() => {
    isMountedRef.current = true;
    console.log('[UserLayout] Main data-fetching useEffect triggered.');
    let userId: string | null = null;
    let isMounted = true; // Local isMounted for this effect

    const setup = async () => {
      console.log('[UserLayout] setup() function initiated.');
      try {
        console.log('[UserLayout] setup(): Attempting supabase.auth.getUser()...');

        const getUserPromise = supabase.auth.getUser();
        const timeoutPromise = new Promise((_, reject) =>
          setTimeout(() => reject(new Error('supabase.auth.getUser() timed out after 10 seconds')), 10000)
        );

        let user: any = null; // Use 'any' or proper User type
        let setupGetUserError: Error | null = null;

        try {
          const result = await Promise.race([
            getUserPromise,
            timeoutPromise
          ]) as { data: { user: any }, error: any }; // Adjust type as needed

          if (result.error) {
            setupGetUserError = result.error;
          } else if (result.data) { // Check if data exists
            user = result.data.user;
          }
        } catch (e: any) {
          setupGetUserError = e;
        }

        if (setupGetUserError) {
          console.error('[UserLayout] setup(): Error or timeout from supabase.auth.getUser():', setupGetUserError);
        }
        console.log('[UserLayout] setup(): supabase.auth.getUser() completed/timedout. User:', user ? user.id : 'null');
        userId = user?.id ?? null;

        await refreshUserDataAndStatus(); // Called regardless of user state from setup's getUser
        // ... (rest of setup) ...

      } catch (error) {
        console.error('[UserLayout] setup(): Error in setup function:', error);
      }
    };

    setup(); // Initial call

    const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange((_event, session) => {
      console.log('[UserLayout] onAuthStateChange triggered. Event:', _event);
      if (!isMountedRef.current) return; // Use ref here

      if (_event === 'SIGNED_OUT') {
        refreshUserDataAndStatus();
        userId = null;
      } else if (_event === 'SIGNED_IN' && session) {
        console.log('[UserLayout] onAuthStateChange: SIGNED_IN event, calling setup().');
        setup();
      }
    });

    return () => {
      isMountedRef.current = false;
      if (authSubscription) authSubscription.unsubscribe();
      // ... any other cleanup ...
    };
  }, [supabase, refreshUserDataAndStatus]);

  if (userData === null) {
    return <LoadingLayout />;
  }
  // ... rest of layout ...
  return ( /* ... */ );
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions