Back to Blog
Web Development10 min readFebruary 5, 2025

Building a Full-Stack App with Supabase and Next.js 15

How to build a production-ready full-stack application using Supabase for auth, database, storage, and realtime — with Next.js 15 App Router and server actions.

SupabaseNext.jsPostgreSQLAuthFull-Stack
A

Azam

DevOps & AI Consultant

Why Supabase Is the Best Backend for Next.js

Supabase is an open-source Firebase alternative built on PostgreSQL. It gives you a hosted Postgres database, auth with row-level security, file storage, and realtime subscriptions — all behind a single TypeScript client. For Next.js applications, it is the fastest path from zero to a production backend without operating your own database or auth infrastructure.

The combination of Supabase with Next.js 15's App Router and Server Actions is particularly powerful: you get server-side data access without exposing service keys to the client, and Server Actions handle mutations without writing API routes.

Project Setup

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

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key  # server-only

Create two Supabase clients: a browser client (using the anon key, subject to Row Level Security) and a server client (using the service role key, for admin operations in Server Actions and Route Handlers).

// lib/supabase/client.ts — browser component usage
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )

// lib/supabase/server.ts — server component / server action usage
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export const createClient = async () => {
  const cookieStore = await cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: { getAll: () => cookieStore.getAll(), setAll: (c) => c.forEach(({ name, value, options }) => cookieStore.set(name, value, options)) } }
  )
}

Row Level Security: The Right Way

Enable RLS on every table and write policies that enforce access rules in the database — not just in your application code. This means even if a bug in your code sends the wrong user ID, the database rejects the query.

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

-- 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);

-- Allow public read for published posts
CREATE POLICY "public_read_published"
ON posts FOR SELECT
USING (published = true);

Server Actions for Mutations

Use Server Actions for form submissions and data mutations. They run on the server, have access to the service role key, and can revalidate cached data after mutation.

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

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

  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('/dashboard/posts')
}

Realtime Subscriptions

Supabase streams database changes over WebSockets using PostgreSQL's logical replication. Subscribe to table changes in client components for live-updating UIs.

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

export function LiveComments({ postId }: { postId: string }) {
  const [comments, setComments] = useState([])
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase
      .channel('comments')
      .on('postgres_changes', {
        event: 'INSERT',
        schema: 'public',
        table: 'comments',
        filter: `post_id=eq.${postId}`,
      }, (payload) => {
        setComments(prev => [...prev, payload.new])
      })
      .subscribe()

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

  return comments.map(c => )
}

File Storage

Supabase Storage provides S3-compatible file storage with access control tied to auth. Use it for user avatars, document uploads, and any user-generated content.

const supabase = await createClient()

// Upload
const { data, error } = await supabase.storage
  .from('avatars')
  .upload(`${user.id}/avatar.png`, file, {
    upsert: true,
    contentType: 'image/png',
  })

// Get public URL
const { data: { publicUrl } } = supabase.storage
  .from('avatars')
  .getPublicUrl(`${user.id}/avatar.png`)

Set storage bucket policies to match your RLS strategy — public buckets for publicly accessible content, private buckets for user-owned files with signed URLs for time-limited access.

Want to Build This for Your Team?

I help teams implement the patterns and architectures described in these articles. Let's talk about your project.

Book a Free Call