After this lesson, you will be able to: Stand up a Supabase project, connect the Supabase JS client to a Next.js app, read and write data with Row-Level Security enabled, and wire up email/password authentication end to end.
Supabase is an open-source backend-as-a-service built on PostgreSQL. It gives a Next.js app three things you'd otherwise build yourself: a managed Postgres database, authentication (email/password, OAuth, magic links), and storage for files. It's the modern open-source alternative to Firebase, and the default Supabase + Next.js + Vercel stack is what most new full-stack apps in 2025 use. This lesson takes you from never having opened the Supabase dashboard to a working database-backed, auth-protected Next.js feature.
Three reasons it's the default new-project backend in 2025. **Postgres, not a custom database.** You learn SQL skills that transfer to every other job. **Auth, storage, realtime, and edge functions on the same platform** — no stitching together five vendors for a starter project. **Open source.** You can self-host the same software if you need to. Firebase, the previous default, is closed-source and proprietary; that matters when you scale or audit. Supabase covers 90% of what a small-to-medium SaaS app needs. When you outgrow it, you migrate the database (it's just Postgres) without re-architecting.
Sign in at supabase.com, create a new project (free tier is enough), then go to Project Settings → API. You need TWO values for Next.js: the Project URL and the `anon` (public) key. The `service_role` key is the admin key — keep it server-side ONLY.
# In your Next.js appnpm install @supabase/supabase-js @supabase/ssr# .env.local (NEVER commit this file)NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.coNEXT_PUBLIC_SUPABASE_ANON_KEY=ey... # safe to expose to browserSUPABASE_SERVICE_ROLE_KEY=ey... # SERVER-ONLY. Never NEXT_PUBLIC_.
Modern Supabase + Next.js uses separate clients for Server Components (which handle cookies for auth) and Client Components. The `@supabase/ssr` package generates both.
// lib/supabase/server.ts — for Server Components and API routesimport { createServerClient } from "@supabase/ssr";import { cookies } from "next/headers";export async function getSupabaseServer() {const cookieStore = await cookies();return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!,process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,{cookies: {getAll() { return cookieStore.getAll(); },setAll(cookiesToSet) {for (const { name, value, options } of cookiesToSet) {cookieStore.set(name, value, options);}},},},);}// lib/supabase/client.ts — for Client Components"use client";import { createBrowserClient } from "@supabase/ssr";export function getSupabaseBrowser() {return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!,process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,);}
Create a table in Supabase (Table Editor → New Table → `notes` with `id`, `body`, `user_id`). Then read it from a page.
// app/notes/page.tsx — Server Componentimport { getSupabaseServer } from "@/lib/supabase/server";export default async function NotesPage() {const supabase = await getSupabaseServer();const { data: notes, error } = await supabase.from("notes").select("id, body, created_at").order("created_at", { ascending: false });if (error) return <p>Error: {error.message}</p>;return (<ul className="space-y-2">{notes?.map((n) => (<li key={n.id} className="border rounded p-3">{n.body}</li>))}</ul>);}
By default, the Supabase `anon` key can do ANYTHING to your tables. That's a problem the moment your app goes live. Row-Level Security is Postgres's built-in answer: define policies that say WHO can SELECT / INSERT / UPDATE / DELETE which rows. Once RLS is enabled on a table, the default is 'deny everything,' and your queries return empty until you add a permitting policy. Always enable RLS on every user-data table BEFORE you put real users on it. Forgetting is the most expensive Supabase mistake.
Run this in the Supabase SQL Editor. The policy says: a user can only SELECT/INSERT their own notes, identified by matching `user_id` to their auth ID.
-- 1. Enable RLSalter table notes enable row level security;-- 2. Anyone authenticated can read their own notescreate policy "read own notes"on notes for selectusing (auth.uid() = user_id);-- 3. Anyone authenticated can insert a note as themselvescreate policy "insert own notes"on notes for insertwith check (auth.uid() = user_id);-- 4. Anyone authenticated can update/delete their own notescreate policy "modify own notes"on notes for update using (auth.uid() = user_id);create policy "delete own notes"on notes for delete using (auth.uid() = user_id);
Supabase Auth ships email/password, magic links, and OAuth out of the box. Below is the minimum email/password flow as a Client Component form.
"use client";import { useState } from "react";import { getSupabaseBrowser } from "@/lib/supabase/client";export function AuthForm() {const supabase = getSupabaseBrowser();const [email, setEmail] = useState("");const [password, setPassword] = useState("");const [error, setError] = useState<string | null>(null);async function signUp() {setError(null);const { error } = await supabase.auth.signUp({ email, password });if (error) setError(error.message);else alert("Check your email to confirm.");}async function signIn() {setError(null);const { error } = await supabase.auth.signInWithPassword({ email, password });if (error) setError(error.message);else window.location.assign("/notes");}return (<form className="max-w-sm space-y-3" onSubmit={(e) => e.preventDefault()}><input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full border p-2 rounded" /><input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" className="w-full border p-2 rounded" />{error && <p className="text-red-600 text-sm">{error}</p>}<div className="flex gap-2"><button onClick={signIn} className="rounded bg-slate-900 px-4 py-2 text-white">Sign in</button><button onClick={signUp} className="rounded border px-4 py-2">Sign up</button></div></form>);}
Use the server client to check who's signed in. Protect routes by redirecting unauthenticated users.
// app/notes/page.tsx — gate behind authimport { redirect } from "next/navigation";import { getSupabaseServer } from "@/lib/supabase/server";export default async function NotesPage() {const supabase = await getSupabaseServer();const { data: { user } } = await supabase.auth.getUser();if (!user) redirect("/login");const { data: notes } = await supabase.from("notes").select("id, body").order("created_at", { ascending: false });return (<ul>{notes?.map((n) => <li key={n.id}>{n.body}</li>)}</ul>);}
Sign in and purchase access to unlock this lesson.