dev_tools
Back to guides

Next.js + Supabase

Build a full-stack Next.js app with Supabase Auth (email + social login), a PostgreSQL database, and proper SSR support using the @supabase/ssr package.

Next.js
Supabase
Auth
PostgreSQL
TypeScript
1

Create Next.js Project

Scaffold a new Next.js app with TypeScript and the App Router.

npx create-next-app@latest my-app --typescript --tailwind --app --src-dir
2

Install Supabase Packages

Install @supabase/supabase-js (core client) and @supabase/ssr (the SSR helper that handles cookies for Server Components and Route Handlers).

npm install @supabase/supabase-js @supabase/ssr
3

Create a Supabase Project and Get Keys

Go to supabase.com, create a project, then open Project Settings → API. Copy the Project URL and the anon public key.

.env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
4

Create the Browser Client Utility

This client is used in Client Components. createBrowserClient reads cookies automatically to hydrate the auth session in the browser.

src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
5

Create the Server Client Utility

This client is used in Server Components, Route Handlers, and Server Actions. It uses Next.js cookies() to read and write session cookies server-side.

src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  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) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            "text-muted-foreground italic">// setAll is called from a Server Component — cookies can't be
            "text-muted-foreground italic">// mutated there. The middleware handles session refresh instead.
          }
        },
      },
    }
  )
}
6

Add the Auth Middleware

Create src/middleware.ts. The middleware refreshes the Supabase session on every request so Server Components always see up-to-date auth state.

src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  let supabaseResponse = 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)
          )
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  "text-muted-foreground italic">// Refresh session — IMPORTANT: do not add logic between createServerClient
  "text-muted-foreground italic">// and getUser() or session refresh may fail
  const { data: { user } } = await supabase.auth.getUser()

  "text-muted-foreground italic">// Redirect unauthenticated users away from protected routes
  if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
7

Create Auth Server Actions

Server Actions are the recommended way to trigger auth flows in the App Router — no API route needed.

src/app/auth/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect }       from 'next/navigation'
import { createClient }   from '@/lib/supabase/server'

export async function login(formData: FormData) {
  const supabase = await createClient()

  const { error } = await supabase.auth.signInWithPassword({
    email:    formData.get('email') as string,
    password: formData.get('password') as string,
  })

  if (error) redirect('/login?error=' + encodeURIComponent(error.message))

  revalidatePath('/', 'layout')
  redirect('/dashboard')
}

export async function signup(formData: FormData) {
  const supabase = await createClient()

  const { error } = await supabase.auth.signUp({
    email:    formData.get('email') as string,
    password: formData.get('password') as string,
  })

  if (error) redirect('/signup?error=' + encodeURIComponent(error.message))

  redirect('/dashboard')
}

export async function signout() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  redirect('/login')
}
8

Create the Login Page

A simple login form that calls the login Server Action on submit.

src/app/login/page.tsx
import { login, signup } from '@/app/auth/actions'

export default function LoginPage({
  searchParams,
}: {
  searchParams: Promise<{ error?: string }>
}) {
  return (
    <main className="flex min-h-screen items-center justify-center">
      <form className="flex flex-col gap-3 w-full max-w-sm p-8 border rounded-xl">
        <h1 className="text-xl font-bold mb-2">Sign in</h1>

        <input
          name="email"
          type="email"
          placeholder="Email"
          required
          className="border rounded px-3 py-2 text-sm"
        />
        <input
          name="password"
          type="password"
          placeholder="Password"
          required
          className="border rounded px-3 py-2 text-sm"
        />

        <button formAction={login}
          className="rounded bg-black text-white py-2 text-sm font-medium mt-2">
          Sign in
        </button>
        <button formAction={signup}
          className="rounded border py-2 text-sm">
          Create account
        </button>
      </form>
    </main>
  )
}
9

Build a Protected Dashboard

Use the server client in a Server Component to get the current user. The middleware redirects unauthenticated requests before this runs.

src/app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { signout }      from '@/app/auth/actions'
import { redirect }     from 'next/navigation'

export default async function DashboardPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) redirect('/login')

  return (
    <main className="p-8 max-w-2xl mx-auto">
      <div className="flex items-center justify-between mb-8">
        <div>
          <h1 className="text-2xl font-bold">Dashboard</h1>
          <p className="text-muted-foreground text-sm mt-1">{user.email}</p>
        </div>
        <form action={signout}>
          <button type="submit"
            className="text-sm border rounded px-4 py-2 hover:bg-muted">
            Sign out
          </button>
        </form>
      </div>
      <p className="text-muted-foreground">
        You are signed in as <strong>{user.email}</strong>
      </p>
    </main>
  )
}
10

Query the Database

Supabase's auto-generated client lets you query your PostgreSQL tables directly — no raw SQL needed for common operations.

src/app/dashboard/page.tsx (with DB query)
import { createClient } from '@/lib/supabase/server'
import { redirect }     from 'next/navigation'

export default async function DashboardPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) redirect('/login')

  "text-muted-foreground italic">// Query a "posts" table — filter by the current user's ID
  const { data: posts, error } = await supabase
    .from('posts')
    .select('id, title, created_at')
    .eq('user_id', user.id)
    .order('created_at', { ascending: false })
    .limit(10)

  return (
    <main className="p-8 max-w-2xl mx-auto">
      <h1 className="text-2xl font-bold mb-6">Your Posts</h1>
      {posts?.map(post => (
        <div key={post.id} className="border rounded p-4 mb-3">
          <p className="font-medium">{post.title}</p>
          <p className="text-xs text-muted-foreground">
            {new Date(post.created_at).toLocaleDateString()}
          </p>
        </div>
      ))}
    </main>
  )
}
💡Create the 'posts' table in the Supabase Table Editor first. Enable Row Level Security (RLS) and add a policy: `auth.uid() = user_id`.
11

Start the Development Server

npm run dev
💡Visit http://localhost:3000/login to create an account and test the auth flow.

Found this guide helpful? Check out more tools and guides.

Browse All Guides