Enable Supabase Auth for your app built with Sabo. You’ll configure Supabase, set env vars, verify preconfigured clients, protect routes, implement sign-in/sign-up (incl. OAuth and Magic Link), and support password recovery.
Quick Setup
Set up Supabase (Dashboard)
Create your project and configure Auth settings:
- Copy your Project URL and anon public key (Dashboard → Settings → API). You can start from the Supabase Dashboard.
- Set your Site URL and add
/auth/callback to Additional Redirect URLs for dev and prod (Dashboard → Auth → URL Configuration). Guide: Redirect URLs.
- Optional: enable your social providers (e.g., Google, GitHub, Apple, Facebook) and use
/auth/callback as the callback (Dashboard → Auth → Providers). See the full list: Social login. Example guide: Login with Google.
By default, Sabo uses a single /auth/callback to handle signup confirmation, password recovery, and OAuth code exchange in one place.
Multiple domains/apps? Add each origin to Additional Redirect URLs and expose a callback endpoint on every domain that needs to complete the code exchange. See Redirect URLs. Confirm: env keys are set, /auth/callback is whitelisted for the domains you use, and (if enabled) providers redirect back successfully.
Define environment variables
Add these environment variables to your local .env (or .env.local) and to your hosting provider.NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SECRET_KEY=
NEXT_PUBLIC_SITE_URL=http://localhost:3000
SUPABASE_SECRET_KEY is the server-only secret key (the modern replacement for the legacy service_role JWT). Never expose it to the browser—only server code such as webhooks or admin utilities should read it.
Run the app and ensure env vars are loaded (no undefined errors).
Verify preconfigured clients
Sabo already ships with multiple clients:
- Browser client:
src/lib/supabase/client.ts
- Server client (cookies wired):
src/lib/supabase/server.ts
You generally don’t need to modify these. Only change them if you’re customizing cookie strategy or adding admin utilities.Run locally and ensure there are no missing env errors for NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.
Route protection (summary)
Sabo applies a global Supabase auth middleware that:
- keeps auth cookies in sync for SSR,
- redirects signed-out users away from protected pages,
- redirects signed-in users away from auth pages.
Customize protected/auth routes and matchers as needed.For full patterns (matchers, includes/excludes, Stripe webhook exclusion, roles/tenants), see Routing & Middleware.
Authentication Flows
Sabo supports four main authentication flows:
- Email/Password - Traditional email and password sign-in/sign-up
- Magic Link - Passwordless authentication via email link
- OAuth - Social login with Google, GitHub, Apple
- Password Reset - Forgot password and reset flow
Each flow is fully implemented with server actions and UI pages.
Email/Password Flow
Sign In Flow
The email/password sign-in uses a two-step UX for better security and flexibility:
Step 1: Enter email (/sign-in)
User enters their email address on /sign-in page.src/app/(auth)/sign-in/page.tsx
"use client";
export default function SignInPage() {
const [email, setEmail] = useState("");
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Save email to sessionStorage
sessionStorage.setItem("signin-email", email);
// Navigate to confirm page
router.push("/sign-in/confirm");
};
return (
<form onSubmit={handleSubmit}>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email address"
required
/>
<Button type="submit">Continue</Button>
</form>
);
}
What happens:
- Email is validated (client-side)
- Email is stored in
sessionStorage for next step
- User is redirected to
/sign-in/confirm
Step 2: Choose authentication method (/sign-in/confirm)
User sees two options:
- Magic Link - Send a passwordless login link via email
- Password - Enter password to sign in
src/app/(auth)/sign-in/confirm/page.tsx
"use client";
import { signIn, signInWithMagicLink } from "../actions";
export default function SignInConfirmPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
useEffect(() => {
// Get email from sessionStorage
const savedEmail = sessionStorage.getItem("signin-email");
if (!savedEmail) {
router.push("/sign-in");
return;
}
setEmail(savedEmail);
}, []);
const handlePasswordSignIn = async (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData();
formData.append("email", email);
formData.append("password", password);
const result = await signIn(formData);
if (result?.error) {
setError(result.error);
}
// Success: redirects to /dashboard
};
const handleMagicLinkSend = async () => {
const result = await signInWithMagicLink(email);
if (result?.error) {
setError(result.error);
} else {
toast.success("Magic link sent!", {
description: "Check your email to sign in."
});
}
};
return (
<>
<Input type="email" value={email} disabled />
<Button onClick={handleMagicLinkSend}>
Send magic link
</Button>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
/>
<Button onClick={handlePasswordSignIn}>
Sign in
</Button>
</>
);
}
What happens:
- Email is retrieved from
sessionStorage and shown (disabled)
- User can choose Magic Link OR password
- Password sign-in → calls
signIn() server action → redirects to /dashboard
- Magic Link → calls
signInWithMagicLink() → sends email → user waits for email
Server action: signIn()
src/app/(auth)/actions.ts
export async function signIn(formData: FormData, redirectTo?: string | null) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { error: error.message };
}
revalidatePath("/", "layout");
redirect(redirectTo || "/dashboard");
}
What happens:
- Supabase verifies email/password
- If successful: sets session cookie and redirects to
/dashboard
- If failed: returns error message
The two-step UX allows users to choose their preferred authentication method while keeping the UI clean and focused.
Need to customize confirmation, magic link, or password reset emails? Follow Auth Emails with Resend for template editing, SMTP setup, and deliverability guidance.
Sign Up Flow
Sign-up follows a similar two-step pattern:
Step 1: Enter name and email (/sign-up)
User enters full name and email address.src/app/(auth)/sign-up/page.tsx
"use client";
export default function SignUpPage() {
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Save data to sessionStorage
sessionStorage.setItem("signup-fullname", fullName);
sessionStorage.setItem("signup-email", email);
// Navigate to confirm page
router.push("/sign-up/confirm");
};
return (
<form onSubmit={handleSubmit}>
<Input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Your name"
required
/>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email address"
required
/>
<Button type="submit">Continue</Button>
</form>
);
}
Step 2: Choose registration method (/sign-up/confirm)
User chooses:
- Magic Link - Complete registration via email link (no password needed)
- Password - Create a password-based account
src/app/(auth)/sign-up/confirm/page.tsx
"use client";
import { signUp, signInWithMagicLink } from "../actions";
export default function SignUpConfirmPage() {
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
useEffect(() => {
const savedFullName = sessionStorage.getItem("signup-fullname");
const savedEmail = sessionStorage.getItem("signup-email");
if (!savedFullName || !savedEmail) {
router.push("/sign-up");
return;
}
setFullName(savedFullName);
setEmail(savedEmail);
}, []);
const handlePasswordSignUp = async (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
setError("Password must be at least 8 characters long.");
return;
}
const formData = new FormData();
formData.append("fullName", fullName);
formData.append("email", email);
formData.append("password", password);
const result = await signUp(formData);
if (result?.error) {
setError(result.error);
} else {
router.push("/sign-in?signup=success");
}
};
const handleMagicLinkSend = async () => {
const result = await signInWithMagicLink(email);
if (result?.error) {
setError(result.error);
} else {
toast.success("Magic link sent!", {
description: "Check your email to complete your registration."
});
}
};
return (
<>
<Input type="text" value={fullName} disabled />
<Input type="email" value={email} disabled />
<Button onClick={handleMagicLinkSend}>
Continue with magic link
</Button>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password"
/>
<Button onClick={handlePasswordSignUp}>
Create account
</Button>
</>
);
}
Server action: signUp()
src/app/(auth)/actions.ts
export async function signUp(formData: FormData) {
const fullName = formData.get("fullName") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
},
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (error) {
return { error: error.message };
}
revalidatePath("/", "layout");
return { success: true };
}
What happens:
- Supabase creates new user account
- Sends confirmation email with link to
/auth/callback
- User must click email link to activate account
- After activation: redirected to
/dashboard
By default, Supabase requires email confirmation for new accounts. Users must click the link in their email before they can sign in.To disable email confirmation (not recommended for production), go to Supabase Dashboard → Authentication → Settings → Email Auth → Disable “Enable email confirmations”.
Magic Link Flow
Magic Links provide passwordless authentication - users receive an email with a one-time login link.
User requests magic link
From either /sign-in/confirm or /sign-up/confirm, user clicks “Send magic link” button.const handleMagicLinkSend = async () => {
const result = await signInWithMagicLink(email);
if (result?.error) {
setError(result.error);
} else {
toast.success("Magic link sent!", {
description: "Check your email to sign in."
});
}
};
Server action sends email
src/app/(auth)/actions.ts
export async function signInWithMagicLink(
email: string,
redirectTo?: string | null
) {
const supabase = await createClient();
const callbackUrl = redirectTo
? `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=${encodeURIComponent(redirectTo)}`
: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`;
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: callbackUrl,
},
});
if (error) {
return { error: error.message };
}
return { success: true };
}
What happens:
- Supabase sends email with magic link
- Email contains link like:
https://yourapp.com/auth/callback?token_hash=abc123&type=email
- User clicks link in email
Callback handles magic link
User clicks magic link → browser opens /auth/callback?token_hash=...&type=emailsrc/app/auth/callback/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const supabase = await createClient();
// Email/Magic Link flow
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
// Redirect to dashboard after email verification
if (type === "signup" || type === "email") {
return NextResponse.redirect(`${origin}/dashboard`);
}
}
}
// On failure: redirect to sign-in with error
return NextResponse.redirect(`${origin}/sign-in?error=auth_failed`);
}
What happens:
- Supabase verifies the OTP token
- If valid: creates session and redirects to
/dashboard
- If invalid/expired: redirects to
/sign-in?error=auth_failed
Magic Links are ideal for:
- Mobile-first apps (no password typing on small screens)
- Security-conscious users (no password to remember/steal)
- Quick sign-in from email (one-click login)
Magic Links expire after a certain time (default: 1 hour). If users click an expired link, they’ll see an error and must request a new one.Configure expiration in Supabase Dashboard → Authentication → Settings → Email → “Magic Link expiration”.
OAuth Flow
Sabo supports OAuth social login with Google, GitHub, and Apple.
Configure OAuth providers (Supabase)
- Go to Supabase Dashboard → Authentication → Providers
- Enable your desired providers (Google, GitHub, Apple)
- For each provider, set:
- Client ID (from provider’s developer console)
- Client Secret (from provider’s developer console)
- Redirect URL: Use Supabase’s callback URL (automatically provided)
User clicks OAuth button
From /sign-in or /sign-up pages:src/components/auth/oauth-buttons.tsx
"use client";
export function OAuthButtons({ onOAuthSignIn }) {
return (
<>
<Button onClick={() => onOAuthSignIn("google")}>
<GoogleIcon /> Continue with Google
</Button>
<Button onClick={() => onOAuthSignIn("github")}>
<GitHubIcon /> Continue with GitHub
</Button>
<Button onClick={() => onOAuthSignIn("apple")}>
<AppleIcon /> Continue with Apple
</Button>
</>
);
}
Server action initiates OAuth
src/app/(auth)/actions.ts
export async function signInWithOAuth(
provider: "google" | "github" | "apple",
redirectTo?: string | null
) {
const supabase = await createClient();
const callbackUrl = redirectTo
? `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=${encodeURIComponent(redirectTo)}`
: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`;
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: callbackUrl,
},
});
if (error) {
return { error: error.message };
}
if (data.url) {
redirect(data.url);
}
}
What happens:
- Supabase generates OAuth URL for the provider
- User is redirected to provider’s login page (e.g., Google sign-in)
- User grants permission to your app
Provider redirects to callback
After user grants permission, provider redirects to:https://yourapp.com/auth/callback?code=abc123&type=signup
src/app/auth/callback/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const type = searchParams.get("type");
const supabase = await createClient();
// OAuth flow
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
// Redirect to dashboard after OAuth signup
if (type === "signup") {
return NextResponse.redirect(`${origin}/dashboard`);
}
// Or redirect to custom location
return NextResponse.redirect(`${origin}${next}`);
}
}
return NextResponse.redirect(`${origin}/sign-in?error=auth_failed`);
}
What happens:
- Callback exchanges OAuth code for session
- Creates user account if first-time sign-in
- Redirects to
/dashboard (or custom next URL)
OAuth sign-in automatically creates a user account if it doesn’t exist. No separate sign-up flow needed.User data (name, email, avatar) is fetched from the OAuth provider and stored in Supabase.
Password Reset Flow
Users who forget their password can reset it via email.
User requests password reset (/forgot-password)
src/app/(auth)/forgot-password/page.tsx
"use client";
import { sendResetEmail } from "../actions";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = await sendResetEmail(email);
if (result?.error) {
setError(result.error);
} else {
// Redirect to sign-in with success message
router.push("/sign-in?reset=true");
}
};
return (
<form onSubmit={handleSubmit}>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email address"
required
/>
<Button type="submit">Send reset link</Button>
</form>
);
}
Server action sends reset email
src/app/(auth)/actions.ts
export async function sendResetEmail(email: string) {
const supabase = await createClient();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
});
if (error) {
return { error: error.message };
}
return { success: true };
}
What happens:
- Supabase sends password reset email
- Email contains link like:
https://yourapp.com/auth/callback?token_hash=xyz&type=recovery
- User clicks link
Callback redirects to reset page
User clicks reset link → /auth/callback?token_hash=...&type=recoverysrc/app/auth/callback/route.ts
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
// Redirect to reset-password page if recovery type
if (type === "recovery") {
return NextResponse.redirect(`${origin}/reset-password`);
}
}
}
What happens:
- Callback verifies reset token
- If valid: redirects to
/reset-password
- User is now authenticated with a temporary session
User enters new password (/reset-password)
src/app/(auth)/reset-password/page.tsx
"use client";
import { resetPassword } from "../actions";
export default function ResetPasswordPage() {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
setError("Password must be at least 8 characters long.");
return;
}
if (password !== confirmPassword) {
setError("Passwords don't match.");
return;
}
const result = await resetPassword(password);
if (result?.error) {
setError(result.error);
}
// Success: redirects to /sign-in
};
return (
<form onSubmit={handleSubmit}>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="New password"
required
/>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm password"
required
/>
<Button type="submit">Reset password</Button>
</form>
);
}
Server action updates password
src/app/(auth)/actions.ts
export async function resetPassword(newPassword: string) {
const supabase = await createClient();
const { error } = await supabase.auth.updateUser({
password: newPassword,
});
if (error) {
return { error: error.message };
}
revalidatePath("/", "layout");
redirect("/sign-in");
}
What happens:
- Supabase updates user’s password
- Session is maintained (user is still authenticated)
- Redirects to
/sign-in with success message
- User can now sign in with new password
Password reset links expire after 1 hour by default. If a user clicks an expired link, they’ll need to request a new one.Configure expiration in Supabase Dashboard → Authentication → Settings → Email → “Password recovery expiration”.
Profile fields (name, avatar, notification preferences) live in Supabase tables. Review Database with Supabase to see how auth events populate user_profiles and related tables.
Auth Callback Route
The /auth/callback route is the central hub for all authentication flows. It handles:
- OAuth code exchange
- Magic link verification
- Email confirmation
- Password reset links
src/app/auth/callback/route.ts
import { type EmailOtpType } from "@supabase/supabase-js";
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
// Get "next" redirect parameter (default: /dashboard)
let next = searchParams.get("next") ?? "/dashboard";
// Only allow relative paths for security
if (!next.startsWith("/")) {
next = "/dashboard";
}
const supabase = await createClient();
// OAuth flow (when code parameter exists)
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
// Redirect to reset-password page if recovery type
if (type === "recovery") {
return NextResponse.redirect(`${origin}/reset-password`);
}
// Redirect to dashboard after OAuth signup
if (type === "signup") {
return NextResponse.redirect(`${origin}/dashboard`);
}
// By default, redirect to next parameter or dashboard
return NextResponse.redirect(`${origin}${next}`);
}
}
// Email/Magic Link flow (when token_hash parameter exists)
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
// Redirect to reset-password page if recovery type
if (type === "recovery") {
return NextResponse.redirect(`${origin}/reset-password`);
}
// Redirect to dashboard after email verification for signup
if (type === "signup" || type === "email") {
return NextResponse.redirect(`${origin}/dashboard`);
}
// For other types, use next parameter
return NextResponse.redirect(`${origin}${next}`);
}
}
// Redirect to sign-in page with error parameter on failure
return NextResponse.redirect(`${origin}/sign-in?error=auth_failed`);
}
The next parameter allows you to redirect users to a specific page after authentication. For example:/sign-in → Save "next=/pricing" to sessionStorage
→ OAuth flow → /auth/callback?code=...&next=/pricing
→ Redirects to /pricing after sign-in
This is useful for:
- Deep linking (e.g., “Sign in to view this post”)
- Checkout flows (e.g., “Sign in to subscribe”)
- Protected resources (e.g., “Sign in to download”)
The next parameter only accepts relative paths (starting with /) to prevent open redirect vulnerabilities.External URLs are automatically sanitized to /dashboard.
Error Handling & UI Patterns
Loading States
All auth actions should show loading indicators:
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
setIsLoading(true);
try {
const result = await signIn(formData);
// Handle result
} finally {
setIsLoading(false);
}
};
<Button disabled={isLoading}>
{isLoading ? (
<>
<Spinner />
Signing in...
</>
) : (
"Sign in"
)}
</Button>
Error States
Display errors from server actions:
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setError(null);
const result = await signIn(formData);
if (result?.error) {
setError(result.error);
}
};
{error && (
<div className="text-sm text-red-500">
{error}
</div>
)}
Toast Notifications
Use sonner for success messages:
import { toast } from "sonner";
const handleMagicLinkSend = async () => {
const result = await signInWithMagicLink(email);
if (result?.error) {
setError(result.error);
} else {
toast.success("Magic link sent!", {
description: "Check your email to sign in."
});
}
};
Client-side validation before calling server actions:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Email validation
if (!email.includes("@")) {
setError("Please enter a valid email address.");
return;
}
// Password validation
if (password.length < 8) {
setError("Password must be at least 8 characters long.");
return;
}
// Password match validation
if (password !== confirmPassword) {
setError("Passwords don't match.");
return;
}
// Clear error and proceed
setError(null);
const result = await signUp(formData);
// ...
};
Query Parameter Banners
Show contextual messages based on URL parameters:
src/app/(auth)/sign-in/page.tsx
const searchParams = useSearchParams();
const resetParam = searchParams.get("reset");
const signupParam = searchParams.get("signup");
const errorParam = searchParams.get("error");
{resetParam === "true" && (
<Alert>
<AlertTitle>Password reset email sent!</AlertTitle>
<AlertDescription>
Please check your email and follow the instructions to reset your password.
</AlertDescription>
</Alert>
)}
{signupParam === "success" && (
<Alert>
<AlertTitle>Account created successfully!</AlertTitle>
<AlertDescription>
Please check your email and activate your account before signing in.
</AlertDescription>
</Alert>
)}
{errorParam === "auth_failed" && (
<Alert variant="destructive">
<AlertTitle>Authentication failed!</AlertTitle>
<AlertDescription>
Please try again.
</AlertDescription>
</Alert>
)}
Auth Routes Summary
| Route | Purpose | Redirects to |
|---|
/sign-in | Email entry (step 1) | /sign-in/confirm |
/sign-in/confirm | Password or Magic Link (step 2) | /dashboard (success) |
/sign-up | Name + email entry (step 1) | /sign-up/confirm |
/sign-up/confirm | Password or Magic Link (step 2) | /sign-in?signup=success |
/forgot-password | Request password reset email | /sign-in?reset=true |
/reset-password | Enter new password (after email link) | /sign-in (success) |
/auth/callback | OAuth & email verification handler | /dashboard or /reset-password |