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/ssr installed
  • .env.local with NEXT_PUBLIC_SUPABASE_URL and NEXT_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.ts is at the project root (NOT inside app/)
  • The matcher pattern 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 profiles table with FK to auth.users.id, populate via a trigger

See also

Sources