Skip to main content
ASoc
Tutorial

Supabase Auth UI for React Is Archived: What We Shipped Instead

Maintenance mode since Feb 2024, archived Oct 2025. The 158-line Server Action file that replaced it here, and the 68 KiB it kept off four pages.

The ASoc Team10 min read

@supabase/auth-ui-react is Supabase's prebuilt <Auth /> component — one import that renders a themed email/password and social sign-in form. Its repository entered maintenance mode on 7 February 2024 and was archived on 23 October 2025, so it ships no fixes. New projects should render their own form and call supabase.auth.* directly.

That is the part the install snippets still circulating do not say. This storefront runs the replacement in production: five auth routes (/login, /signup, /forgot-password, /reset-password, /auth/callback), no prebuilt auth component anywhere in package.json, and one 158-line file holding every call into Supabase. Everything below is copied out of that code, including the two defects the hand-written version is specifically shaped to avoid.

Status, stated plainly

FactDetail
Package@supabase/auth-ui-react, plus @supabase/auth-ui-shared for themes
Repositorysupabase/auth-ui
Maintenance modeAnnounced in the README, 7 February 2024
Archived / read-only23 October 2025
Named React successor in that READMENone — it keeps the Svelte package and some Next.js password pages
Still installableYes. npm does not delete archived packages

The last two rows are why the library keeps getting installed. npm i @supabase/auth-ui-react still succeeds, the component still renders, and nothing in your terminal mentions the archive. Supabase has since published a separate UI library at ui.supabase.com, which is a different project rather than a drop-in replacement for the <Auth /> prop surface.

So the question is not whether to migrate off it. It is what the replacement looks like, and the honest answer is: smaller than you expect, and it hands back three decisions the component was making for you.

What the component gives you, and what you trade for it

Axis<Auth /> from auth-ui-reactOwn form + Server Action
Time to first working formMinutesAn afternoon
Rendering modelClient Component by construction — it needs a browser clientServer Component shell; no client JS for the fields
Error messagesSupabase's strings, surfaced as-isYours, written per branch
Themingappearance prop + ThemeSupaYour own classes, same as every other form
LocalisationCopy a JSON file out of the repo, pass localization.variablesNormal app i18n
Redirect after sign-inSupabase's redirectToYours, validated
MaintenanceArchived upstreamYours, which is the point

Row two is the structural one, not a preference. <Auth /> takes a supabaseClient instance as a prop, which means a browser client exists in the module graph of whatever page renders it. In the App Router that forces the page into client rendering and pulls the auth SDK into that route's bundle.

We have the measurement for what that SDK weighs, because we paid it by accident.

The 68 KiB that was on pages with no account UI

During a Lighthouse pass on this repo, @supabase/ssr plus auth-js measured ~68 KiB gzipped and 255 KiB parsed in the initial bundle of /, /blog, /docs and /pricing — four pages with no sign-in form, no account menu, nothing. Two call sites caused it: Header (site-wide) and useOwnedProducts (behind every templates grid) both imported @/lib/supabase/client at module scope.

Neither actually needed the client during render. Both only touch it inside an effect, so the import moved behind src/lib/supabase/lazyClient.ts, which does two things: imports the SDK dynamically, and skips it entirely when no sb-*-auth-token cookie is present. @supabase/ssr sets those cookies with httpOnly: false by design — createBrowserClient reads them from document.cookie itself — so a cookie probe sees exactly what the client would see.

// src/lib/supabase/lazyClient.ts — the shape, not the whole file
export function hasAuthCookie(): boolean {
  if (typeof document === "undefined") return false;
  return /(?:^|;\s*)sb-[^=]*-auth-token=/.test(document.cookie);
}

The lesson transfers directly: an auth component is only cheap on the routes that need it. A prebuilt <Auth /> is fine on /login. The failure mode is an auth-aware header, and that is a bundle problem you solve the same way whichever form library you use. It is also a rendering shortcut only — every real gate in this codebase stays server-side (authorizeDownload, Postgres RLS), so a wrong guess from the cookie probe costs a render, never an authorization decision.

The replacement: one Server Action file

Here is sign-in, verbatim from src/lib/actions/auth.ts:

"use server";
import { createClient } from "@/lib/supabase/server";
import { EMAIL_RE } from "@/lib/validation";

export type AuthState = { ok: boolean; message: string } | null;

export async function signInWithPassword(
  _prev: AuthState,
  formData: FormData,
): Promise<AuthState> {
  const email = String(formData.get("email") ?? "")
    .trim()
    .toLowerCase();
  const password = String(formData.get("password") ?? "");

  if (!EMAIL_RE.test(email) || !password) {
    return { ok: false, message: "Please enter a valid email and password." };
  }

  const supabase = await createClient();
  const { error } = await supabase.auth.signInWithPassword({ email, password });
  if (error) {
    console.error("auth: signInWithPassword error", error);
    // Always the same message — never let a caller distinguish "wrong
    // password" from "email exists but unconfirmed" (account enumeration).
    return { ok: false, message: "Incorrect email or password." };
  }

  return { ok: true, message: "Signed in — redirecting…" };
}

That is the whole replacement for <Auth />'s password path. The same file holds signUpWithPassword, signInWithGoogle, requestPasswordReset, updatePassword and signOut — 158 lines for every Supabase auth call in the product. The page that renders it (src/app/login/page.tsx, 114 lines) is an ordinary Server Component: metadata, a <Container>, two labelled inputs, and the action passed down.

Google sign-in is the one branch that cannot return state, because it has to leave:

/** Bound directly to a `<form action={signInWithGoogle}>` — always redirects. */
export async function signInWithGoogle(formData: FormData): Promise<void> {
  const next = safeNext(formData.get("next")?.toString());
  const supabase = await createClient();
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: "google",
    options: {
      redirectTo: `${SITE_URL}/auth/callback?next=${encodeURIComponent(next)}`,
    },
  });
  if (error || !data?.url) {
    console.error("auth: signInWithOAuth error", error);
    redirect("/login?error=auth");
  }
  redirect(data.url);
}

Writing it out is how the next three decisions become visible. With <Auth /> they are made for you, and two of them are made in a way that would not survive review here.

Decision 1: the error message is a security choice

A sign-in failure has several causes — wrong password, no such account, correct password but unconfirmed email — and a helpful form tells you which. That is account enumeration: anyone with a list of addresses can learn which ones are registered with your product, one request at a time.

So the handler above collapses every failure into "Incorrect email or password." and logs the real reason server-side. Password reset does the same thing from the other direction, by succeeding whatever happens:

const { error } = await supabase.auth.resetPasswordForEmail(email, {
  redirectTo: `${SITE_URL}/reset-password`,
});
// Don't reveal whether the account exists either way (generic message).
if (error) console.error("auth: resetPasswordForEmail error", error);
return {
  ok: true,
  message: "If an account exists for that email, a reset link is on its way.",
};

You cannot express that through an appearance prop. It is per-branch copy, which means it belongs to whoever owns the branch.

Decision 2: where the session lives

With your own forms, nothing client-side holds the session. signInWithPassword runs on the server, @supabase/ssr writes the cookies, and the request that follows reads them. Refresh happens in src/proxy.ts using getClaims() — never getSession(), which trusts whatever is already in the cookie rather than verifying it.

That is also why the session survives a hard refresh without a loading flash: there is no client-side bootstrap to wait for. The broader argument for cookies over React context is in auth in React; the Next.js wiring is in Supabase auth with Next.js, and the older helpers package that <Auth /> was built alongside is covered in @supabase/auth-helpers-nextjs.

Decision 3: the next parameter is attacker input

Every auth route here takes ?next= so a visitor sent to sign in lands back where they were. That parameter arrives from the URL, which means it arrives from anyone. Sending it straight into a redirect is an open redirect, and the naive guard — "must start with /" — is not enough:

/**
 * Only a same-origin relative path is allowed. Rejects `//host` (browsers
 * treat it as protocol-relative) *and* `/\host` — WHATWG URL parsing treats a
 * leading backslash as a slash too, so `/\evil.com` is just as much an open
 * redirect once the client does `router.push(redirectTo)`.
 */
const SAFE_NEXT_RE = /^\/(?![/\\])/;

export function safeNext(next: string | null | undefined): string {
  return next && SAFE_NEXT_RE.test(next) ? next : "/dashboard";
}

/\evil.com is the one that gets missed. It passes "starts with a slash", and the URL parser then treats the backslash as a second slash, producing a protocol-relative URL to someone else's host. The regex rejects both characters in position two and falls back to /dashboard. Three lines, called in every action and on the login page itself — which is the general shape of trusting query parameters, covered in Next.js API query params.

Troubleshooting

SymptomCauseFix
<Auth /> renders completely unstyledNo theme passed; the package ships none by defaultPass appearance={{ theme: ThemeSupa }} from @supabase/auth-ui-shared — or stop here and write the form, since you are styling it anyway
Localisation strings missing after an upgradeTranslations were unbundled to keep package size downCopy the locale JSON out of the archived repo and pass localization.variables
Sign-in succeeds, the next page still thinks you are anonymousThe session was set on the client but the server request carried no cookieDo the sign-in in a Server Action so @supabase/ssr writes the cookies on that response
Session looks valid but expires unpredictablyRefresh is reading the cookie instead of verifying itRefresh in middleware/proxy with getClaims(), not getSession()
OAuth returns to the site signed outredirectTo host is not in Supabase's allow-list, or the callback route never exchanges the codeAdd the exact URL under Auth → URL Configuration, and keep a /auth/callback route that exchanges it
Auth SDK appears in the bundle of pages with no login UIA module-scope createBrowserClient() import in a shared componentImport it dynamically, and skip it when no sb-*-auth-token cookie exists

FAQ

Is @supabase/auth-ui-react deprecated? The README calls it maintenance mode as of 7 February 2024, and the repository was archived read-only on 23 October 2025. "Deprecated" is not the word used, but no fixes are coming and the README names no React successor. Treat anything already shipping on it as frozen: it will keep working against the current supabase-js surface until that surface changes, at which point nobody upstream will adjust it.

Do I have to migrate an existing app immediately? No, and a rushed swap on a working sign-in flow is its own risk. The upgrade that forces the decision is the one that touches supabase-js or your SSR cookie handling. The cheap first step is moving the auth calls into your own Server Actions while keeping the markup — that is where the enumeration and redirect decisions live, and it leaves the visual layer for later.

Is social login harder without the component? No. signInWithOAuth returns a URL and you redirect to it; the snippet above is the entire Google path. What the component really saved was the provider buttons, which is markup. The genuinely fiddly part — the allow-listed redirect URL and the /auth/callback exchange — is Supabase dashboard configuration and identical either way.

What about the newer Supabase UI library? ui.supabase.com is a separate, actively published project rather than a renamed auth-ui. It does not take the same props, so moving to it is a rewrite of the same size as writing the form — which is worth knowing before you plan a migration around the assumption that it is a drop-in upgrade.

Templates in this post

ASoc Zenith (a growth-marketing agency site), ASoc Aegis (a risk-management SaaS landing page) and ASoc Ally (an AI support-chatbot site) are built on the same component conventions as the auth pages above — Server Components by default, client JS only where interaction demands it.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the cookie-versus-context argument, see auth in React; for the Next.js wiring, Supabase auth with Next.js; for checking a session server-side, is the user logged in.

Keep reading

Tutorial8 min read

Supabase: Canceling Statement Due to Statement Timeout

Error 57014 is almost never the timeout's fault. On Supabase it is usually a policy calling auth.uid() per row against an unindexed column — with the six-policy fix.

Read more
Tutorial9 min read

Supabase: Check If a User Is Logged In (getUser vs getClaims)

Three methods, one of which trusts an unverified cookie. Counted across this codebase: 14 getUser() calls, 3 getClaims(), and zero getSession().

Read more
Tutorial8 min read

Supabase Cloud vs Self-Hosted, Audited Against One Shipped Project

What the managed platform owns and what stays yours: 271 lines of SQL and 102 of client code move; the advisor linter, the pooler and six dashboard settings do not.

Read more