Real-Time Collaborative Task Board
Build a Kanban board with live multi-tab sync using Next.js and Supabase.
Introduction
30 Second Summary
Every time you move a card on a shared project board and a teammate sees it update instantly, there's a carefully designed real-time system making that happen. Building that kind of live synchronization is one of the architectural skills that defines senior frontend engineers.
In this project, you will build a real-time Kanban task board with Next.js 16 and Supabase. You'll practice five senior-level patterns: Server Components, Server Actions, optimistic UI, Realtime subscriptions, and Streaming with Suspense.
What You'll Build
A three-column Kanban task board (Todo, In Progress, Done) with a server-first architecture, instant optimistic updates, and live multi-tab synchronization powered by Supabase Realtime.
By the end of this project, you'll have:
- A Next.js 16 App Router project with deliberate server/client boundary separation and streaming loading states.
- Server Actions with Zod validation and optimistic UI that updates instantly before server confirmation.
- Live multi-tab synchronization via Supabase Realtime where changes in one tab appear in another within seconds.
- Secret Mission: Add a task deletion flow with optimistic removal, error recovery, and a confirmation dialog.
Are there any prerequisites?
This project assumes you're comfortable with basic TypeScript, React fundamentals, and working in a terminal. You'll create a free Supabase account during the project with guided setup.
Set Up the Project and Supabase
Every real-time application starts with two foundations: a frontend framework that handles rendering efficiently, and a backend service that pushes live updates.
In this step, you will scaffold a Next.js 16 project, connect it to Supabase (your database and real-time engine), and create the client utilities that let your app talk to Supabase from both the server and the browser.
In this step, get ready to:
- Scaffold a Next.js 16 project with TypeScript, Tailwind CSS, and App Router.
- Create a Supabase project and store your connection credentials.
- Build server and browser client utilities for Supabase.
Scaffold the Next.js project and initialize Git
Next.js 16 uses the App Router by default, which gives you Server Components, Server Actions, and Streaming out of the box.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your Next.js project by running this command:
npx create-next-app@latest task-board --yes --use-npm
What does this command do?
The --yes flag accepts all recommended defaults without prompting you. This gives you TypeScript, Tailwind CSS, ESLint, App Router, and Turbopack out of the box.
The --use-npm flag ensures the project uses npm as the package manager.
- Once the scaffolding finishes, navigate into your new project by running:
cd task-board
- Install the Supabase and validation libraries by running:
npm install @supabase/supabase-js @supabase/ssr zod
What are these packages?
- @supabase/supabase-js is the core Supabase client for querying your database and subscribing to real-time changes.
- @supabase/ssr provides server-safe client creation with cookie handling, designed for frameworks like Next.js.
- zod is a schema validation library you will use to validate form inputs before they reach the database.
- Confirm the installation succeeded by checking that no error messages appeared in the terminal output.
Seeing permission errors?
On macOS, if you see EACCES errors, try running the command with sudo in front. On Windows, try running your terminal as Administrator.
If you see network timeout errors, check your internet connection and try the command again.
Why is npm install failing for me?
Now let's clean up the default boilerplate so you start with a blank canvas.
- Open the task-board folder in VS Code by running:
code .
- In the Explorer sidebar, open app/page.tsx.
- Select all the content (Cmd+A on macOS or Ctrl+A on Windows) and replace it with this minimal placeholder:
export default function Home() {
return (
<main className="p-6">
<h1 className="text-2xl font-bold">Task Board</h1>
</main>
);
}
Why remove the boilerplate?
The default Next.js starter page has demo content and styles you do not need. Starting with a clean page makes it easier to verify that your setup works without visual clutter.
- Save app/page.tsx.
- Make your first Git commit by running these commands in the terminal:
git add . && git commit -m "feat: scaffold Next.js 16 project with Supabase deps"
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
- You should see a white page with "Task Board" in bold text at the top.
Don't see your page?
Make sure the terminal shows "Ready" with the localhost URL. If port 3000 is already in use, Next.js will use port 3001 instead. Check the terminal output for the correct URL.
Help me fix my Next.js dev server
Create a Supabase project and configure environment variables
Supabase provides your database, authentication, and real-time subscriptions all in one service. You need a project in Supabase to get the connection credentials your Next.js app will use.
- In your browser, navigate to database.new.
- This redirects you to the Supabase dashboard to create a new project.
- Enter a project name (e.g. task-board).
- Set a database password. Save this somewhere safe for later.
- Choose a region close to your location.
- Click Create new project.
Supabase takes about 30 seconds to provision your project. Wait for the dashboard to show your project is ready.
- Once ready, click Project Settings in the left sidebar.
- Click API Keys.
- Copy the Project URL value.
- Copy the publishable anon key value.
What is the publishable anon key?
This key is safe to use in browser-side code. It identifies your project but does not grant admin access. Row Level Security policies (which you will set up in the next step) control what this key can actually read or write.
- Switch back to VS Code.
- Create a new file called .env.local in the root of your task-board folder.
- Add your Supabase credentials by pasting the following, replacing the placeholder values with the URL and key you just copied:
NEXT_PUBLIC_SUPABASE_URL=[[SUPABASE_URL="your Supabase project URL"]]
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=[[SUPABASE_KEY="your publishable anon key"]]
Why NEXT_PUBLIC_ prefix?
Any environment variable prefixed with NEXT_PUBLIC_ is accessible in both server and client code. Since the Supabase URL and publishable key are designed to be exposed to the browser (they are not secrets), this prefix is correct.
- Save .env.local.
- Verify that .env.local is listed in your .gitignore file. Open .gitignore from the Explorer sidebar and look for the line .env*.local. It should already be there from the scaffold step.
Keep credentials out of Git
The .gitignore entry ensures your .env.local file is never committed to version control. Even though the publishable key is not a secret, keeping environment files out of Git is a good habit.
- Restart your dev server to pick up the new environment variables. Press Ctrl+C in the terminal to stop it, then run:
npm run dev
- Confirm the dev server starts without errors. You should see the same "Task Board" page at http://localhost:3000.
Create Supabase client utilities
Supabase needs two different client types depending on where your code runs. Server Components use a cookie-aware client. Client Components use a browser client.
- In the Explorer sidebar, create a new folder called lib at the root of your project.
- Inside lib, create a subfolder called supabase.
- Inside lib/supabase, create a file called client.ts and paste the following code:
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}
What does this code do?
- createBrowserClient from @supabase/ssr creates a Supabase client safe for use in Client Components (browser-side code).
- The ! after each environment variable tells TypeScript "trust me, this value exists." It avoids type errors since env vars are technically string | undefined.
- Save lib/supabase/client.ts.
- Now create the server client. Inside lib/supabase, create a file called server.ts and paste the following code:
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_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// The `setAll` method is called from a Server Component.
// This can be ignored if you have middleware refreshing sessions.
}
},
},
}
);
}
What does this code do?
- createServerClient from @supabase/ssr creates a Supabase client that works inside Server Components and Server Actions.
- The cookies() function from next/headers gives the client access to the request's cookies, which Supabase uses to manage user sessions.
- The try/catch in setAll is intentional. Server Components cannot set cookies directly, so the catch block silently handles that case. Middleware handles session refreshes separately.
- Save lib/supabase/server.ts.
- Check the terminal where your dev server is running. You should see no errors.
Seeing TypeScript or module errors?
If you see "Cannot find module '@supabase/ssr'", make sure you ran npm install @supabase/supabase-js @supabase/ssr zod earlier and it completed without errors.
If you see type errors about cookies, ensure your file is inside the lib/supabase/ directory and not inside the app/ directory.
Help me fix my Supabase client utility errors
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before continuing.
ⓧ I'd like to double check the full code
Here is the complete code for each file. Compare yours carefully.
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}
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_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// The `setAll` method is called from a Server Component.
// This can be ignored if you have middleware refreshing sessions.
}
},
},
}
);
}
NEXT_PUBLIC_SUPABASE_URL=[[SUPABASE_URL="your Supabase project URL"]]
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=[[SUPABASE_KEY="your publishable anon key"]]
- Commit your new utilities by running:
git add . && git commit -m "feat: add Supabase client utilities for server and browser"
- You should see a message confirming the commit with files changed.
Your project foundation is solid. You have a Next.js 16 app connected to Supabase with proper server and browser client separation. Next up, you will create the database table and enable real-time subscriptions.
Create the Database Schema and Enable Realtime
Your Supabase client utilities are in place, ready to talk to the database from both server and browser contexts.
But there is no database table yet. In this step, you will create the tasks table that stores your board data, enable real-time change detection on it, and define the TypeScript types that keep your app in sync with the schema.
In this step, get ready to:
- Create a tasks table with Row Level Security in Supabase.
- Enable Realtime so the table broadcasts changes to connected clients.
- Define TypeScript types and Zod validation schemas for type safety.
Create the tasks table with SQL
Every task on your board needs a place to live in the database. You will define the table schema with constraints that prevent invalid data from sneaking in.
- Switch to your Supabase dashboard in the browser.
- Click SQL Editor in the left sidebar.
- Click New query to open a fresh editor.
- Paste the following SQL and click Run:
-- Create the tasks table
create table public.tasks (
id uuid primary key default gen_random_uuid(),
title text not null,
status text not null default 'todo' check (status in ('todo', 'in_progress', 'done')),
created_at timestamptz default now(),
position integer not null default 0
);
-- Grant access to the anon role
grant select, insert, update, delete on public.tasks to anon;
-- Enable Row Level Security
alter table public.tasks enable row level security;
-- Create a permissive policy for anon (learning project only)
create policy "Allow all operations for anon"
on public.tasks
for all
to anon
using (true)
with check (true);
What does this SQL do?
- The tasks table stores each task with a UUID primary key, a title, a status constrained to three valid values, a timestamp, and a position integer for ordering.
- The grant statement gives your app's anonymous role full read/write access to the table.
- Row Level Security (RLS) adds a security layer. The permissive policy allows all operations for the anon role so your learning project works without authentication.
Why enable RLS on a learning project?
Supabase requires RLS to be enabled for Realtime to work with Postgres Changes. Even though the policy is wide open here, the pattern teaches you the production habit of always enabling RLS.
In a real app, you would restrict the policy to authenticated users.
- Confirm the query ran successfully. You should see a Success. No rows returned message at the bottom of the SQL Editor.
- Click Table Editor in the left sidebar to verify the tasks table now appears in the list.
Insert a test row to confirm everything works.
- Go back to SQL Editor and run this query:
insert into public.tasks (title) values ('Test task from SQL editor');
- Return to Table Editor and click on the tasks table. You should see your test row with status todo and a generated UUID.
Don't see the tasks table?
- Make sure you ran all the SQL statements in a single query. If you only ran part of it, the table might exist without the correct grants or RLS settings.
- Check the error output at the bottom of the SQL Editor for any syntax issues.
- Help me troubleshoot my tasks table creation
Enable Realtime on the tasks table
For your board to update live across multiple tabs, Supabase Realtime needs to know which tables to watch. You will add the tasks table to the Realtime publication so it broadcasts INSERT, UPDATE, and DELETE events.
- In the SQL Editor, run the following query to add the tasks table to the Realtime publication:
-- Add tasks table to the Realtime publication
alter publication supabase_realtime add table public.tasks;
What does this do?
Supabase Realtime uses a Postgres publication called supabase_realtime to track which tables broadcast changes. By adding your table to this publication, any INSERT, UPDATE, or DELETE on tasks will be streamed to connected clients over WebSockets.
- Confirm the query shows Success. No rows returned.
You can verify the publication includes your table by running this check.
- Run this query in the SQL Editor:
select * from pg_publication_tables where pubname = 'supabase_realtime';
- You should see tasks listed in the tablename column of the results.
Getting a 'publication does not exist' error?
- The supabase_realtime publication is created automatically when your Supabase project is provisioned. If you see this error, your project may still be initializing. Wait a minute and try again.
- Help me fix this publication error
Create TypeScript types and Zod schemas
Your database schema is ready, but TypeScript does not know about it yet. You will create type definitions that mirror the table columns and Zod schemas that validate data before it reaches the database.
- In VS Code, create a new file called lib/types.ts in your task-board project.
- Add the following type definitions:
export type TaskStatus = "todo" | "in_progress" | "done";
export interface Task {
id: string;
title: string;
status: TaskStatus;
created_at: string;
position: number;
}
What does this code do?
- TaskStatus is a union type that restricts status values to the same three options as your database check constraint.
- The Task interface mirrors the table columns exactly. TypeScript will catch any mismatches between your app code and the database schema at compile time.
- Save lib/types.ts.
- Create another new file called lib/schemas.ts in the same lib/ folder.
- Add the following Zod validation schemas:
import * as z from "zod";
// Validates task creation input
export const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Title too long"),
});
// Validates status update input
export const updateTaskStatusSchema = z.object({
id: z.string().uuid("Invalid task ID"),
status: z.enum(["todo", "in_progress", "done"]),
});
// Validates task deletion input
export const deleteTaskSchema = z.object({
id: z.string().uuid("Invalid task ID"),
});
What does this code do?
- createTaskSchema ensures a task title is between 1 and 100 characters. Empty or overly long titles get rejected before they hit the database.
- updateTaskStatusSchema validates that the task ID is a real UUID and the new status is one of the three allowed values.
- deleteTaskSchema validates the task ID format before attempting deletion.
- These schemas run inside Server Actions later. Invalid data gets caught with a clear error message instead of crashing the database query.
Why Zod for validation?
Zod gives you runtime validation that TypeScript alone cannot provide. TypeScript checks types at compile time, but user input arrives at runtime.
Zod bridges that gap by parsing and validating data at the boundary between your app and the outside world.
- Save lib/schemas.ts.
- Confirm TypeScript compiles without errors by checking your terminal where npm run dev is running. You should see no red error output.
Seeing TypeScript errors?
- Make sure your import uses import * as z from "zod" and not a default import. Zod 4 uses this namespace import pattern.
- Verify that zod is listed in your package.json dependencies. If not, run npm install zod again.
- Help me debug my TypeScript errors
- Commit your progress by running these commands in your terminal:
git add .
git commit -m "feat: database schema, RLS, realtime, and TS types"
✔️ Awesome, I've got everything!
Great. Double check you have saved both lib/types.ts and lib/schemas.ts.
ⓧ I'd like to double check the full code
Here is the complete code for both files:
export type TaskStatus = "todo" | "in_progress" | "done";
export interface Task {
id: string;
title: string;
status: TaskStatus;
created_at: string;
position: number;
}
import * as z from "zod";
export const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Title too long"),
});
export const updateTaskStatusSchema = z.object({
id: z.string().uuid("Invalid task ID"),
status: z.enum(["todo", "in_progress", "done"]),
});
export const deleteTaskSchema = z.object({
id: z.string().uuid("Invalid task ID"),
});
Your database is set up with proper constraints, security policies, and real-time change broadcasting. Next up, you will build the server-first board layout that fetches tasks and streams each column independently using Suspense.
Build the Server-First Board Layout with Streaming
Your database is live, your types are defined, and your Supabase clients are ready. Now it is time to build the actual board UI that fetches tasks directly on the server.
In this step, you will create a server-first page that queries Supabase from inside a Server Component and streams each column independently using Suspense boundaries. The result is a board that sends full HTML to the browser with zero client-side JavaScript for the column rendering.
In this step, get ready to:
- Create the board page as an async Server Component that fetches tasks per column.
- Add Suspense boundaries and a route-level loading skeleton for progressive streaming.
- Build the static BoardColumn and TaskCard UI components.
Build the board page as a Server Component
The board page fetches tasks directly on the server. Each column gets its own async sub-component that queries Supabase independently, so slow columns do not block fast ones.
- Create the folder app/board/ inside your task-board project.
- Create app/board/page.tsx and paste the following code:
import { Suspense } from "react";
import { createClient } from "@/lib/supabase/server";
import { BoardColumn } from "@/components/BoardColumn";
import { CreateTaskForm } from "@/components/CreateTaskForm";
import type { Task, TaskStatus } from "@/lib/types";
async function TaskColumn({ status, label }: { status: TaskStatus; label: string }) {
const supabase = await createClient();
const { data: tasks } = await supabase
.from("tasks")
.select("*")
.eq("status", status)
.order("created_at", { ascending: false });
return <BoardColumn status={status} label={label} tasks={(tasks as Task[]) ?? []} />;
}
export default function BoardPage() {
return (
<div className="space-y-6">
<CreateTaskForm />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Suspense fallback={<ColumnSkeleton />}>
<TaskColumn status="todo" label="Todo" />
</Suspense>
<Suspense fallback={<ColumnSkeleton />}>
<TaskColumn status="in_progress" label="In Progress" />
</Suspense>
<Suspense fallback={<ColumnSkeleton />}>
<TaskColumn status="done" label="Done" />
</Suspense>
</div>
</div>
);
}
function ColumnSkeleton() {
return (
<div className="bg-white rounded-lg p-4 shadow-sm animate-pulse">
<div className="h-6 bg-gray-200 rounded w-24 mb-4"></div>
<div className="space-y-3">
<div className="h-16 bg-gray-100 rounded"></div>
<div className="h-16 bg-gray-100 rounded"></div>
</div>
</div>
);
}
What does this code do?
This file has three pieces working together:
- TaskColumn is an async function that creates a Supabase server client, queries tasks filtered by status, and passes them to BoardColumn. Because it is async, React can suspend it while it fetches data.
- BoardPage renders three TaskColumn instances (one per status), each wrapped in its own Suspense boundary. Each column streams independently as its query resolves.
- ColumnSkeleton is the fallback UI shown while a column loads. It uses Tailwind's animate-pulse to create a shimmer effect.
Notice there is no 'use client' directive anywhere. This entire file runs on the server with zero client JavaScript.
Why Suspense per column instead of one boundary for the whole board?
Wrapping each column in its own Suspense boundary means a fast column (say, Todo with 2 tasks) renders immediately while a slower column (Done with 50 tasks) is still loading. Without separate boundaries, the entire board would wait for the slowest column before showing anything.
This pattern is called granular streaming. The static shell (your layout and column skeletons) ships instantly, and each async chunk fills in as it resolves.
The page imports BoardColumn, CreateTaskForm, and other components that do not exist yet. You will create them shortly. TypeScript will show import errors until then.
Add the route-level loading state
Next.js uses a special loading.tsx file to show an instant loading state for the entire route while navigating to it. This file wraps page.tsx in a Suspense boundary automatically.
- Create app/board/loading.tsx and paste this code:
export default function BoardLoading() {
return (
<div className="space-y-6">
<div className="h-12 bg-gray-200 rounded animate-pulse w-64"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-lg p-4 shadow-sm animate-pulse">
<div className="h-6 bg-gray-200 rounded w-24 mb-4"></div>
<div className="space-y-3">
<div className="h-16 bg-gray-100 rounded"></div>
<div className="h-16 bg-gray-100 rounded"></div>
</div>
</div>
))}
</div>
</div>
);
}
What does this code do?
This skeleton mirrors the shape of the full board (a header bar and three column cards) using gray placeholder blocks. Next.js automatically shows this as the initial UI while the route loads, so the learner sees structure instead of a blank page.
The loading.tsx file gives you route-level streaming for free. The granular Suspense boundaries in page.tsx then give you per-column streaming within the page itself.
Build the static UI components
The board page renders two Server Components for the visual layout. These receive data as props and produce pure HTML with no interactivity or client JavaScript.
- Create the components/ folder at the root of your task-board project.
- Create components/BoardColumn.tsx and paste the following code:
import type { Task, TaskStatus } from "@/lib/types";
import { TaskCard } from "@/components/TaskCard";
const statusColors: Record<TaskStatus, string> = {
todo: "bg-blue-100 text-blue-800",
in_progress: "bg-yellow-100 text-yellow-800",
done: "bg-green-100 text-green-800",
};
export function BoardColumn({
status,
label,
tasks,
}: {
status: TaskStatus;
label: string;
tasks: Task[];
}) {
return (
<div className="bg-white rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-700">{label}</h2>
<span className={`text-xs px-2 py-1 rounded-full ${statusColors[status]}`}>
{tasks.length}
</span>
</div>
<div className="space-y-3">
{tasks.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No tasks</p>
) : (
tasks.map((task) => <TaskCard key={task.id} task={task} />)
)}
</div>
</div>
);
}
What does this code do?
- statusColors maps each task status to a color-coded badge style. Todo is blue, In Progress is yellow, Done is green.
- The component renders a column header with the label and a count badge, then maps over tasks to render a TaskCard for each one.
- If there are no tasks, it shows a friendly "No tasks" placeholder instead of an empty column.
- Create components/TaskCard.tsx and paste this code:
import type { Task } from "@/lib/types";
import { StatusToggle } from "@/components/StatusToggle";
export function TaskCard({ task }: { task: Task }) {
return (
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
<p className="text-sm font-medium text-gray-900 mb-2">{task.title}</p>
<StatusToggle task={task} />
</div>
);
}
What does this code do?
TaskCard renders a single task as a card with the title and a StatusToggle component below it. The StatusToggle will be a client component you build in the next step to let users move tasks between columns.
Two imported components still do not exist: CreateTaskForm and StatusToggle. You will build both in the next step when you add interactivity. For now, create minimal placeholder files so the project compiles.
- Create components/CreateTaskForm.tsx with this temporary placeholder:
// Placeholder - will be replaced with the real form in the next step
export function CreateTaskForm() {
return null;
}
- Create components/StatusToggle.tsx with this temporary placeholder:
// Placeholder - will be replaced with the real toggle in the next step
export function StatusToggle({ task }: { task: any }) {
return null;
}
- Save all files.
- Return to your terminal and confirm the dev server is running with npm run dev.
- Open http://localhost:3000/board in your browser. You should see three white column cards labeled Todo, In Progress, and Done, each showing "No tasks".
View source to confirm server rendering
Right-click the page and select View Page Source. You should see the column labels and "No tasks" text directly in the HTML. This proves the content rendered on the server with no client-side JavaScript needed.
Not seeing the board columns?
- Check that all files are saved. Unsaved changes will not trigger a rebuild.
- Confirm you created the app/board/ folder (not app/boards/ or similar). The URL path matches the folder name exactly.
- If you see a TypeScript error about missing exports, confirm both placeholder files (CreateTaskForm.tsx and StatusToggle.tsx) export named functions matching the import names.
Why aren't my board columns showing up at /board?
- Commit your progress by running:
git add . && git commit -m "feat: server-first board layout with Suspense streaming"
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and committed your changes.
ⓧ I'd like to double check the full code
Here is app/board/page.tsx:
import { Suspense } from "react";
import { createClient } from "@/lib/supabase/server";
import { BoardColumn } from "@/components/BoardColumn";
import { CreateTaskForm } from "@/components/CreateTaskForm";
import type { Task, TaskStatus } from "@/lib/types";
async function TaskColumn({ status, label }: { status: TaskStatus; label: string }) {
const supabase = await createClient();
const { data: tasks } = await supabase
.from("tasks")
.select("*")
.eq("status", status)
.order("created_at", { ascending: false });
return <BoardColumn status={status} label={label} tasks={(tasks as Task[]) ?? []} />;
}
export default function BoardPage() {
return (
<div className="space-y-6">
<CreateTaskForm />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Suspense fallback={<ColumnSkeleton />}>
<TaskColumn status="todo" label="Todo" />
</Suspense>
<Suspense fallback={<ColumnSkeleton />}>
<TaskColumn status="in_progress" label="In Progress" />
</Suspense>
<Suspense fallback={<ColumnSkeleton />}>
<TaskColumn status="done" label="Done" />
</Suspense>
</div>
</div>
);
}
function ColumnSkeleton() {
return (
<div className="bg-white rounded-lg p-4 shadow-sm animate-pulse">
<div className="h-6 bg-gray-200 rounded w-24 mb-4"></div>
<div className="space-y-3">
<div className="h-16 bg-gray-100 rounded"></div>
<div className="h-16 bg-gray-100 rounded"></div>
</div>
</div>
);
}
Here is app/board/loading.tsx:
export default function BoardLoading() {
return (
<div className="space-y-6">
<div className="h-12 bg-gray-200 rounded animate-pulse w-64"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-lg p-4 shadow-sm animate-pulse">
<div className="h-6 bg-gray-200 rounded w-24 mb-4"></div>
<div className="space-y-3">
<div className="h-16 bg-gray-100 rounded"></div>
<div className="h-16 bg-gray-100 rounded"></div>
</div>
</div>
))}
</div>
</div>
);
}
Here is components/BoardColumn.tsx:
import type { Task, TaskStatus } from "@/lib/types";
import { TaskCard } from "@/components/TaskCard";
const statusColors: Record<TaskStatus, string> = {
todo: "bg-blue-100 text-blue-800",
in_progress: "bg-yellow-100 text-yellow-800",
done: "bg-green-100 text-green-800",
};
export function BoardColumn({
status,
label,
tasks,
}: {
status: TaskStatus;
label: string;
tasks: Task[];
}) {
return (
<div className="bg-white rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-700">{label}</h2>
<span className={`text-xs px-2 py-1 rounded-full ${statusColors[status]}`}>
{tasks.length}
</span>
</div>
<div className="space-y-3">
{tasks.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No tasks</p>
) : (
tasks.map((task) => <TaskCard key={task.id} task={task} />)
)}
</div>
</div>
);
}
Here is components/TaskCard.tsx:
import type { Task } from "@/lib/types";
import { StatusToggle } from "@/components/StatusToggle";
export function TaskCard({ task }: { task: Task }) {
return (
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
<p className="text-sm font-medium text-gray-900 mb-2">{task.title}</p>
<StatusToggle task={task} />
</div>
);
}
Your board is rendering entirely on the server with progressive streaming per column. Next up, you will add Server Actions and optimistic UI to make it interactive.
Add Server Actions and Optimistic UI
Your board renders tasks from the database, but it cannot create new ones or move them between columns yet. The missing piece is a way to mutate data on the server without building separate API routes.
Server Actions let you define server-side mutations right inside your codebase. Combined with React's useOptimistic hook, users see instant feedback the moment they interact, even before the server responds.
In this step, get ready to:
- Create Server Actions for task creation and status updates.
- Build a task creation form with instant optimistic feedback.
- Build a status toggle that moves tasks between columns instantly.
Create Server Actions for task mutations
Traditional React apps use API routes to handle data mutations. Server Actions replace that pattern entirely. You write an async function with a "use server" directive and call it directly from your client components.
- Create a new folder called _actions inside app/board/.
- Create a file called tasks.ts inside the new _actions folder.
- Add the Server Action directive, imports, type definition, and the createTask action by pasting this code:
"use server";
import { revalidateTag } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { createTaskSchema, updateTaskStatusSchema } from "@/lib/schemas";
// Standardized response shape for all actions
export type ActionResponse = {
success: boolean;
error?: string;
};
export async function createTask(
_prevState: ActionResponse,
formData: FormData
): Promise<ActionResponse> {
// Validate the form input with Zod
const parsed = createTaskSchema.safeParse({
title: formData.get("title"),
});
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0].message };
}
// Insert the validated task into Supabase
const supabase = await createClient();
const { error } = await supabase.from("tasks").insert({
title: parsed.data.title,
status: "todo",
position: 0,
});
if (error) {
return { success: false, error: "Failed to create task" };
}
// Invalidate the tasks cache so the board re-fetches
revalidateTag("tasks");
return { success: true };
}
What does this code do?
- "use server" at the top marks every exported function in this file as a Server Action that runs only on the server.
- ActionResponse gives every action a consistent return shape so client components always know what to expect.
- createTask receives the previous state and form data (this signature is required by useActionState), validates with Zod, then inserts into Supabase.
- revalidateTag("tasks") tells Next.js that cached data tagged with "tasks" is stale, triggering a server re-render with fresh data.
- Now add the updateTaskStatus action below createTask in the same file:
export async function updateTaskStatus(
id: string,
status: string
): Promise<ActionResponse> {
// Validate the task ID and new status value
const parsed = updateTaskStatusSchema.safeParse({ id, status });
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0].message };
}
// Update the task's status column in Supabase
const supabase = await createClient();
const { error } = await supabase
.from("tasks")
.update({ status: parsed.data.status })
.eq("id", parsed.data.id);
if (error) {
return { success: false, error: "Failed to update task" };
}
// Invalidate the cache to refresh all columns
revalidateTag("tasks");
return { success: true };
}
What does this code do?
- updateTaskStatus takes a task ID and new status string directly (not form data) because it will be called programmatically from a button click.
- The Zod schema ensures the status value is one of "todo", "in_progress", or "done" before it reaches the database.
- Both actions follow the same pattern: validate, mutate, invalidate cache, return a structured response.
- Save app/board/_actions/tasks.ts.
Why Server Actions instead of API routes?
Server Actions integrate directly with React's form and transition system. You get automatic request deduplication, streaming responses, and progressive enhancement (forms work even before JavaScript loads).
For a project with fewer than 20 mutations, Server Actions eliminate the boilerplate of creating and maintaining separate route handlers.
Seeing a TypeScript error?
- Make sure the "use server" directive is on the very first line of the file, before any imports.
- Confirm you imported updateTaskStatusSchema (not updateStatusSchema) to match the export name in lib/schemas.ts.
- Help me fix a TypeScript error in my Server Actions file.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/board/_actions/tasks.ts.
ⓧ I'd like to double check the full code
"use server";
import { revalidateTag } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { createTaskSchema, updateTaskStatusSchema } from "@/lib/schemas";
export type ActionResponse = {
success: boolean;
error?: string;
};
export async function createTask(
_prevState: ActionResponse,
formData: FormData
): Promise<ActionResponse> {
const parsed = createTaskSchema.safeParse({
title: formData.get("title"),
});
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0].message };
}
const supabase = await createClient();
const { error } = await supabase.from("tasks").insert({
title: parsed.data.title,
status: "todo",
position: 0,
});
if (error) {
return { success: false, error: "Failed to create task" };
}
revalidateTag("tasks");
return { success: true };
}
export async function updateTaskStatus(
id: string,
status: string
): Promise<ActionResponse> {
const parsed = updateTaskStatusSchema.safeParse({ id, status });
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0].message };
}
const supabase = await createClient();
const { error } = await supabase
.from("tasks")
.update({ status: parsed.data.status })
.eq("id", parsed.data.id);
if (error) {
return { success: false, error: "Failed to update task" };
}
revalidateTag("tasks");
return { success: true };
}
Build the CreateTaskForm client component
The board page already imports a CreateTaskForm component. Now you will create the actual file that powers task creation with a form wired directly to the createTask Server Action.
- Create components/CreateTaskForm.tsx and paste this code:
"use client";
import { useActionState, useRef } from "react";
import { createTask, type ActionResponse } from "@/app/board/_actions/tasks";
// Start with a neutral state before the first submission
const initialState: ActionResponse = { success: false };
export function CreateTaskForm() {
// useActionState wires the form to the Server Action and tracks pending state
const [state, formAction, isPending] = useActionState(createTask, initialState);
const formRef = useRef<HTMLFormElement>(null);
// Reset the input field when a task is created successfully
if (state.success) {
formRef.current?.reset();
}
return (
<form ref={formRef} action={formAction} className="flex gap-3 max-w-md">
<input
type="text"
name="title"
placeholder="Add a new task..."
required
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isPending}
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? "Adding..." : "Add Task"}
</button>
{state.error && (
<p className="text-sm text-red-600 self-center">{state.error}</p>
)}
</form>
);
}
What does this code do?
- "use client" marks this as a Client Component that hydrates in the browser. Only interactive components need this directive.
- useActionState connects the form's action attribute to the createTask Server Action. It returns the latest response state, a bound action, and a pending boolean.
- useRef holds a reference to the form element so you can call .reset() to clear the input after a successful submission.
- The button text changes to "Adding..." while the action is in flight, giving the user immediate feedback.
- Save components/CreateTaskForm.tsx.
- Switch back to your browser and refresh /board. You should see a text input and an Add Task button at the top of the page.
- Type a task title and click Add Task. The button should briefly show "Adding..." then the task appears in the Todo column.
Task not appearing after submission?
- Check your browser console for errors. A common cause is the NEXT_PUBLIC_SUPABASE_URL env var missing a trailing slash or containing a typo.
- Open the Supabase Table Editor and check if the row was inserted. If it was, the issue is cache invalidation. Restart your dev server.
- My task form submits but the task does not appear in the column.
✔️ Awesome, I've got everything!
Great. Double check you have saved components/CreateTaskForm.tsx.
ⓧ I'd like to double check the full code
"use client";
import { useActionState, useRef } from "react";
import { createTask, type ActionResponse } from "@/app/board/_actions/tasks";
const initialState: ActionResponse = { success: false };
export function CreateTaskForm() {
const [state, formAction, isPending] = useActionState(createTask, initialState);
const formRef = useRef<HTMLFormElement>(null);
if (state.success) {
formRef.current?.reset();
}
return (
<form ref={formRef} action={formAction} className="flex gap-3 max-w-md">
<input
type="text"
name="title"
placeholder="Add a new task..."
required
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isPending}
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? "Adding..." : "Add Task"}
</button>
{state.error && (
<p className="text-sm text-red-600 self-center">{state.error}</p>
)}
</form>
);
}
Build the StatusToggle client component with useOptimistic
When a user clicks a status button, waiting for the server round trip creates a sluggish feel. The useOptimistic hook solves this by rendering the new state immediately, then reconciling once the server confirms.
- Create components/StatusToggle.tsx and paste this code:
"use client";
import { useOptimistic, useTransition } from "react";
import { updateTaskStatus } from "@/app/board/_actions/tasks";
import type { Task, TaskStatus } from "@/lib/types";
// Map each status to the action label and target status
const nextStatus: Record<TaskStatus, { label: string; target: TaskStatus }> = {
todo: { label: "Start", target: "in_progress" },
in_progress: { label: "Complete", target: "done" },
done: { label: "Reopen", target: "todo" },
};
export function StatusToggle({ task }: { task: Task }) {
// Optimistic state updates the UI instantly before server confirmation
const [optimisticStatus, setOptimisticStatus] = useOptimistic(task.status);
const [isPending, startTransition] = useTransition();
const { label, target } = nextStatus[optimisticStatus];
function handleToggle() {
startTransition(async () => {
// Show the new status immediately in the UI
setOptimisticStatus(target);
// Then send the update to the server
await updateTaskStatus(task.id, target);
});
}
return (
<button
onClick={handleToggle}
disabled={isPending}
className="text-xs px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded text-gray-700 disabled:opacity-50"
>
{label}
</button>
);
}
What does this code do?
- useOptimistic(task.status) creates an optimistic version of the task's status. When you call setOptimisticStatus, React re-renders the component with the new value immediately.
- useTransition wraps the async work so React knows this update can be interrupted. If the server action fails, React automatically reverts the optimistic state.
- The nextStatus map defines the cycle: Todo moves to In Progress, In Progress moves to Done, and Done reopens back to Todo.
- Save components/StatusToggle.tsx.
- Refresh /board in your browser. Each task card now shows a button (Start, Complete, or Reopen depending on its current column).
- Click a status button. The task's button label changes instantly without any visible delay.
Button click does nothing?
- Confirm components/TaskCard.tsx imports StatusToggle from "@/components/StatusToggle". The import path is case-sensitive.
- Check the browser console for a hydration mismatch error. This can happen if you accidentally removed the "use client" directive at the top of StatusToggle.tsx.
- My StatusToggle button does not respond to clicks on the task board.
✔️ Awesome, I've got everything!
Great. Double check you have saved components/StatusToggle.tsx.
ⓧ I'd like to double check the full code
"use client";
import { useOptimistic, useTransition } from "react";
import { updateTaskStatus } from "@/app/board/_actions/tasks";
import type { Task, TaskStatus } from "@/lib/types";
const nextStatus: Record<TaskStatus, { label: string; target: TaskStatus }> = {
todo: { label: "Start", target: "in_progress" },
in_progress: { label: "Complete", target: "done" },
done: { label: "Reopen", target: "todo" },
};
export function StatusToggle({ task }: { task: Task }) {
const [optimisticStatus, setOptimisticStatus] = useOptimistic(task.status);
const [isPending, startTransition] = useTransition();
const { label, target } = nextStatus[optimisticStatus];
function handleToggle() {
startTransition(async () => {
setOptimisticStatus(target);
await updateTaskStatus(task.id, target);
});
}
return (
<button
onClick={handleToggle}
disabled={isPending}
className="text-xs px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded text-gray-700 disabled:opacity-50"
>
{label}
</button>
);
}
- Stage and commit all changes by running:
git add . && git commit -m "feat: server actions with Zod validation and optimistic task creation"
Your board now supports full interactivity with instant feedback. Next up, you will wire in Supabase Realtime so changes made in one browser tab appear automatically in another.
Enable Real-Time Multi-Tab Sync
Your task board works great in a single tab. But real collaboration means multiple people (or tabs) seeing the same updates at the same time.
In this step, you'll wire up Supabase Realtime to push database changes directly to the browser. When a task is created or moved in one tab, every other tab updates automatically within 1-2 seconds.
In this step, get ready to:
- Create a RealtimeListener client component that subscribes to Postgres Changes.
- Mount the listener in a board layout so it runs once for the entire route.
- Test live multi-tab sync by creating and moving tasks across two browser tabs.
Create the RealtimeListener client component
The RealtimeListener is a "client island" that renders no visible UI. Its only job is to open a WebSocket connection to Supabase and listen for changes on the tasks table.
Why router.refresh() instead of client-state management?
When the listener detects a change, it calls router.refresh() to trigger a fresh server-side render. This reuses your existing Server Component data fetching instead of duplicating fetch logic on the client.
The trade-off is slightly higher latency for the non-acting tab (1-2 second round trip). For a task board, that's perfectly acceptable.
- Create a new file at components/RealtimeListener.tsx and add the following code:
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
export function RealtimeListener() {
const router = useRouter();
const supabase = createClient();
useEffect(() => {
// Subscribe to all changes (INSERT, UPDATE, DELETE) on the tasks table
const channel = supabase
.channel("tasks-changes")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "tasks" },
() => {
// Trigger a server-side re-render to fetch fresh data
router.refresh();
}
)
.subscribe();
// Clean up the subscription when the component unmounts
return () => {
supabase.removeChannel(channel);
};
}, [supabase, router]);
// This component renders nothing — it only listens
return null;
}
What does this code do?
- "use client" marks this as a client component because it uses browser-only hooks (useEffect, useRouter).
- supabase.channel("tasks-changes") opens a named WebSocket channel to Supabase Realtime.
- .on("postgres_changes", ...) listens for any INSERT, UPDATE, or DELETE event on the tasks table.
- supabase.removeChannel(channel) in the cleanup function prevents memory leaks by closing the WebSocket when the component unmounts.
- return null means this component is invisible. It's purely a subscription manager.
- Save components/RealtimeListener.tsx.
You won't see a change yet. The next chunk mounts this listener into the board layout so it actually runs.
Seeing a TypeScript error on the import?
Make sure the import path is @/lib/supabase/client (not @/lib/supabase/server). The server client uses next/headers which is not available in client components.
help me fix the import error in RealtimeListener.tsx
Wire the RealtimeListener into the board layout
A layout in Next.js wraps all pages under its route segment. By placing the listener here, it mounts once and stays active as the learner navigates within /board.
- Create a new file at app/board/layout.tsx and add the following code:
import { RealtimeListener } from "@/components/RealtimeListener";
export default function BoardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 px-6 py-4">
<h1 className="text-2xl font-bold text-gray-900">Task Board</h1>
</header>
<main className="p-6">
{children}
</main>
{/* Invisible client island — subscribes to realtime changes */}
<RealtimeListener />
</div>
);
}
What does this code do?
- This layout is a Server Component (no "use client" directive). It renders a header and wraps the page content.
- The RealtimeListener is placed after {children} so it doesn't block any visible content from rendering.
- Because the layout persists across navigation within /board, the WebSocket connection stays open without reconnecting on every page transition.
- Save app/board/layout.tsx.
- Switch back to your browser tab at /board. You should see a "Task Board" header appear above the columns.
Don't see the header?
Make sure the file is saved at app/board/layout.tsx (not app/layout.tsx). The board layout must live inside the app/board/ directory to wrap the board route.
help me fix my board layout file placement
Test the full real-time flow
The real proof of realtime sync is seeing changes appear in a second tab without touching it.
- Open a second browser tab and navigate to http://localhost:3000/board.
- In Tab 1, create a new task using the form.
- Switch to Tab 2. The new task should appear in the Todo column within 1-2 seconds without a manual refresh.
- In Tab 2, click the Start button on a task to move it to In Progress.
- Switch back to Tab 1. The task should move to the In Progress column automatically.
What's happening under the hood?
In the acting tab, the optimistic update shows the change instantly. Meanwhile, the Supabase database broadcasts the change over WebSocket to all connected clients.
The other tab's RealtimeListener picks up the event and calls router.refresh(), which triggers a server-side re-render with fresh data. The result arrives in 1-2 seconds.
- Commit your realtime work by running this command:
git add . && git commit -m "feat: realtime multi-tab sync via Supabase Postgres Changes"
Tab 2 not updating?
Open the browser DevTools console in Tab 2 and look for WebSocket connection messages. If you see no connection, check that Realtime is enabled for the tasks table in your Supabase dashboard under Database then Replication.
Also confirm that your .env.local has both NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY set correctly. The browser client needs these to connect.
help me debug why my second tab isn't receiving realtime updates
✔️ Awesome, I've got everything!
Great. Double check that both files are saved and your two tabs stay in sync.
ⓧ I'd like to double check the full code
Here's the complete code for both new files:
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
export function RealtimeListener() {
const router = useRouter();
const supabase = createClient();
useEffect(() => {
const channel = supabase
.channel("tasks-changes")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "tasks" },
() => {
router.refresh();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase, router]);
return null;
}
import { RealtimeListener } from "@/components/RealtimeListener";
export default function BoardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 px-6 py-4">
<h1 className="text-2xl font-bold text-gray-900">Task Board</h1>
</header>
<main className="p-6">
{children}
</main>
<RealtimeListener />
</div>
);
}
Your board now syncs across tabs in real time. You've built a server-first architecture where the realtime listener is a tiny client island that triggers server re-renders on demand. Next up, the Secret Mission challenges you to add optimistic task deletion with error recovery.
Secret mission
Task Deletion with Optimistic Removal and Error Recovery
Your task board can create and move tasks, but what about deleting them? In this challenge, you'll add a delete flow that removes tasks instantly from the UI, shows a confirmation dialog to prevent accidents, and gracefully recovers if the server-side deletion fails.
Clean Up Your Resources
Clean Up Your Resources
This project runs on the Supabase Free Tier with a local Next.js development server. There are no ongoing costs, but Supabase limits you to 2 active free projects and pauses inactive ones after 1 week.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Supabase project (cloud database with Realtime enabled).
- Local task-board project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project soon.
- Your Supabase project stays active as long as you interact with it at least once a week. After 1 week of inactivity, Supabase automatically pauses free projects.
- Your local task-board folder and Git history remain on your Desktop.
- The dev server only runs when you start it with npm run dev. It does not consume resources when stopped.
Pause - I'll come back to this later
Shut down the local dev server to free up your terminal. Supabase will pause itself after 1 week of inactivity.
- In the terminal running the dev server, press Ctrl+C to stop it.
- Your Supabase project will auto-pause after 1 week without activity. You can restore it later from the Supabase dashboard.
- Your local files and Git history are preserved in the task-board folder on your Desktop.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent for the Supabase project.
- Delete your Supabase project: open the Supabase dashboard, select your project, click Project Settings in the sidebar, then General. Scroll down to the Danger Zone section and delete the project. This permanently removes all data including the tasks table.
- Delete the local project folder. On macOS or Linux, run the command below.
cd ~/Desktop
rm -rf task-board
- On Windows, run this command instead.
cd ~\Desktop
Remove-Item -Recurse -Force task-board
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) in your Desktop directory. You should no longer see task-board in the output.
Nice Work!
Nice Work!
You just built a production-quality real-time collaborative task board using Next.js 16 App Router and Supabase. Open two browser tabs, create a task in one, and watch it appear in the other. That is senior-level architecture in action.
You've learned how to:
- Build a server-first architecture where 90% of the component tree renders as Server Components with zero client-side JavaScript, using client islands only for interactivity.
- Implement Server Actions with Zod validation and useOptimistic for instant UI feedback before server confirmation.
- Set up Supabase Realtime for live multi-tab synchronization and Streaming with Suspense for progressive column loading.
- Secret Mission: Added optimistic task deletion with automatic error recovery and a confirmation dialog.
Ready to quiz yourself?