©Code byMilanParmar

Lovable app hangs after login: 6 causes and the fixes

How to tell which cause you have from the browser's network tab, the auth listener deadlock and the supabase-js version that fixed it, profile fetches that never finish, missing profile rows, redirect settings, and an auth provider that can't hang.

10 min read

A Lovable app that hangs after login is usually waiting on itself. On supabase-js versions before 2.107.0, awaiting a Supabase call inside onAuthStateChange could deadlock, often only after a page reload. The other common causes are a profile fetch that fails and never ends the loading state, a missing profile row, and redirect settings.

The symptom is the same for all of them: the user signs in and sees a spinner, or a blank page, that never goes away. The causes are different, and the fix for one doesn't help the others, so start by finding which one you have. This guide is for founders debugging their own app and for the developer helping them. It applies to apps built with Lovable, Bolt, and anything else that uses Supabase Auth in React. If the app has bigger problems than one login bug, read whether to fix or rebuild a Lovable app first.

Find why your Lovable app hangs after login

Sign in, then reload the page with Chrome's developer tools open on the Network tab. Type supabase.co into the filter. What you see points to the cause:

What the network tab shows, and what it means
What you seeLikely cause
The profile request is pending forever, or never sentThe auth listener deadlock (cause 1)
A 500 error with code 42P17A looping access policy (cause 2)
A 406 with PGRST116, or a 200 with []The row is hidden by RLS or doesn't exist (causes 2 and 3)
After an email link, the address is localhost or another domainSite URL or redirect URLs (cause 4)
A flash, then back to the login pageThe route guard runs before the session loads (cause 5)
Console warns "Multiple GoTrueClient instances detected"More than one Supabase client (cause 6)

Also check which version of the Supabase library the app runs. In a terminal in the project folder, run npm ls @supabase/supabase-js. The version decides whether cause 1 is possible at all.

Cause 1: a Supabase call inside onAuthStateChange

onAuthStateChangeis the listener that tells the app when someone signs in or out. AI-generated code often uses it to load the user's profile straight away, with an await inside the callback:

The pattern that hangs on older supabase-js
supabase.auth.onAuthStateChange(async (event, session) => {
  setSession(session);
  if (session?.user) {
    const { data } = await supabase
      .from("profiles")
      .select("*")
      .eq("id", session.user.id)
      .single();
    setProfile(data);
  }
  setLoading(false);
});

Supabase's library ran this callback while holding an internal lock. The awaited query needed the same lock, so it waited for a callback that was waiting for it. Older versions of the library documentation warned that you can easily create a deadlock by awaiting another Supabase method in the callback, and Supabase's troubleshooting page "Why is my Supabase API call not returning?" still describes it: after such a call, the next Supabase call anywhere on that client hangs.

Why it often hangs only after a reload

In a reproduction for this post on supabase-js 2.106.0, a fresh sign-in with this callback worked. Reloading the page while signed in hung for good. On reload, the library restores the saved session and fires the sign-in event while it holds the lock, which is exactly when the awaited query can't get it. Returning to the tab can take the same path. A GitHub issue from April 2025 titled "Supabase JS Client Hangs During onAuthStateChange Fetch After Refresh" reports the same thing.

Fixed in supabase-js 2.107.0

Supabase removed that lock in supabase-js 2.107.0, released on 2 June 2026. The change says callbacks can now call other auth methods on the default path, and lists Strict Mode deadlocks and iOS Safari hangs with a saved session among the problems it resolved. One hazard remains: calling refreshSession inside a TOKEN_REFRESHED handler still deadlocks. Apps that pass their own lock option to createClient keep the old behaviour.

So the fix depends on your version:

  1. Below 2.107.0: upgrade the library, or at least stop awaiting Supabase calls in the callback. The old documented workaround is to defer them with setTimeout, so they run after the callback returns.
  2. 2.107.0 or later:the deadlock is gone, but keep the callback quick and synchronous anyway. Supabase's current docs note that events are handled in order, so a slow callback delays the next ones.
The smallest safe change to an existing callback
supabase.auth.onAuthStateChange((event, session) => {
  setSession(session);
  if (session?.user) {
    // Runs after the callback returns, outside the auth lock
    setTimeout(() => {
      void fetchProfile(session.user.id);
    }, 0);
  } else {
    setProfile(null);
  }
});

The cleaner fix, which the provider at the end of this post uses, is to load the profile in its own effect whenever the user ID changes. After upgrading, test signing in, signing out, reloading, and switching tabs before you ship.

Cause 2: the profile fetch fails and loading never ends

The second pattern: the code sets loading to false only when the profile loads successfully. Any error leaves the spinner up forever, and the user never sees the error. Three things make the fetch fail:

  1. Row level security hides the row. With no select policy that lets users read their own profile, the query returns nothing. With .single(), which demands exactly one row, nothing becomes an error, PGRST116.
  2. A policy loops. An admin check on the profiles table can fail every read with error 42P17. The fix is in infinite recursion detected in policy for relation.
  3. The row doesn't exist. See cause 3.

Fix it in two places. Use .maybeSingle(), which Supabase's docs say returns null instead of an error when no row matches. And end loading in a finallyblock, so it ends whether the fetch succeeds or not, with an error message on screen instead of a spinner. Then make sure the profiles table has a select policy for the user's own row, as in the Supabase RLS checklist.

Cause 3: new users have no profile row

Supabase keeps logins in its own auth.users table. Your profilestable only gets a row if something creates one. Supabase's docs recommend a trigger that inserts the row on every signup, and warn that a failing trigger can block signups, so test it. Users who signed up before the trigger existed have no row, which is why the hang can hit only older accounts.

SQL: create a profile row for every new user
create function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  insert into public.profiles (id) values (new.id);
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

-- Not callable from the API; the trigger still runs it
revoke execute on function public.handle_new_user()
  from public, anon, authenticated;

-- One-off: add rows for users who signed up before the trigger
insert into public.profiles (id)
select u.id
from auth.users u
left join public.profiles p on p.id = u.id
where p.id is null;

Add any other columns your profiles table requires to the insert, or give them defaults, or the trigger fails and signups break. To see whether this is your problem before changing anything, count the users with no profile:

SQL: users without a profile row
select count(*)
from auth.users u
left join public.profiles p on p.id = u.id
where p.id is null;

Anything above zero means some users will sign in to an app that can't find their profile. Run the backfill above, then sign in as one of those users to confirm the spinner is gone.

Cause 4: login works in preview, not when published

If sign-in works in Lovable's preview and hangs on the published app, or the email confirmation link opens localhost:3000, the cause is the Site URL and redirect URLs in Supabase's auth settings. Supabase's docs say the Site URL is the default place users are sent after email confirmations and password resets, and tell you to change it from http://localhost:3000 to your production address. Any other address the app sends users to has to be in the redirect URLs list.

Lovable's docs say that on Lovable Cloud, it keeps both up to date for you when you publish or connect a domain, unless you set the Site URL yourself. Apps on your own Supabase project get no automatic updates. Lovable puts it plainly: if sign-in works in preview but breaks on the published app or custom domain, these URLs are usually the reason. In your Supabase project, open Authentication, then URL Configuration, set the Site URL to your live domain, and add every domain users sign in from.

Cause 5: redirected to login before the session loads

On a reload, the session takes a moment to restore. A protected route that checks "is there a user?" before that finishes sees nobody, and sends the user to the login page, which may then send them back. The fix is a loading state that stays on until the first auth event arrives. The listener fires an INITIAL_SESSION event when it subscribes, so wait for that before deciding where to send anyone. With the provider at the end of this post, a protected route in an older React and Vite Lovable app, which uses React Router, looks like this. Apps created since May 2026 run on TanStack Start and its router, but the order of the checks is the same:

A protected route that waits for the session
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "@/contexts/AuthProvider";

export function ProtectedRoute({ children }: { children: ReactNode }) {
  const { session, loading, error } = useAuth();
  if (loading) return <p>Loading…</p>;
  if (!session) return <Navigate to="/login" replace />;
  if (error) return <p>We couldn't load your account: {error}</p>;
  return <>{children}</>;
}

The order matters: wait, then redirect, then show errors. Code that redirects first is what produces the flash back to the login page, and code that never reaches the last line is what leaves users staring at a spinner.

Cause 6: two Supabase clients, or StrictMode

Lovable creates one Supabase client in src/integrations/supabase/client.ts. If another file calls createClientas well, the console shows "Multiple GoTrueClient instances detected in the same browser context". The warning says it may produce undefined behaviour, such as parts of the app disagreeing about who's logged in. Import the one client everywhere.

In development, React's StrictMode runs every effect twice, which its docs describe as one extra setup and cleanup cycle. An auth listener without a cleanup that unsubscribes ends up registered twice. That only affects development and preview, but on older supabase-js the June 2026 change lists StrictMode deadlocks among the bugs it fixed, so it can make the preview hang when the live app doesn't.

An auth provider that can't hang

This provider puts the fixes together: a synchronous listener with a cleanup, the profile loaded in its own effect, maybeSingle, loading that always ends, and no redirect until the first auth event has arrived. It uses the client Lovable already creates. Adjust the profile columns to match yours.

src/contexts/AuthProvider.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import type { Session } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";

type Profile = { id: string; full_name: string | null };
type AuthValue = {
  session: Session | null;
  profile: Profile | null;
  loading: boolean;
  error: string | null;
};

const AuthContext = createContext<AuthValue | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const [authReady, setAuthReady] = useState(false);
  const [profile, setProfile] = useState<Profile | null>(null);
  const [loadedFor, setLoadedFor] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  // 1. Listen for sign-in and sign-out. No await, no Supabase calls here.
  useEffect(() => {
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, newSession) => {
        setSession(newSession);
        setAuthReady(true); // the first event is INITIAL_SESSION
      },
    );
    return () => subscription.unsubscribe();
  }, []);

  // 2. Load the profile separately, whenever the signed-in user changes.
  const userId = session?.user.id ?? null;
  useEffect(() => {
    if (!authReady) return;
    if (!userId) {
      setProfile(null);
      setError(null);
      return;
    }
    let cancelled = false;
    setError(null);

    async function loadProfile(id: string) {
      try {
        const { data, error } = await supabase
          .from("profiles")
          .select("id, full_name")
          .eq("id", id)
          .maybeSingle(); // null, not an error, when there's no row
        if (cancelled) return;
        if (error) setError(error.message);
        setProfile((data as Profile | null) ?? null);
      } catch (e) {
        if (!cancelled) {
          setError(e instanceof Error ? e.message : "Could not load profile");
        }
      } finally {
        if (!cancelled) setLoadedFor(id); // loading always ends
      }
    }

    void loadProfile(userId);
    return () => {
      cancelled = true;
    };
  }, [authReady, userId]);

  const value: AuthValue = {
    session,
    profile,
    error,
    loading: !authReady || (userId !== null && loadedFor !== userId),
  };
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
  return ctx;
}

Protected routes then wait while loadingis true, send the user to login only when loading is false and there's no session, and show errorif the profile couldn't load.

When a login hang is a sign of more

A login hang on its own is usually a small fix. It's a sign of more when the profile fetch failed because of access rules: if the profiles table had no policy, or a looping one, other tables probably have the same gaps, and those don't show up as a spinner. They show up as data the wrong people can read.

Logins, sessions, and access rules are the first things my Launch-Ready Review checks, for a fixed $900–1,500 in 3–5 days. The Chat Company started as an AI-generated demo; I rewrote about 80% of it, and it now serves 30+ businesses. What each step of fixing an app costs is in the cost to fix a vibe-coded app. Or send me what your network tab shows.

Sources

  1. onAuthStateChange reference, Supabase Docs
  2. Why is my Supabase API call not returning?, Supabase Docs
  3. Remove navigator.locks-based mutex (shipped in v2.107.0), supabase-js on GitHub
  4. Client hangs during onAuthStateChange fetch after refresh, supabase-js on GitHub
  5. Redirect URLs and the Site URL, Supabase Docs
  6. Authentication: Site URL and redirect URLs, Lovable Docs
  7. Managing user data: profiles trigger, Supabase Docs
  8. maybeSingle reference, Supabase Docs
  9. StrictMode: extra setup and cleanup in development, React
Milan Parmar

The developer

Freelance full-stack developer in Bengaluru with 5+ years of experience. I redesign websites, take over broken apps, and build SaaS products, with 18 case studies to show for it.

FAQs

Why does my Lovable app hang only after a page refresh?

Because on a refresh, Supabase restores the saved session and fires the sign-in event while it holds an internal lock. On supabase-js versions before 2.107.0, a callback that then awaited another Supabase call waited for that same lock, forever. A fresh login often worked, which is why the bug looks random. Returning to the tab can trigger the same path.

Can I ask Lovable to fix the login hang?

Yes. Ask it to make the onAuthStateChange callback synchronous, move the profile fetch into a separate effect that runs when the user ID changes, use maybeSingle, and end the loading state in a finally block. Then test by reloading the page while signed in, and by opening the app in a second tab. If the hang survives that, check the network tab.

Why does login hang on iPhone Safari but not on desktop?

The supabase-js change that fixed the auth deadlock in June 2026 lists hangs on iOS Safari with a saved session among the failure types it resolved. If the app is on an older version, upgrading supabase-js is the first thing to try. Test on a real iPhone afterwards, signed in, closing and reopening the tab.