Supabase with Next.js: Complete Tutorial for 2025
ID | EN

Supabase with Next.js: Complete Tutorial for 2025

Kamis, 16 Jan 2025

Supabase is an open-source Firebase alternative that provides a PostgreSQL database, authentication, real-time subscriptions, and storage. Combined with Next.js, it’s a powerful stack for building full-stack applications.

Project Setup

Create Next.js App

npx create-next-app@latest my-supabase-app --typescript --tailwind --app
cd my-supabase-app

Install Supabase

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

Environment Variables

Create .env.local:

NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

Get these values from your Supabase project dashboard under Settings > API.

Supabase Client Configuration

Browser Client

Create 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!
  )
}

Server Client

Create lib/supabase/server.ts:

import { createServerClient, type CookieOptions } 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 {
            // Handle server component cookie setting
          }
        },
      },
    }
  )
}

Middleware for Auth

Create middleware.ts in your project root:

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, options }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const {
    data: { user },
  } = await supabase.auth.getUser()

  // Protect 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|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

Authentication

Sign Up with Email

// app/auth/signup/page.tsx
'use client'

import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'

export default function SignUpPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [message, setMessage] = useState('')
  const supabase = createClient()

  const handleSignUp = async (e: React.FormEvent) => {
    e.preventDefault()
    
    const { error } = await supabase.auth.signUp({
      email,
      password,
      options: {
        emailRedirectTo: `${location.origin}/auth/callback`,
      },
    })

    if (error) {
      setMessage(error.message)
    } else {
      setMessage('Check your email for the confirmation link!')
    }
  }

  return (
    <form onSubmit={handleSignUp} className="space-y-4 max-w-md mx-auto p-8">
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        className="w-full p-2 border rounded"
        required
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        className="w-full p-2 border rounded"
        required
      />
      <button type="submit" className="w-full bg-blue-500 text-white p-2 rounded">
        Sign Up
      </button>
      {message && <p className="text-center">{message}</p>}
    </form>
  )
}

Sign In

// app/auth/login/page.tsx
'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const router = useRouter()
  const supabase = createClient()

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    
    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    })

    if (error) {
      setError(error.message)
    } else {
      router.push('/dashboard')
      router.refresh()
    }
  }

  return (
    <form onSubmit={handleLogin} className="space-y-4 max-w-md mx-auto p-8">
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        className="w-full p-2 border rounded"
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        className="w-full p-2 border rounded"
      />
      <button type="submit" className="w-full bg-blue-500 text-white p-2 rounded">
        Sign In
      </button>
      {error && <p className="text-red-500 text-center">{error}</p>}
    </form>
  )
}

OAuth (Google, GitHub)

'use client'

import { createClient } from '@/lib/supabase/client'

export function OAuthButtons() {
  const supabase = createClient()

  const signInWithGoogle = async () => {
    await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${location.origin}/auth/callback`,
      },
    })
  }

  const signInWithGitHub = async () => {
    await supabase.auth.signInWithOAuth({
      provider: 'github',
      options: {
        redirectTo: `${location.origin}/auth/callback`,
      },
    })
  }

  return (
    <div className="space-y-2">
      <button onClick={signInWithGoogle} className="w-full p-2 border rounded">
        Continue with Google
      </button>
      <button onClick={signInWithGitHub} className="w-full p-2 border rounded">
        Continue with GitHub
      </button>
    </div>
  )
}

Auth Callback Route

Create app/auth/callback/route.ts:

import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/dashboard'

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`)
    }
  }

  return NextResponse.redirect(`${origin}/auth/error`)
}

Sign Out

'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'

export function SignOutButton() {
  const router = useRouter()
  const supabase = createClient()

  const handleSignOut = async () => {
    await supabase.auth.signOut()
    router.push('/')
    router.refresh()
  }

  return (
    <button onClick={handleSignOut} className="text-red-500">
      Sign Out
    </button>
  )
}

Database Operations

TypeScript Types Generation

npx supabase gen types typescript --project-id your-project-id > types/supabase.ts

Typed Client

import { createClient } from '@/lib/supabase/server'
import { Database } from '@/types/supabase'

// Types are automatically inferred
const supabase = await createClient()
const { data: posts } = await supabase.from('posts').select('*')
// posts is typed as Database['public']['Tables']['posts']['Row'][]

CRUD Operations

Create

// Server Action
'use server'

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

export async function createPost(formData: FormData) {
  const supabase = await createClient()
  
  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    throw new Error('Not authenticated')
  }

  const { error } = await supabase.from('posts').insert({
    title: formData.get('title') as string,
    content: formData.get('content') as string,
    user_id: user.id,
  })

  if (error) throw error
  
  revalidatePath('/posts')
}

Read

// Server Component
import { createClient } from '@/lib/supabase/server'

export default async function PostsPage() {
  const supabase = await createClient()
  
  const { data: posts, error } = await supabase
    .from('posts')
    .select(`
      id,
      title,
      content,
      created_at,
      author:users(name, avatar_url)
    `)
    .order('created_at', { ascending: false })
    .limit(10)

  if (error) {
    console.error(error)
    return <div>Error loading posts</div>
  }

  return (
    <div className="space-y-4">
      {posts?.map((post) => (
        <article key={post.id} className="p-4 border rounded">
          <h2 className="text-xl font-bold">{post.title}</h2>
          <p>{post.content}</p>
          <span className="text-gray-500">By {post.author?.name}</span>
        </article>
      ))}
    </div>
  )
}

Update

'use server'

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

export async function updatePost(id: string, formData: FormData) {
  const supabase = await createClient()

  const { error } = await supabase
    .from('posts')
    .update({
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      updated_at: new Date().toISOString(),
    })
    .eq('id', id)

  if (error) throw error
  
  revalidatePath('/posts')
  revalidatePath(`/posts/${id}`)
}

Delete

'use server'

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

export async function deletePost(id: string) {
  const supabase = await createClient()

  const { error } = await supabase
    .from('posts')
    .delete()
    .eq('id', id)

  if (error) throw error
  
  revalidatePath('/posts')
}

Row Level Security (RLS)

Enable RLS in your Supabase dashboard and create policies:

-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Policy: Anyone can read posts
CREATE POLICY "Public posts are viewable by everyone"
ON posts FOR SELECT
USING (published = true);

-- Policy: Users can only insert their own posts
CREATE POLICY "Users can insert their own posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);

-- Policy: Users can only update their own posts
CREATE POLICY "Users can update their own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id);

-- Policy: Users can only delete their own posts
CREATE POLICY "Users can delete their own posts"
ON posts FOR DELETE
USING (auth.uid() = user_id);

Real-time Subscriptions

'use client'

import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
import { RealtimePostgresChangesPayload } from '@supabase/supabase-js'

interface Message {
  id: string
  content: string
  user_id: string
  created_at: string
}

export function ChatMessages({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([])
  const supabase = createClient()

  useEffect(() => {
    // Fetch initial messages
    const fetchMessages = async () => {
      const { data } = await supabase
        .from('messages')
        .select('*')
        .eq('room_id', roomId)
        .order('created_at', { ascending: true })
      
      if (data) setMessages(data)
    }

    fetchMessages()

    // Subscribe to real-time changes
    const channel = supabase
      .channel(`room:${roomId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `room_id=eq.${roomId}`,
        },
        (payload: RealtimePostgresChangesPayload<Message>) => {
          if (payload.new) {
            setMessages((prev) => [...prev, payload.new as Message])
          }
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId, supabase])

  return (
    <div className="space-y-2">
      {messages.map((message) => (
        <div key={message.id} className="p-2 bg-gray-100 rounded">
          {message.content}
        </div>
      ))}
    </div>
  )
}

Storage

Upload Files

'use client'

import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'

export function FileUpload() {
  const [uploading, setUploading] = useState(false)
  const supabase = createClient()

  const uploadFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    setUploading(true)

    const fileExt = file.name.split('.').pop()
    const fileName = `${Date.now()}.${fileExt}`
    const filePath = `uploads/${fileName}`

    const { error } = await supabase.storage
      .from('files')
      .upload(filePath, file)

    if (error) {
      console.error('Error uploading:', error)
    } else {
      // Get public URL
      const { data } = supabase.storage
        .from('files')
        .getPublicUrl(filePath)
      
      console.log('File URL:', data.publicUrl)
    }

    setUploading(false)
  }

  return (
    <input
      type="file"
      onChange={uploadFile}
      disabled={uploading}
    />
  )
}

Download Files

const { data, error } = await supabase.storage
  .from('files')
  .download('uploads/file.pdf')

if (data) {
  const url = URL.createObjectURL(data)
  // Use url for download or display
}

Delete Files

const { error } = await supabase.storage
  .from('files')
  .remove(['uploads/file1.pdf', 'uploads/file2.pdf'])

Best Practices

1. Use Server Components for Data Fetching

// Good: Server Component
export default async function Page() {
  const supabase = await createClient()
  const { data } = await supabase.from('posts').select('*')
  return <PostList posts={data} />
}

2. Use Server Actions for Mutations

// Good: Server Action
'use server'
export async function createPost(formData: FormData) {
  const supabase = await createClient()
  // ... mutation logic
  revalidatePath('/posts')
}

3. Type Your Database

Always generate and use TypeScript types:

import { Database } from '@/types/supabase'

type Post = Database['public']['Tables']['posts']['Row']
type PostInsert = Database['public']['Tables']['posts']['Insert']

4. Handle Errors Gracefully

const { data, error } = await supabase.from('posts').select('*')

if (error) {
  // Log for debugging
  console.error('Supabase error:', error)
  // Show user-friendly message
  return <ErrorMessage message="Failed to load posts" />
}

5. Use RLS for Security

Never rely on client-side validation alone. Always implement Row Level Security policies in Supabase.

Conclusion

Supabase + Next.js is a powerful combination for building full-stack applications. Key takeaways:

  • Use separate clients for browser and server
  • Implement middleware for auth protection
  • Leverage Server Components and Server Actions
  • Always enable and configure RLS
  • Generate TypeScript types for type safety

Start building your next project with this stack!