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.
@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
| Fact | Detail |
|---|---|
| Package | @supabase/auth-ui-react, plus @supabase/auth-ui-shared for themes |
| Repository | supabase/auth-ui |
| Maintenance mode | Announced in the README, 7 February 2024 |
| Archived / read-only | 23 October 2025 |
| Named React successor in that README | None — it keeps the Svelte package and some Next.js password pages |
| Still installable | Yes. 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-react | Own form + Server Action |
|---|---|---|
| Time to first working form | Minutes | An afternoon |
| Rendering model | Client Component by construction — it needs a browser client | Server Component shell; no client JS for the fields |
| Error messages | Supabase's strings, surfaced as-is | Yours, written per branch |
| Theming | appearance prop + ThemeSupa | Your own classes, same as every other form |
| Localisation | Copy a JSON file out of the repo, pass localization.variables | Normal app i18n |
| Redirect after sign-in | Supabase's redirectTo | Yours, validated |
| Maintenance | Archived upstream | Yours, 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
| Symptom | Cause | Fix |
|---|---|---|
<Auth /> renders completely unstyled | No theme passed; the package ships none by default | Pass 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 upgrade | Translations were unbundled to keep package size down | Copy the locale JSON out of the archived repo and pass localization.variables |
| Sign-in succeeds, the next page still thinks you are anonymous | The session was set on the client but the server request carried no cookie | Do the sign-in in a Server Action so @supabase/ssr writes the cookies on that response |
| Session looks valid but expires unpredictably | Refresh is reading the cookie instead of verifying it | Refresh in middleware/proxy with getClaims(), not getSession() |
| OAuth returns to the site signed out | redirectTo host is not in Supabase's allow-list, or the callback route never exchanges the code | Add 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 UI | A module-scope createBrowserClient() import in a shared component | Import 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.
