Build a Real-Time App with Supabase and Next.js 15: Complete Guide

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Supabase has become the go-to open-source alternative to Firebase, providing a complete backend with PostgreSQL, authentication, real-time subscriptions, and storage — all out of the box. Combined with Next.js 15 and the App Router, you get a powerful full-stack framework that handles server-side rendering, server actions, and seamless client interactions.

In this guide, you'll build a real-time collaborative task board — a Trello-lite where multiple users can add, update, and delete tasks that instantly sync across all connected browsers. Along the way, you'll master Supabase Auth, Row Level Security (RLS), database triggers, and the Realtime Broadcast system.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed
  • A Supabase account — free tier at supabase.com
  • Basic knowledge of React and TypeScript
  • Familiarity with Next.js App Router (pages, layouts, server components)
  • A code editor (VS Code recommended)

What You'll Build

A collaborative task board with these features:

  • Email/password authentication with protected routes
  • Real-time task updates across all connected clients
  • Row Level Security ensuring users only see their own data
  • Server Components for initial data loading
  • Server Actions for mutations
  • Middleware for session management

Step 1: Create a Supabase Project

Head to supabase.com/dashboard and create a new project. Once your project is ready, note down these values from Settings > API:

  • Project URL — your Supabase instance URL
  • anon public key — safe for client-side use
  • service_role key — admin access (keep this secret)

Step 2: Set Up the Database Schema

Go to the SQL Editor in your Supabase dashboard and run the following:

-- Create a profiles table linked to auth.users
create table public.profiles (
  id uuid references auth.users on delete cascade not null primary key,
  email text,
  display_name text,
  created_at timestamptz default now() not null
);
 
-- Create the tasks table
create table public.tasks (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users on delete cascade not null,
  title text not null,
  description text,
  status text default 'todo' check (status in ('todo', 'in_progress', 'done')),
  created_at timestamptz default now() not null,
  updated_at timestamptz default now() not null
);
 
-- Enable Row Level Security
alter table public.profiles enable row level security;
alter table public.tasks enable row level security;
 
-- Profiles: users can read and update their own profile
create policy "Users can view own profile"
  on public.profiles for select
  using (auth.uid() = id);
 
create policy "Users can update own profile"
  on public.profiles for update
  using (auth.uid() = id);
 
-- Tasks: users can CRUD their own tasks
create policy "Users can view own tasks"
  on public.tasks for select
  using (auth.uid() = user_id);
 
create policy "Users can create tasks"
  on public.tasks for insert
  with check (auth.uid() = user_id);
 
create policy "Users can update own tasks"
  on public.tasks for update
  using (auth.uid() = user_id);
 
create policy "Users can delete own tasks"
  on public.tasks for delete
  using (auth.uid() = user_id);
 
-- Auto-create profile on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, email, display_name)
  values (new.id, new.email, split_part(new.email, '@', 1));
  return new;
end;
$$ language plpgsql security definer;
 
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();
 
-- Auto-update updated_at on tasks
create or replace function public.update_updated_at()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end;
$$ language plpgsql;
 
create trigger tasks_updated_at
  before update on public.tasks
  for each row execute function public.update_updated_at();

This sets up a complete schema with:

  • A profiles table that auto-populates on user signup
  • A tasks table with status tracking
  • RLS policies so each user only accesses their own data
  • Triggers for automatic timestamps

Step 3: Set Up Realtime Broadcasting

For scalable real-time updates, Supabase recommends Broadcast via database triggers over the older postgres_changes approach. Run this in the SQL Editor:

-- Broadcast trigger for tasks table
create or replace function broadcast_task_changes()
returns trigger as $$
begin
  perform realtime.broadcast_changes(
    'tasks:updates',
    TG_OP,
    TG_OP,
    TG_TABLE_NAME,
    TG_TABLE_SCHEMA,
    NEW,
    OLD
  );
  return null;
end;
$$ language plpgsql security definer;
 
create trigger tasks_broadcast_trigger
  after insert or update or delete on public.tasks
  for each row execute function broadcast_task_changes();

Step 4: Scaffold the Next.js Project

npx create-next-app@latest task-board --typescript --tailwind --eslint --app --src-dir
cd task-board

Install the Supabase packages:

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

Create your .env.local file:

NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here

Step 5: Create the Supabase Clients

You need two clients: one for server-side (Server Components, Server Actions, Middleware) and one for the browser.

Browser Client

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

Server Client

// 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 {
            // Called from a Server Component — safe to ignore
            // if middleware handles session refresh
          }
        },
      },
    }
  )
}

Step 6: Add Middleware for Session Management

The middleware refreshes the user session on every request, preventing expired tokens:

// 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)
          )
        },
      },
    }
  )
 
  const {
    data: { user },
  } = await supabase.auth.getUser()
 
  // Redirect unauthenticated users to login
  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/signup') &&
    !request.nextUrl.pathname.startsWith('/auth')
  ) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }
 
  // Redirect authenticated users away from auth pages
  if (user && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) {
    const url = request.nextUrl.clone()
    url.pathname = '/dashboard'
    return NextResponse.redirect(url)
  }
 
  return supabaseResponse
}
 
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

Step 7: Build the Authentication Pages

Login Page

// src/app/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<string | null>(null)
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()
 
  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setError(null)
 
    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    })
 
    if (error) {
      setError(error.message)
      setLoading(false)
      return
    }
 
    router.push('/dashboard')
    router.refresh()
  }
 
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
        <h2 className="text-3xl font-bold text-center text-gray-900">
          Sign in to Task Board
        </h2>
 
        <form onSubmit={handleLogin} className="space-y-6">
          {error && (
            <div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
              {error}
            </div>
          )}
 
          <div>
            <label htmlFor="email" className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <input
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
            />
          </div>
 
          <div>
            <label htmlFor="password" className="block text-sm font-medium text-gray-700">
              Password
            </label>
            <input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
            />
          </div>
 
          <button
            type="submit"
            disabled={loading}
            className="w-full flex justify-center py-2 px-4 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'Signing in...' : 'Sign in'}
          </button>
        </form>
 
        <p className="text-center text-sm text-gray-600">
          No account?{' '}
          <a href="/signup" className="text-blue-600 hover:underline">
            Sign up
          </a>
        </p>
      </div>
    </div>
  )
}

Signup Page

// src/app/signup/page.tsx
'use client'
 
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
 
export default function SignupPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()
 
  const handleSignup = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setError(null)
 
    const { error } = await supabase.auth.signUp({
      email,
      password,
    })
 
    if (error) {
      setError(error.message)
      setLoading(false)
      return
    }
 
    router.push('/dashboard')
    router.refresh()
  }
 
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
        <h2 className="text-3xl font-bold text-center text-gray-900">
          Create your account
        </h2>
 
        <form onSubmit={handleSignup} className="space-y-6">
          {error && (
            <div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
              {error}
            </div>
          )}
 
          <div>
            <label htmlFor="email" className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <input
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
            />
          </div>
 
          <div>
            <label htmlFor="password" className="block text-sm font-medium text-gray-700">
              Password (min. 6 characters)
            </label>
            <input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2"
            />
          </div>
 
          <button
            type="submit"
            disabled={loading}
            className="w-full flex justify-center py-2 px-4 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'Creating account...' : 'Sign up'}
          </button>
        </form>
 
        <p className="text-center text-sm text-gray-600">
          Already have an account?{' '}
          <a href="/login" className="text-blue-600 hover:underline">
            Sign in
          </a>
        </p>
      </div>
    </div>
  )
}

Step 8: Build the Dashboard with Server Components

The dashboard loads tasks server-side for fast initial page load, then subscribes to real-time changes on the client.

Dashboard Layout

// src/app/dashboard/layout.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user) redirect('/login')
 
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white shadow-sm border-b">
        <div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
          <h1 className="text-xl font-bold text-gray-900">Task Board</h1>
          <div className="flex items-center gap-4">
            <span className="text-sm text-gray-600">{user.email}</span>
            <form action="/auth/signout" method="post">
              <button
                type="submit"
                className="text-sm text-red-600 hover:underline"
              >
                Sign out
              </button>
            </form>
          </div>
        </div>
      </header>
      <main className="max-w-7xl mx-auto px-4 py-8">{children}</main>
    </div>
  )
}

Server Component: Fetch Initial Tasks

// src/app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { TaskBoard } from './task-board'
 
export default async function DashboardPage() {
  const supabase = await createClient()
 
  const { data: tasks, error } = await supabase
    .from('tasks')
    .select('*')
    .order('created_at', { ascending: false })
 
  if (error) {
    return <div className="text-red-600">Failed to load tasks: {error.message}</div>
  }
 
  return <TaskBoard initialTasks={tasks ?? []} />
}

Step 9: Create the Real-Time Task Board Component

This is the heart of the app — a client component that renders the Kanban columns and subscribes to real-time changes:

// src/app/dashboard/task-board.tsx
'use client'
 
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
 
type Task = {
  id: string
  title: string
  description: string | null
  status: 'todo' | 'in_progress' | 'done'
  created_at: string
  updated_at: string
  user_id: string
}
 
const STATUS_COLUMNS = [
  { key: 'todo', label: 'To Do', color: 'bg-yellow-100 border-yellow-300' },
  { key: 'in_progress', label: 'In Progress', color: 'bg-blue-100 border-blue-300' },
  { key: 'done', label: 'Done', color: 'bg-green-100 border-green-300' },
] as const
 
export function TaskBoard({ initialTasks }: { initialTasks: Task[] }) {
  const [tasks, setTasks] = useState<Task[]>(initialTasks)
  const [newTitle, setNewTitle] = useState('')
  const supabase = createClient()
 
  // Subscribe to real-time task changes
  useEffect(() => {
    const channel = supabase
      .channel('tasks:updates', { config: { private: true } })
      .on('broadcast', { event: 'INSERT' }, (payload) => {
        const newTask = payload.payload.record as Task
        setTasks((prev) => [newTask, ...prev])
      })
      .on('broadcast', { event: 'UPDATE' }, (payload) => {
        const updated = payload.payload.record as Task
        setTasks((prev) =>
          prev.map((t) => (t.id === updated.id ? updated : t))
        )
      })
      .on('broadcast', { event: 'DELETE' }, (payload) => {
        const deleted = payload.payload.old_record as Task
        setTasks((prev) => prev.filter((t) => t.id !== deleted.id))
      })
      .subscribe()
 
    return () => {
      supabase.removeChannel(channel)
    }
  }, [supabase])
 
  const addTask = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!newTitle.trim()) return
 
    const { data: { user } } = await supabase.auth.getUser()
    if (!user) return
 
    const { error } = await supabase.from('tasks').insert({
      title: newTitle.trim(),
      user_id: user.id,
      status: 'todo',
    })
 
    if (!error) setNewTitle('')
  }
 
  const updateStatus = async (taskId: string, status: Task['status']) => {
    await supabase.from('tasks').update({ status }).eq('id', taskId)
  }
 
  const deleteTask = async (taskId: string) => {
    await supabase.from('tasks').delete().eq('id', taskId)
  }
 
  return (
    <div className="space-y-6">
      {/* Add Task Form */}
      <form onSubmit={addTask} className="flex gap-3">
        <input
          type="text"
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          placeholder="Add a new task..."
          className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
        >
          Add Task
        </button>
      </form>
 
      {/* Kanban Columns */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {STATUS_COLUMNS.map((col) => (
          <div key={col.key} className={`rounded-xl border-2 p-4 ${col.color}`}>
            <h3 className="font-semibold text-lg mb-4">
              {col.label}
              <span className="ml-2 text-sm font-normal text-gray-500">
                ({tasks.filter((t) => t.status === col.key).length})
              </span>
            </h3>
 
            <div className="space-y-3">
              {tasks
                .filter((t) => t.status === col.key)
                .map((task) => (
                  <div
                    key={task.id}
                    className="bg-white rounded-lg p-4 shadow-sm border"
                  >
                    <h4 className="font-medium text-gray-900">{task.title}</h4>
                    {task.description && (
                      <p className="text-sm text-gray-500 mt-1">
                        {task.description}
                      </p>
                    )}
 
                    <div className="mt-3 flex gap-2 flex-wrap">
                      {col.key !== 'todo' && (
                        <button
                          onClick={() =>
                            updateStatus(
                              task.id,
                              col.key === 'done' ? 'in_progress' : 'todo'
                            )
                          }
                          className="text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200"
                        >
                          ← Move back
                        </button>
                      )}
                      {col.key !== 'done' && (
                        <button
                          onClick={() =>
                            updateStatus(
                              task.id,
                              col.key === 'todo' ? 'in_progress' : 'done'
                            )
                          }
                          className="text-xs px-2 py-1 bg-blue-100 rounded hover:bg-blue-200 text-blue-700"
                        >
                          Move forward →
                        </button>
                      )}
                      <button
                        onClick={() => deleteTask(task.id)}
                        className="text-xs px-2 py-1 bg-red-100 rounded hover:bg-red-200 text-red-700"
                      >
                        Delete
                      </button>
                    </div>
                  </div>
                ))}
 
              {tasks.filter((t) => t.status === col.key).length === 0 && (
                <p className="text-sm text-gray-400 text-center py-4">
                  No tasks
                </p>
              )}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

Step 10: Add the Sign-Out Route

// src/app/auth/signout/route.ts
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export async function POST() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  redirect('/login')
}

Step 11: Auth Callback Handler

Supabase sends users to a callback URL after email confirmation. Add this route:

// src/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')
 
  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (!error) {
      return NextResponse.redirect(`${origin}/dashboard`)
    }
  }
 
  return NextResponse.redirect(`${origin}/login?error=auth`)
}

Testing Your Implementation

  1. Start the development server:
npm run dev
  1. Sign up at http://localhost:3000/signup with a test email
  2. Create tasks and move them between columns
  3. Open a second browser tab — changes appear in real time
  4. Check RLS — sign in with a different account to verify task isolation

Troubleshooting

Common issues and solutions

"relation 'tasks' does not exist" — Make sure you ran the SQL setup in Step 2 on your Supabase project.

Cookies not being set — Ensure your middleware is configured with the correct matcher pattern and that @supabase/ssr is installed.

Real-time events not arriving — Verify that the broadcast trigger was created in Step 3. Check your Supabase dashboard under Realtime > Inspector to see if events are flowing.

"Unauthorized" errors — Row Level Security is enabled. Make sure the user is authenticated before making database queries. Check that RLS policies match the auth.uid() to the correct column.

Session expired on page refresh — The middleware should handle token refresh automatically. If sessions are lost, double-check that setAll in the server client properly writes cookies.

Project Structure Recap

src/
├── app/
│   ├── auth/
│   │   ├── callback/route.ts    # OAuth/email callback
│   │   └── signout/route.ts     # Sign-out handler
│   ├── dashboard/
│   │   ├── layout.tsx           # Auth guard + header
│   │   ├── page.tsx             # Server component (data fetch)
│   │   └── task-board.tsx       # Client component (real-time)
│   ├── login/page.tsx           # Login form
│   └── signup/page.tsx          # Signup form
├── lib/
│   └── supabase/
│       ├── client.ts            # Browser client
│       └── server.ts            # Server client
└── middleware.ts                 # Session refresh + route guards

Next Steps

Now that you have a working real-time task board, here are ways to extend it:

  • Add OAuth providers — Enable Google, GitHub, or Discord login via Supabase Auth settings
  • Implement drag-and-drop — Use @dnd-kit/core for smooth Kanban interactions
  • Add Supabase Storage — Let users attach files to tasks
  • Deploy to Vercel — Set your environment variables and deploy with vercel deploy
  • Add Presence — Show which users are currently online using Supabase Realtime Presence

Conclusion

You've built a full-stack, real-time collaborative task board using Supabase and Next.js 15. The key patterns you've learned are:

  • Dual client setup — separate browser and server Supabase clients for the App Router
  • Middleware session management — automatically refreshing tokens on every request
  • Row Level Security — database-level access control without writing backend code
  • Broadcast triggers — the scalable approach to real-time database change notifications
  • Server Components + Client Components — server-side data loading with client-side interactivity

These patterns transfer to any Supabase + Next.js project, whether you're building a chat app, dashboard, or SaaS product. Supabase handles the heavy infrastructure so you can focus on your application logic.


Want to read more tutorials? Check out our latest tutorial on Building a Custom Code Interpreter for LLM Agents.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles