Supabase UI vs Hand-Rolled Auth: What 812 Lines Actually Buy
This storefront's auth stack is 812 lines of Server Actions, not the Supabase UI library. What the library would replace, and what it wouldn't touch at all.
Supabase UI is a component library — shadcn/ui-compatible blocks for auth forms, file drop zones and realtime widgets, dropped into a Next.js or React app via the shadcn CLI. It gets you a password sign-up screen fast. This storefront's own auth stack — email/password, Google OAuth, password reset, session refresh — is 812 lines of hand-written Server Actions and forms, none of them from that library, and the reasons are specific enough to be worth stating rather than assuming.
What "hand-written" actually measures
$ wc -l src/lib/actions/auth.ts src/proxy.ts src/lib/supabase/*.ts \
src/components/molecules/AuthCard.tsx \
src/app/{login,signup,forgot-password,reset-password}/page.tsx \
src/app/auth/callback/route.ts
158 src/lib/actions/auth.ts
42 src/proxy.ts
31 src/lib/supabase/server.ts
9 src/lib/supabase/client.ts
15 src/lib/supabase/admin.ts
47 src/lib/supabase/lazyClient.ts
112 src/components/molecules/AuthCard.tsx
114 src/app/login/page.tsx
125 src/app/signup/page.tsx
63 src/app/forgot-password/page.tsx
70 src/app/reset-password/page.tsx
26 src/app/auth/callback/route.ts
812 total
That's every line involved in getting a visitor from an empty form to a valid session: five Server Actions (signInWithPassword, signUpWithPassword, signInWithGoogle, requestPasswordReset, updatePassword), four pages, one shared card molecule, one OAuth callback route, and the Supabase client wiring itself. Supabase UI's Password-Based Authentication block would replace a meaningful slice of that — the form markup and the client-side call to supabase.auth.signInWithPassword — but not the middle layer this codebase actually leans on: Server Actions that validate input, translate Supabase's error into a message that doesn't leak account existence, and route the redirect.
Where the library and this codebase diverge
| Supabase UI blocks | This codebase | |
|---|---|---|
| Where auth calls happen | Client-side, direct supabase.auth.* calls from the component | Server Actions ("use server") — src/lib/actions/auth.ts |
| Session refresh | Left to your app's setup | src/proxy.ts on every request, via getClaims() |
| Error message shape | Whatever the SDK returns, typically surfaced as-is | Deliberately generic ("Incorrect email or password") to prevent account enumeration |
| Styling | shadcn/ui primitives, Tailwind, customizable | This repo's own AuthCard molecule and Button atom |
| Install path | npx shadcn add <block-url> | Already-written first-party code |
| What it saves | The first working screen | Nothing — it's additive, not a replacement for the actions layer |
The real divergence isn't styling — it's where the Supabase call happens. A block that calls supabase.auth.signInWithPassword from a Client Component is fine for many apps. This one deliberately keeps every auth call behind a Server Action so the credential never has a code path where a client-side script decides what happens with it beyond submitting a form.
The part a component library can't ship for you: the error message
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…" };
}
requestPasswordReset carries the identical rule for a different reason — it always returns "If an account exists for that email, a reset link is on its way," whether or not the email is registered. A UI block gives you a form and a submit handler; it has no opinion about whether your app's error copy leaks which emails have accounts. That decision belongs to whoever owns the product's threat model, which is exactly the layer a drop-in component doesn't reach.
The session refresh a form library doesn't touch at all
None of this works without src/proxy.ts running on every request:
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { /* … */ } },
);
await supabase.auth.getClaims();
return response;
}
getClaims(), never getSession() — the former validates the JWT, the latter trusts whatever cookie is present without checking it's still valid. This is the piece that makes every Server Component's read of "is this user signed in" trustworthy, and it has nothing to do with which library rendered the sign-in form. Whether you adopt Supabase UI's blocks or write your own forms, this proxy (or its Pages Router/middleware equivalent) is the part that actually keeps sessions correct, and it ships with neither approach — you write it once, regardless.
The cost neither approach avoids for free: shipping the SDK at all
Whether the sign-in form comes from Supabase UI or from AuthCard.tsx, both ultimately call @supabase/ssr + auth-js in the browser — and that pairing is roughly 68 KiB over the wire, 255 KiB parsed. Two call sites on this site used to import it at module scope regardless of whether the visitor had a session: Header, which renders on every page, and useOwnedProducts, which runs behind every templates grid. That put the whole auth stack in the initial bundle of /, /blog, /docs and /pricing — pages with no account UI at all.
src/lib/supabase/lazyClient.ts exists to undo that. loadSupabaseClient() dynamically imports the client only inside the effect that needs it, and hasAuthCookie() — a synchronous regex over document.cookie for the sb-<project-ref>-auth-token cookie Supabase's own client sets — skips even that import for a visitor who was never signed in:
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}
That's a rendering shortcut only — it can never grant access, since every real authorization check (authorizeDownload, the RLS policies) stays server-side regardless of what this probe returns. But it's the piece a component library has no reason to ship: Supabase UI's job is the form, not the question of whether your marketing pages should pay for the auth SDK before anyone has clicked "Sign in."
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Signed-in state is stale or wrong in a Server Component | Session read via getSession(), which doesn't validate the JWT | Use getClaims() in the proxy/middleware, as this repo does |
| Login error reveals whether an email is registered | Surfacing Supabase's raw error message | Return one generic message for every credential failure |
| Password reset confirms account existence | Different response for "email found" vs "not found" | Always return the same "if an account exists…" message |
| A client-side auth call works locally, fails behind the CSP in production | Supabase's origin isn't in connect-src | Add it — this repo derives it from NEXT_PUBLIC_SUPABASE_URL in next.config.ts so it's correct per environment |
| OAuth redirect lands on the wrong page after login | next param not carried through emailRedirectTo/redirectTo | Thread a validated next (see safeNext()) through signup, Google OAuth, and the /auth/callback route |
Frequently asked questions
Is Supabase UI worth using instead of writing this by hand? For the first working screen, yes — it gets a styled sign-up form in minutes. What it doesn't replace is the Server Action layer that decides error copy, redirect targets, and session-refresh correctness; you still write or adapt that part regardless of where the form markup came from.
Does Supabase UI handle session refresh for you?
No. Session refresh — keeping the JWT valid across requests via getClaims() — is a proxy/middleware concern, independent of which component library rendered the form.
Why not just use the client-side supabase.auth.signInWithPassword call directly from a component?
You can, and Supabase UI's blocks do exactly that. This codebase routes it through a Server Action instead so the credential submission, the error-message policy, and the redirect logic live in one server-side place rather than being decided by whatever calls the client SDK.
Is 812 lines a lot for auth?
It's five Server Actions, four pages, and the Supabase client wiring — most of it is straightforward form handling and validation, not custom cryptography. The number matters less than what's in it: the account-enumeration guard and the getClaims() refresh are the two pieces you'd still need to add on top of any UI library.
Does adding Supabase UI make the client bundle bigger?
Not by itself — the blocks are markup and Tailwind classes, and the SDK call they make is the same @supabase/ssr client this codebase already ships. The bundle cost comes from the SDK, not the form; whether that cost loads on every page or only where a visitor is actually signed in is a separate decision, and it's the one lazyClient.ts makes here.
Templates in this post
ASoc Zenith is an AI-led growth-marketing studio site with case studies and partner badges. ASoc Aegis is a risk-management-software marketing site with a comparison pricing table. ASoc Ally is an AI support-chatbot marketing site with a live widget preview and tiered pricing. Each is the marketing front for a SaaS product whose actual sign-up screen — free trial, dashboard login — would sit behind auth code shaped like the Server Actions above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
