Authentication
The topic beginners are most afraid of and most likely to get wrong. The fix is to never roll your own — pick a managed auth provider and follow its docs exactly.
When to use this
- The user wants to add login, signup, or user accounts.
- The user is hitting "how do I know who's logged in" problems.
- The user has built their own auth and wants to migrate to something safer.
- The user is adding social login (Google, GitHub, Apple, etc.).
Stack-aware
This skill works for two common auth paths:
- Stack A (Clerk): Best DX, drop-in components, generous free tier. Best when you want to ship in a day and don't mind a paid tier later.
- Stack B (Supabase Auth): Free, includes the database, works with Render, OAuth-only signups are easy. Best when Supabase is already your backend OR you want zero per-user fees.
Both are production-grade. Stack A is faster to wire up; Stack B is cheaper at scale and more flexible for indie projects. Both playbooks below.
Pick the right provider
Match the provider to the stack — there's a clear right answer per stack:
| Stack | Recommended | Why |
|---|---|---|
| Next.js (App Router) | Clerk | Best DX, drop-in components, generous free tier |
| Next.js (alt) | Better Auth | Self-hosted, owns the database, growing fast |
| Next.js (alt) | Auth.js (NextAuth v5) | Mature, free, more setup |
| Vue / Nuxt | Supabase Auth | Tightly integrated, easy |
| SvelteKit | Lucia or Supabase Auth | Both work well |
| Plain React | Clerk or Supabase Auth | Both have React SDKs |
| Mobile (Expo) | Clerk or Supabase Auth | Both have native SDKs |
| Custom backend | Better Auth or Lucia | Library-based, you own the storage |
Never roll your own for a beginner project. Password hashing, session tokens, CSRF, password reset flows, account recovery, MFA, suspicious activity detection — all of these are landmines. Managed providers handle them all for free.
The Clerk + Next.js playbook (recommended path)
Step 1 — Install
npm install @clerk/nextjs
Step 2 — Add env vars
Get these from clerk.com → create an app → API keys.
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
Step 3 — Wrap the app
// app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs";
export default function RootLayout({ children }) {
return (
<ClerkProvider>
<html>
<body>{children}</body>
</html>
</ClerkProvider>
);
}
Step 4 — Add middleware
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtectedRoute = createRouteMatcher(["/dashboard(.*)"]);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) await auth.protect();
});
export const config = {
matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};
Step 5 — Sign-in / sign-up pages
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return <SignIn />;
}
// app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from "@clerk/nextjs";
export default function Page() {
return <SignUp />;
}
Step 6 — Show the user button in the navbar
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</header>
Step 7 — Read the user in a server component
// app/dashboard/page.tsx
import { currentUser } from "@clerk/nextjs/server";
export default async function Dashboard() {
const user = await currentUser();
if (!user) return null; // middleware already protects this
return <h1>Welcome, {user.firstName}</h1>;
}
That's the entire setup. Social login (Google, GitHub, etc.) is enabled in the Clerk dashboard with a single toggle — no code changes needed.
The Supabase Auth playbook (for non-Clerk projects)
Step 1 — Install
npm install @supabase/supabase-js @supabase/ssr
Step 2 — Env vars
NEXT_PUBLIC_SUPABASE_URL=https://...supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=ey...
Step 3 — Create the clients
Follow the @supabase/ssr docs exactly — there's a browser client, a server
client, and a middleware client. Put them in src/lib/supabase/.
Step 4 — Sign-in page
"use client";
import { createBrowserClient } from "@supabase/ssr";
export function LoginForm() {
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
async function handleLogin(e) {
e.preventDefault();
const { error } = await supabase.auth.signInWithPassword({
email: e.target.email.value,
password: e.target.password.value,
});
if (error) console.error(error);
else window.location.href = "/dashboard";
}
return (
<form onSubmit={handleLogin}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button>Sign in</button>
</form>
);
}
Step 5 — Read the user
// In a server component
import { createServerClient } from "@/lib/supabase/server";
export default async function Dashboard() {
const supabase = await createServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
return <h1>Welcome, {user.email}</h1>;
}
Social login
With Clerk: enable each provider in the dashboard. Done.
With Supabase: enable each provider in the dashboard, then add a button:
<button onClick={() => supabase.auth.signInWithOAuth({ provider: "google" })}>
Sign in with Google
</button>
You'll also need an auth callback route:
// app/auth/callback/route.ts
import { createServerClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const { searchParams, origin } = new URL(req.url);
const code = searchParams.get("code");
if (code) {
const supabase = await createServerClient();
await supabase.auth.exchangeCodeForSession(code);
}
return NextResponse.redirect(`${origin}/dashboard`);
}
OAuth-only signup (no email/password) [Stack B]
For most indie projects, email/password is more trouble than it's worth: you have to handle password reset flows, account recovery, email verification, spam signups, and the entire "I forgot my password" support burden.
The simpler path: only allow Google OAuth (or Google + GitHub). No password ever exists. Account recovery is "click Sign in with Google again".
// app/login/page.tsx
"use client";
import { createBrowserClient } from "@supabase/ssr";
export default function LoginPage() {
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
async function signInWithGoogle() {
await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
}
return (
<div className="max-w-md mx-auto py-20 text-center">
<h1 className="text-3xl font-bold mb-4">Sign in</h1>
<p className="text-muted mb-8">
We use Google to sign you in. No password to remember.
</p>
<button
onClick={signInWithGoogle}
className="inline-flex items-center gap-3 px-6 py-3 rounded-xl border border-border bg-surface hover:bg-surface-alt font-semibold"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">{/* Google icon */}</svg>
Continue with Google
</button>
</div>
);
}
Steps to enable in Supabase:
- Supabase dashboard → Authentication → Providers → Google → Enable
- Add your Client ID + Secret from console.cloud.google.com
- In Google Cloud Console, add your prod domain to Authorized redirect URIs:
https://yoursite.com/auth/callbackANDhttps://<project>.supabase.co/auth/v1/callback - Done. No email templates, no password rules, no recovery flow to build.
The whole signup-and-onboarding pattern for OAuth-only sites:
- User clicks "Sign in with Google" → bounces through Google → lands on
/auth/callback - Callback exchanges the code for a session, creates a Supabase user
- Trigger or server-side check: if first time, insert a row into
user_profileswith empty username - Redirect to
/onboardingif profile is incomplete (no username yet), else to/dashboard - Onboarding form collects username, avatar, optional fields → updates the profile row → redirects to dashboard
This is the chokchok pattern (skin type instead of username) and it works beautifully — minimal friction, no password support tickets, and you get real users instead of throwaway emails.
Email-allowlist admin auth [Stack B]
For internal admin pages (/admin), the cheapest pattern is **Google OAuth
- a hardcoded allowlist of admin emails**. No roles table, no permissions system, no extra UI. Five lines of code.
// app/admin/layout.tsx
import { redirect } from "next/navigation";
import { createServerClient } from "@/lib/supabase/server";
const ADMIN_EMAILS = new Set([
"you@yoursite.com",
"cofounder@yoursite.com",
]);
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login?next=/admin");
if (!ADMIN_EMAILS.has(user.email ?? "")) redirect("/");
return <>{children}</>;
}
Why this is fine:
- The allowlist lives in code, not the database — it's version-controlled and tamper-proof
- It works for 1-10 admins (a real CMS pattern only matters at 50+ admins)
- Adding a new admin = a one-line PR + redeploy
- No "permission denied" UI needed — non-admins are silently redirected
- Combine with RLS policies on Supabase tables that check
auth.email() = ANY(ARRAY[...])for an extra layer
Once you outgrow it (more than ~10 admins, or you need granular roles):
move the allowlist to a user_roles table and check role membership instead.
The transition is 30 lines of code.
What good looks like
- New users can sign up with email/password and verify their email
- Existing users can sign in
- The session persists across page reloads and browser restarts
- Sign-out clears the session and redirects to home
- Protected routes redirect to sign-in when accessed without auth
- The user's name and avatar appear in the navbar when signed in
- Password reset works end-to-end
- Social login takes one click
Common mistakes to avoid
- Storing passwords yourself. Never. Use a managed provider.
- Putting auth checks only in client components. Auth must be enforced on the server (middleware, server components, route handlers). Client checks are UI hints only — they can be bypassed.
- Forgetting Row Level Security with Supabase. Without RLS, your auth is decorative.
- Hardcoding redirects. Use the provider's "redirect after sign-in" config so deep links work.
- Skipping email verification. Spam accounts will pile up.
- Using
getSession()instead ofgetUser()in Supabase server code.getUser()validates the JWT;getSession()trusts the cookie. - Not adding the production domain to OAuth allowed redirects. Sign-in with Google will fail in prod until you do.
- Mixing auth providers mid-project. Migrating users between providers is painful. Pick once and commit.
Going deeper
- Authentication: https://www.codebooks.ai/authentication
- Social Login: https://www.codebooks.ai/social-login
- Security Basics: https://www.codebooks.ai/security
