How to add Supabase auth to a Next.js app
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Email/password and magic-link login, fully wired up. ~30 minutes for a working signup/login/logout flow.
Goal
You have a Next.js app with Supabase already connected. At the end of this guide: users can sign up, log in, log out, stay logged in across reloads. Server Components know who’s logged in. RLS policies can use auth.uid() to filter data per user.
Prerequisites
- Supabase project set up — see Set up a Supabase project
- Next.js app with
@supabase/ssrinstalled -
.env.localwithNEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEY
Steps
1. Configure Supabase Auth in the dashboard
In Supabase: Authentication → Providers.
Email is enabled by default. For dev, that’s plenty.
Optional for later:
- Google / GitHub / Apple — social logins (each takes ~10 minutes to wire up)
- Magic Link — passwordless email login (already part of Email provider)
- Phone / SMS — uses Twilio (paid)
- Passkeys — biometric (Face ID, etc.)
For email confirmation in dev, go to Authentication → URL Configuration and add:
- Site URL:
http://localhost:3000 - Redirect URLs: add
http://localhost:3000/**(and later your production URL)
2. Add middleware for session refresh
Create middleware.ts at the project root:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
}
}
}
);
await supabase.auth.getUser();
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"]
};This refreshes the session cookie on every request — critical for keeping users logged in.
3. Build the login page
app/login/page.tsx:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase-client";
export default function LoginPage() {
const router = useRouter();
const supabase = createClient();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) setError(error.message);
else router.push("/");
};
return (
<form onSubmit={handleLogin}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required />
<button type="submit">Log in</button>
{error && <p>{error}</p>}
</form>
);
}4. Build the signup page
app/signup/page.tsx — almost identical, with signUp instead of signInWithPassword:
const { error } = await supabase.auth.signUp({ email, password });By default, Supabase sends a confirmation email. The user clicks the link to verify. If you want signup without confirmation (dev convenience): Authentication → Settings → Email Auth → Confirm email → disable.
5. Build logout
A button anywhere:
"use client";
import { createClient } from "@/lib/supabase-client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const supabase = createClient();
const router = useRouter();
return (
<button onClick={async () => {
await supabase.auth.signOut();
router.push("/login");
}}>
Log out
</button>
);
}6. Read the user in Server Components
// app/page.tsx
import { createClient } from "@/lib/supabase-server";
export default async function HomePage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return <p>You're not logged in. <a href="/login">Log in</a></p>;
}
return <p>Hello, {user.email}!</p>;
}7. Protect routes
Use middleware or per-page checks:
// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase-server";
export default async function Dashboard() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
return <p>Welcome to the dashboard, {user.email}</p>;
}8. Use auth.uid() in RLS policies
Now that users are logged in, RLS can identify them:
-- Users can only read their own posts
CREATE POLICY "Users read own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);
-- Users can only insert posts as themselves
CREATE POLICY "Users insert own posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);auth.uid() returns the logged-in user’s ID from the JWT Supabase issues at login. With the middleware refreshing sessions properly, every server-side request knows the user.
Verification
- ✅ Visit
/signup, create an account - ✅ Get the confirmation email (or skip if you disabled it)
- ✅ Visit
/login, log in - ✅ Homepage shows your email (proves Server Components know the user)
- ✅ Click logout — homepage shows “not logged in”
- ✅ Refresh the page after login — still logged in (proves session persistence)
- ✅ Close and reopen the browser — still logged in (proves cookies)
Common failures
Session lost on page reload
The middleware isn’t running. Check:
middleware.tsis at the project root (NOT insideapp/)- The
matcherpattern includes your routes getUser()is being called inside the middleware
”You’re not logged in” right after login
Same as above. Or: cookies() not being awaited in your server client helper. In modern Next.js, cookies() returns a Promise.
Confirmation email never arrives
Check spam. Or: redirect URL not configured properly in Supabase. Or: free-tier email service is rate-limited (10/hour). For production, configure a custom SMTP provider (Resend, SendGrid).
Password requirements
Supabase enforces a minimum (6 characters by default). Tweak in Authentication → Policies.
CSRF / origin errors
Site URL or redirect URL not configured. Set them in Authentication → URL Configuration.
auth.uid() is null in SQL Editor
Of course — you’re running SQL as the postgres user, not as a logged-in app user. Test RLS via the app or via the Studio’s “Test policy” feature.
What you’ve just built
A complete auth flow:
- Email + password signup, login, logout
- Sessions persist via cookies
- Server Components know who’s logged in
- RLS policies can reference
auth.uid() - Protected routes redirect when unauthenticated
To extend:
- Magic links:
supabase.auth.signInWithOtp({ email }) - Social login: enable a provider in Supabase, use
signInWithOAuth({ provider: 'google' }) - Password reset:
supabase.auth.resetPasswordForEmail(email) - User profiles: create a
profilestable with FK toauth.users.id, populate via a trigger
See also
- How-to: Set up Supabase 🟩
- How-to: Enable RLS 🟩
- Supabase 🟩 🟦
- Row-Level Security 🟥
- Authentication vs authorization 🟥
- Sessions & cookies 🟥
- Auth gotchas 🟥