Fix: Multiple GoTrueClient instances detected (Supabase)

This warning means createClient() ran more than once — a module-scope client re-created per render, or two auth SDKs sharing the same localStorage key.

Mahdi Ben RhoumaMahdi Ben Rhouma · Updated

Multiple GoTrueClient instances detected in the same browser context. It is not currently supported to run multiple instances of GoTrueClient. This may produce undefined behavior fires because Supabase's auth module (GoTrue) is built as a per-tab singleton that owns one localStorage key (sb-<project-ref>-auth-token by default) for the session, its refresh timer, and its onAuthStateChange subscribers. A second createClient() call in the same browser tab creates a second GoTrue instance that fights the first one over that same key — both try to refresh the token, both listen for auth changes, and one can overwrite the other's session write mid-refresh.

Cause 1: createClient() called on every render or every import#

This is the most common version, and it's a module-instantiation bug, not an auth bug:

ts
// ❌ lib/supabase.ts — re-runs createClient() every time this module is imported
import { createClient } from '@supabase/supabase-js';
 
export function getSupabase() {
  return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
}

Every call site that does const supabase = getSupabase() gets its own GoTrue instance. The fix is a module-scope singleton — create the client once, at module load time, and export the instance itself:

ts
// ✅ lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
 
export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
ts
// every call site imports the same instance
import { supabase } from '@/lib/supabase';

In a Next.js App Router project, the equivalent bug is calling createBrowserClient() from @supabase/ssr inside a component body instead of a hook created once:

ts
// ❌ components/Nav.tsx — new client on every render
import { createBrowserClient } from '@supabase/ssr';
 
export function Nav() {
  const supabase = createBrowserClient(url, key); // re-created every render
}
ts
// ✅ lib/supabase-browser.ts — one instance, reused via a hook
import { createBrowserClient } from '@supabase/ssr';
 
let client: ReturnType<typeof createBrowserClient>;
 
export function getSupabaseBrowserClient() {
  if (!client) {
    client = createBrowserClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    );
  }
  return client;
}

Cause 2: hot-reload duplicating the client in development#

Next.js Fast Refresh can re-evaluate a module without a full page reload, which re-runs createClient() at module scope and can leave a second, orphaned GoTrue instance listening in the background — this version of the warning tends to appear only in next dev, disappears on a hard refresh, and never shows up in production. Guarding the singleton against globalThis survives Fast Refresh cleanly:

ts
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
 
const g = globalThis as unknown as { __supabase?: ReturnType<typeof createClient> };
 
export const supabase =
  g.__supabase ??
  (g.__supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  ));

Cause 3: a second auth SDK sharing the browser context#

This is the case Stack Overflow question 78016419 describes: an app using Clerk (or NextAuth, or Firebase Auth) for the primary sign-in flow and Supabase's own client for database access ends up with two independent auth stacks in the same tab. If the Supabase client is only being used for its Postgres/Storage access — not for Supabase Auth itself — disable GoTrue's session persistence so it stops trying to own a session at all:

ts
export const supabase = createClient(url, key, {
  auth: {
    persistSession: false,
    autoRefreshToken: false,
  },
});

Pass the external provider's JWT explicitly per request instead (Authorization: Bearer <token> via Third-Party Auth if using Clerk/Auth0/Firebase with Supabase RLS), rather than letting two clients race to manage localStorage.

Verifying the fix#

  1. grep -rn "createClient(" src app lib — count the call sites; there should be exactly one for the browser client (server-side clients created per request are fine and unrelated to this warning).
  2. Reload the app in a clean incognito tab; the warning is logged once, at most, on the very first client creation — not repeatedly per navigation.
  3. Confirm localStorage.getItem('sb-<project-ref>-auth-token') in DevTools holds one consistent session object across page navigations instead of flipping between two different expires_at values.

When two clients are legitimate: separate storageKey per client#

Not every case of this warning is a bug to eliminate. A multi-tenant app that connects to more than one Supabase project in the same browser tab — one project per tenant, or a primary project plus a separate analytics/reporting project — genuinely needs two independent GoTrueClient instances, and collapsing them into one client is not an option because each targets a different Supabase URL and anon key. The warning still fires here because both clients default to the same localStorage key pattern unless told otherwise, so the fix is not removing a client, it's giving each one its own storage key:

ts
export const supabaseA = createClient(urlA, keyA, {
  auth: { storageKey: 'sb-tenant-a-auth-token' },
});
 
export const supabaseB = createClient(urlB, keyB, {
  auth: { storageKey: 'sb-tenant-b-auth-token' },
});

With distinct storageKey values, both clients persist their sessions under separate localStorage entries and stop overwriting each other's refresh state — the warning disappears because there is no longer a shared key to race over, even though two GoTrueClient instances are still running by design. This is the same mechanism a multi-tenant architecture needs for tenant-scoped auth; see Supabase Multi-Tenant Architecture: Best Practices 2026 for how tenant isolation extends beyond just the client instance into RLS policies and subdomain routing. Do not reach for persistSession: false here as a shortcut — that disables session persistence entirely for that client, which breaks the tenant it's actually supposed to authenticate.

A third-party widget bundling its own Supabase client#

The warning sometimes has nothing to do with your own code. An embedded third-party script — a support-chat widget, a feedback tool, or an analytics snippet that itself uses Supabase under the hood and happens to point at the same project (same URL and anon key you use) — instantiates its own GoTrueClient inside the same browser tab, completely outside your bundle and outside grep's reach. This is the hardest version of the bug to find because grep -rn "createClient(" across your own repo comes back clean; the second instance is shipped by a script tag or an npm package you didn't write.

To confirm this is the cause, open DevTools → Sources (or Network, for a script tag) and search the loaded scripts for @supabase/supabase-js or GoTrueClient outside your own bundle's chunk names. If a vendor script is the source, the two realistic fixes are: ask the vendor for a build that lets you pass an existing Supabase client instance instead of creating its own (many analytics/chat SDKs support this via an init option), or, if that's not available, isolate the vendor's client from yours by scoping your own client's session to a non-default storageKey as shown above — it won't stop the vendor's client from existing, but it stops the two from fighting over the same localStorage entry. If the vendor client only needs read access and never performs its own sign-in, check whether it can run against the service_role key server-side instead of a second browser client entirely, removing the second GoTrueClient from the tab.

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Related fixes & guides