Build a SaaS App with Next.js and Supabase

Build a full-stack task dashboard with auth and a real PostgreSQL database.

Introduction

30 Second Summary

Every app you use daily follows the same pattern. You log in, you see your data, and nobody else can see it.

In this project, you will build a Task Dashboard app from scratch with user accounts, a real database, and a protected dashboard that only logged-in users can see.

What You'll Build

A full-stack Task Dashboard application built with Next.js, Supabase, and TypeScript where users can sign up, log in, and manage their personal tasks.

By the end of this project, you'll have:

  • A working Next.js web application with user sign-up and login powered by Supabase authentication.
  • A PostgreSQL database with Row Level Security so each user's data stays completely private.
  • A protected dashboard with full CRUD operations for creating, reading, updating, and deleting tasks.
  • Secret Mission: Add task priority levels and a filter to show only high-priority tasks.

Are there any prerequisites?

None. This guide walks you through installing every tool you need from scratch, including VS Code and Node.js. All you need is a Windows PC and basic programming knowledge (understanding variables and lists).

Set Up Your Development Environment

Every web application starts with a solid foundation of tools. Before you can build your Task Dashboard, you need a code editor to write your code and a runtime to execute it.

In this step, you will install Visual Studio Code (your code editor) and Node.js (the engine that runs JavaScript on your computer). Then you will scaffold your Next.js project with a single command.

In this step, get ready to:
  • Install Visual Studio Code and Node.js on your computer.
  • Create a brand new Next.js project called task-dashboard.
  • Confirm your app runs locally in the browser.
Install Visual Studio Code

Visual Studio Code (VS Code) is a free code editor that you will use throughout this project to write and manage your files.

  • Open your browser and go to https://code.visualstudio.com.
  • Click the Download for Windows button.
  • Run the downloaded installer file.
  • During installation, check the box for Add to PATH so you can open VS Code from the terminal later.
  • Accept all other defaults and click Install.

Once the installer finishes, verify that VS Code works.

  • Press the Windows key to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

✔️ VS Code opened successfully

You should see the VS Code welcome screen. You are ready to move on.

ⓧ It didn't open or I can't find it

If VS Code does not appear in your search results, the installation may not have completed.

  • Go back to https://code.visualstudio.com and download the installer again.
  • Run it and make sure you check Add to PATH during the setup wizard.
  • Restart your computer after installation, then search for Visual Studio Code again.
Install Node.js

Node.js is the runtime that lets you run JavaScript outside the browser. It also includes npm, a package manager you will use to install libraries and run your app.

This project requires Node.js v24 (the current LTS release). Let's check whether you already have it installed.

  • Press the Windows key to open your search bar.
  • Type Command Prompt and press Enter to open it.
  • Check your Node.js version by running this command:
node -v

✔️ I see v24 or higher

If you see something like v24.16.0 or any v24.x.x number, you are good to go.

  • Also confirm npm is available by running:
npm -v

You should see a version number printed. Both tools are ready.

ⓧ I see an older version

If you see a version lower than v24 (for example v18.x.x or v20.x.x), you need to upgrade. The Node.js Windows installer will replace your current version with the latest LTS.

  • Go to https://nodejs.org and click the LTS download button (v24.16.0).
  • Run the .msi installer and accept the defaults. Make sure Add to PATH is checked.
  • Close your current Command Prompt and open a new one.
  • Run node -v again. You should now see v24.16.0.
  • Run npm -v to confirm the package manager is also available.

ⓧ Command not found

If you see an error like "node is not recognized as an internal or external command," Node.js is not installed yet.

  • Go to https://nodejs.org and click the LTS download button (v24.16.0).
  • Run the .msi installer and accept the defaults. Make sure Add to PATH is checked.
  • Close your current Command Prompt and open a new one.
  • Run node -v again. You should now see v24.16.0.
  • Run npm -v to confirm the package manager is also available.

What is Node.js?

Node.js lets you run JavaScript on your computer (not just in the browser). It powers the development server that serves your app locally while you build it.

The npm tool that comes bundled with Node.js downloads and manages third-party packages (libraries) your project depends on.

Create your Next.js project

Next.js is a framework built on top of React that gives you routing, server-side rendering, and a dev server out of the box. You will scaffold a new project using a single terminal command.

  • Move to your Desktop by running this command:
cd Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest task-dashboard --yes

What does this command do?

This command uses npx to download and run the official Next.js project scaffolding tool. The --yes flag accepts all the default settings automatically: TypeScript, Tailwind CSS, ESLint, App Router, and Turbopack.

The result is a ready-to-run project folder called task-dashboard on your Desktop.

First time using npx?

If this is your first time running npx, you may see a prompt that says "Need to install the following packages: create-next-app@latest. Ok to proceed? (y)". Type y and press Enter to continue.

Wait for the scaffolding to finish. This may take a minute while packages are downloaded.

  • Move into your new project folder by running:
cd task-dashboard
  • Start the development server by running:
npm run dev

What should I see?

After a few seconds, your terminal will display output similar to this:

▲ Next.js 16.x.x
- Local:        http://localhost:3000

✓ Ready in 1.2s

The "Ready" line confirms your server is running and waiting for requests.

  • Open your browser and go to http://localhost:3000.

You should see the Next.js welcome page with the Next.js logo and links to documentation.

Don't see the welcome page?

  • Make sure your terminal still shows the "Ready" message. If it shows an error, try running npm run dev again.
  • Check that you typed http://localhost:3000 exactly in your browser address bar (not https).
  • If port 3000 is already in use, the terminal will show a different port number. Use that URL instead.

Still stuck? Help me debug why my Next.js dev server isn't showing the welcome page.

  • Go back to your terminal and press Ctrl+C to stop the development server.

Your terminal should return to the normal prompt inside the task-dashboard folder.

Your development environment is ready. Next up, you will connect your app to a real database and authentication system with Supabase.

Set Up Supabase

Your Next.js project is scaffolded and ready to go. Now it needs somewhere to store user accounts and their data.

Supabase is a backend platform that bundles a PostgreSQL database and user authentication into one free service. In this step, you'll create a Supabase project, build a database table, and wire everything into your app.

In this step, get ready to:
  • Create a free Supabase project with a PostgreSQL database.
  • Build a tasks table with Row Level Security policies.
  • Connect Supabase to your Next.js app using environment variables.
Create a Supabase account and project

Supabase will host both your database and your authentication system. You need to create a free account and a project before you can store any data.

  • Open https://supabase.com in your browser.
  • Click Start your project and sign up for a free account using your email or GitHub.
  • Once signed in, click New Project.
  • Enter task-dashboard as the project name.
  • Set a database password and save it somewhere safe (you will not need it again in this project, but keep it just in case).
  • Select South Asia (Mumbai) as the region for the best performance from Pakistan.
  • Click Create new project.
  • Wait about 1 minute for the project to finish setting up. You will see a dashboard home page when it is ready.

What is Supabase's free tier?

Supabase's free tier gives you 2 active projects, 500 MB of database storage, and 50,000 monthly active users. That is more than enough for this project.

One thing to note: free projects pause after 1 week of inactivity. If that happens, just visit your dashboard and click Restore to bring it back instantly.

Create the tasks table

Your app needs a place to store tasks. You will create a table in your PostgreSQL database and protect it with Row Level Security so each user can only see their own data.

  • In the left sidebar of your Supabase dashboard, click SQL Editor.
  • Delete any placeholder text in the editor, then paste the following SQL:
create table tasks (
  id bigint primary key generated always as identity,
  user_id uuid references auth.users(id) on delete cascade not null,
  title text not null,
  is_complete boolean default false,
  created_at timestamptz default now()
);

alter table tasks enable row level security;

create policy "Users can view their own tasks"
on tasks for select
to authenticated
using (auth.uid() = user_id);

create policy "Users can insert their own tasks"
on tasks for insert
to authenticated
with check (auth.uid() = user_id);

create policy "Users can update their own tasks"
on tasks for update
to authenticated
using (auth.uid() = user_id);

create policy "Users can delete their own tasks"
on tasks for delete
to authenticated
using (auth.uid() = user_id);

What does this SQL do?

  • Creates a tasks table with columns for the task title, completion status, timestamp, and a reference to which user owns it.
  • Enables Row Level Security (RLS) on the table so the database itself enforces who can access what.
  • Creates four policies (select, insert, update, delete) that only allow authenticated users to interact with rows where user_id matches their own ID.

This means even if someone tampers with the frontend, the database will reject any request to touch another user's tasks.

  • Click the Run button (or press Ctrl+Enter) to execute the SQL.
  • You should see a "Success. No rows returned" message at the bottom of the editor.

Seeing an error instead of success?

  • Make sure you copied the entire SQL block, including the final semicolon after the last policy.
  • If you see "relation tasks already exists", you may have run the SQL twice. That is fine. Your table is already created.
  • Help me debug my SQL Editor error in Supabase.
  • To confirm the table was created, click Table Editor in the left sidebar. You should see tasks listed as a table.
Connect Supabase to your Next.js app

Your Next.js app needs two pieces of information to talk to Supabase: the Project URL and the Publishable Key. You will store these as environment variables so your code can read them without hardcoding secrets.

  • In the left sidebar of your Supabase dashboard, click Settings.
  • Click API Keys.
  • Copy the Project URL value. It looks like https://abcdefgh.supabase.co.
  • Copy the Publishable Key value. It starts with sb_publishable_.

What is a publishable key?

The publishable key is safe to use in client-side code because your database is protected by Row Level Security. Even if someone sees this key, they cannot access another user's data. The database policies you just created handle the protection.

  • Switch back to VS Code with your task-dashboard project open.
  • Create a new file called .env.local in the root of your project (the same folder as package.json).
  • Add the following to .env.local, replacing the placeholder values with the Project URL and Publishable Key you just copied:
NEXT_PUBLIC_SUPABASE_URL=your-project-url-here
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key-here

What do these variable names mean?

  • NEXT_PUBLIC_ is a Next.js convention. Any environment variable with this prefix is available in the browser. Without it, the variable stays server-side only.
  • SUPABASE_URL tells your app where your Supabase project lives on the internet.
  • SUPABASE_PUBLISHABLE_KEY is the credential that lets your frontend authenticate with Supabase.
  • Save .env.local.

✔️ Awesome, I've got everything!

Great. Make sure you have saved .env.local with your real Supabase URL and key pasted in.

ⓧ I'd like to double check the full code

Your .env.local file should look like this (with your own values replacing the placeholders):

NEXT_PUBLIC_SUPABASE_URL=your-project-url-here
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key-here

Now you need to install the Supabase packages so your Next.js code can communicate with the database and authentication system.

  • Install the Supabase packages by running this command in your terminal:
npm install @supabase/supabase-js @supabase/ssr
  • Wait for the installation to complete. You should see "added X packages" with no errors.

Seeing install errors?

  • Make sure your terminal is inside the task-dashboard folder. Run cd task-dashboard if you are not sure.
  • Check your internet connection. The packages need to download from the npm registry.
  • Help me fix my npm install error for Supabase packages.

Your database is live, your security policies are in place, and your app is connected. Next up, you will build the authentication pages so users can actually sign up and log in.

Build the Authentication Pages

Your database is ready with a tasks table and Row Level Security policies protecting each user's data. Now you need a way for users to actually create accounts and log in.

In this step, you will create a reusable Supabase client utility and build a login page with both sign-up and sign-in modes.

In this step, get ready to:
  • Create a Supabase client utility that connects your app to your database.
  • Build a login and sign-up page with email and password fields.
  • Test user registration and confirm it appears in your Supabase dashboard.
Create the Supabase client utility

Every page in your app that talks to Supabase needs a client instance. Instead of repeating the setup code everywhere, you will create one shared utility file that any component can import.

  • In VS Code, create a new folder called lib inside your task-dashboard project folder.
  • Inside lib, create another folder called supabase.
  • Create a new file called client.ts inside lib/supabase/ and paste the following code:
import { createBrowserClient } from "@supabase/ssr";

// Creates a Supabase client for use in browser-side components
export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}

What does this code do?

createBrowserClient is a function from the @supabase/ssr package that creates a Supabase client designed for use in the browser. It uses a singleton pattern, meaning only one instance is created no matter how many components call createClient().

The ! after each environment variable tells TypeScript "I promise this value exists." It reads the URL and key you saved in .env.local during the previous step.

  • Save lib/supabase/client.ts.

You will not see a visible change yet. This utility is used by the login page you will build next.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}
Build the login and sign-up page

Your app needs a page where users can either create a new account or log into an existing one. You will build a single page that toggles between "Sign Up" and "Log In" modes.

  • In VS Code, create a new folder called login inside the app directory.
  • Create a new file called page.tsx inside app/login/ and paste the following code:
"use client";

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

export default function LoginPage() {
  // State for form fields and UI feedback
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [isSignUp, setIsSignUp] = useState(false);
  const [error, setError] = useState("");
  const [message, setMessage] = useState("");
  const router = useRouter();
  const supabase = createClient();

  // Handles both sign-up and login depending on the current mode
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setMessage("");

    if (isSignUp) {
      const { error } = await supabase.auth.signUp({ email, password });
      if (error) {
        setError(error.message);
      } else {
        setMessage("Check your email to confirm your account!");
      }
    } else {
      const { error } = await supabase.auth.signInWithPassword({
        email,
        password,
      });
      if (error) {
        setError(error.message);
      } else {
        router.push("/dashboard");
      }
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
        <h1 className="text-2xl font-bold text-center mb-6">
          {isSignUp ? "Create Account" : "Log In"}
        </h1>

        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
            />
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
            />
          </div>

          {error && <p className="text-red-600 text-sm">{error}</p>}
          {message && <p className="text-green-600 text-sm">{message}</p>}

          <button
            type="submit"
            className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
          >
            {isSignUp ? "Sign Up" : "Log In"}
          </button>
        </form>

        <p className="mt-4 text-center text-sm text-gray-600">
          {isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
          <button
            onClick={() => setIsSignUp(!isSignUp)}
            className="text-blue-600 hover:underline"
          >
            {isSignUp ? "Log In" : "Sign Up"}
          </button>
        </p>
      </div>
    </div>
  );
}

What does this code do?

"use client" at the top tells Next.js this is a Client Component that runs in the browser. You need this because the page uses React state and event handlers.

The handleSubmit function checks if the user is in sign-up or login mode. For sign-up, it calls supabase.auth.signUp() which creates a new user in your Supabase project. For login, it calls supabase.auth.signInWithPassword() which authenticates an existing user.

On successful login, router.push("/dashboard") redirects the user to the dashboard page (which you will build in the next step). The toggle at the bottom lets users switch between "Log In" and "Sign Up" modes without navigating to a different page.

  • Save app/login/page.tsx.

You will not see a visible change yet because the dev server is not running. You will start it in the next substep to test your authentication flow.

Seeing red underlines in VS Code?

If you see errors under the @/lib/supabase/client import, make sure you created the lib/supabase/ folder at the root of your project (next to the app folder), not inside app. The @/ alias points to your project root.

help me fix import errors in my Next.js project

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

"use client";

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

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [isSignUp, setIsSignUp] = useState(false);
  const [error, setError] = useState("");
  const [message, setMessage] = useState("");
  const router = useRouter();
  const supabase = createClient();

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setMessage("");

    if (isSignUp) {
      const { error } = await supabase.auth.signUp({ email, password });
      if (error) {
        setError(error.message);
      } else {
        setMessage("Check your email to confirm your account!");
      }
    } else {
      const { error } = await supabase.auth.signInWithPassword({
        email,
        password,
      });
      if (error) {
        setError(error.message);
      } else {
        router.push("/dashboard");
      }
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
        <h1 className="text-2xl font-bold text-center mb-6">
          {isSignUp ? "Create Account" : "Log In"}
        </h1>

        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
            />
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
            />
          </div>

          {error && <p className="text-red-600 text-sm">{error}</p>}
          {message && <p className="text-green-600 text-sm">{message}</p>}

          <button
            type="submit"
            className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
          >
            {isSignUp ? "Sign Up" : "Log In"}
          </button>
        </form>

        <p className="mt-4 text-center text-sm text-gray-600">
          {isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
          <button
            onClick={() => setIsSignUp(!isSignUp)}
            className="text-blue-600 hover:underline"
          >
            {isSignUp ? "Log In" : "Sign Up"}
          </button>
        </p>
      </div>
    </div>
  );
}
Test your authentication flow

Your login page is ready. Time to start the dev server and confirm that user sign-up actually creates a record in your Supabase database.

  • Start your development server by running this command in the terminal:
npm run dev
  • Open your browser and go to http://localhost:3000/login.

You should see a clean white card with a "Log In" heading, an email field, a password field, and a blue Log In button.

  • Click the Sign Up link at the bottom of the card to switch to sign-up mode.
  • Enter an email address and a password (at least 6 characters).
  • Click Sign Up.

You should see a green message that says "Check your email to confirm your account!" in the form.

What about sign-out?

You will add a sign-out button in the next step when you build the dashboard. It calls supabase.auth.signOut() and redirects back to /login. The login page you just built will be the landing spot after signing out.

  • Switch to your Supabase dashboard in the browser.
  • Click Authentication in the left sidebar.
  • Click Users.

You should see a new row with the email address you just signed up with.

Don't see your user in the list?

Make sure you are looking at the correct Supabase project (the one named "task-dashboard"). Click the Reload button on the Users page to refresh the list.

If sign-up shows an error, check that your .env.local file has the correct NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY values from your Supabase project settings. After editing .env.local, restart the dev server by pressing Ctrl+C and running npm run dev again.

help me debug why my Supabase sign-up is not working

Your authentication pages are live and working. Next up, you will build the task dashboard where logged-in users can create, view, and manage their tasks.

Build the Task Dashboard

Your users can now sign up and log in. The next step is giving them a reason to be logged in.

In this step, you will build the main dashboard page where authenticated users can create, view, complete, and delete their personal tasks. The Row Level Security policies you set up earlier ensure each user only sees their own data.

In this step, get ready to:
  • Create a protected dashboard page that redirects unauthenticated visitors.
  • Build full CRUD operations (Create, Read, Update, Delete) for tasks.
  • Style the dashboard with Tailwind CSS so completed tasks look different from active ones.
Create the dashboard page

The dashboard needs to do two things before showing any content. It checks whether a user is logged in, and it loads that user's tasks from the database.

  • In VS Code, create a new folder called dashboard inside your app/ directory.
  • Inside app/dashboard/, create a new file called page.tsx.
  • Paste the following code into app/dashboard/page.tsx:
"use client";

import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";

// Define the shape of a task from the database
type Task = {
  id: number;
  title: string;
  is_complete: boolean;
  created_at: string;
  user_id: string;
};

export default function DashboardPage() {
  // State for tasks, new task input, user email, and loading status
  const [tasks, setTasks] = useState<Task[]>([]);
  const [newTask, setNewTask] = useState("");
  const [userEmail, setUserEmail] = useState("");
  const [loading, setLoading] = useState(true);
  const router = useRouter();
  const supabase = createClient();

  // On page load, check if user is logged in and fetch their tasks
  useEffect(() => {
    async function init() {
      const {
        data: { user },
      } = await supabase.auth.getUser();

      if (!user) {
        router.push("/login");
        return;
      }

      setUserEmail(user.email || "");
      await fetchTasks();
      setLoading(false);
    }
    init();
  }, []);

  // Fetch all tasks from Supabase, newest first
  async function fetchTasks() {
    const { data } = await supabase
      .from("tasks")
      .select("*")
      .order("created_at", { ascending: false });

    if (data) setTasks(data);
  }
}

What does this code do?

The "use client" directive tells Next.js this page runs in the browser, not on the server. This is required because we use React hooks like useState and useEffect.

The Task type defines the exact shape of data coming from your Supabase tasks table. The useEffect runs once on page load, calling supabase.auth.getUser() to check if someone is logged in. If not, it redirects them to /login.

The fetchTasks function queries your tasks table and orders results by created_at descending so the newest tasks appear first.

This file is not complete yet. You will add more functions below fetchTasks in the next substep.

Add task CRUD operations

Users need to add new tasks, mark them as done, and delete them. Each operation talks directly to your Supabase database, and Row Level Security ensures users can only touch their own rows.

  • In app/dashboard/page.tsx, find the closing } of the fetchTasks function.
  • Add the following code directly below it (still inside the DashboardPage component):
  // Insert a new task tied to the current user
  async function addTask(e: React.FormEvent) {
    e.preventDefault();
    if (!newTask.trim()) return;

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

    if (!user) return;

    await supabase
      .from("tasks")
      .insert({ title: newTask.trim(), user_id: user.id });

    setNewTask("");
    await fetchTasks();
  }

  // Toggle a task between complete and incomplete
  async function toggleTask(id: number, isComplete: boolean) {
    await supabase
      .from("tasks")
      .update({ is_complete: !isComplete })
      .eq("id", id);

    await fetchTasks();
  }

  // Remove a task from the database
  async function deleteTask(id: number) {
    await supabase.from("tasks").delete().eq("id", id);
    await fetchTasks();
  }

  // Sign out and redirect to the login page
  async function handleSignOut() {
    await supabase.auth.signOut();
    router.push("/login");
  }

What does this code do?

  • addTask prevents the form's default submit behavior, gets the current user's ID, and inserts a new row into the tasks table with the task title and user_id.
  • toggleTask flips the is_complete boolean for a specific task row using .eq("id", id) to target the right one.
  • deleteTask removes the matching row entirely from the database.
  • handleSignOut ends the session and sends the user back to the login page.

After each mutation, fetchTasks() is called again to refresh the list with the latest data from the database.

You still need to add the JSX that renders the dashboard UI. That comes next.

Style the dashboard

Now you will add the visual layer. The dashboard needs a header showing who is logged in, a form to add tasks, and a task list where completed items look distinct from active ones.

  • In app/dashboard/page.tsx, find the closing } of the handleSignOut function.
  • Add the following code directly below it (still inside the DashboardPage component, before the final closing }):
  // Show a loading state while checking authentication
  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <p>Loading...</p>
      </div>
    );
  }

  // Main dashboard layout with header, add-task form, and task list
  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <div className="max-w-2xl mx-auto px-4">
        <div className="flex justify-between items-center mb-8">
          <div>
            <h1 className="text-2xl font-bold">Task Dashboard</h1>
            <p className="text-gray-600 text-sm">{userEmail}</p>
          </div>
          <button
            onClick={handleSignOut}
            className="text-sm bg-gray-200 px-3 py-1 rounded hover:bg-gray-300 transition"
          >
            Sign Out
          </button>
        </div>

        <form onSubmit={addTask} className="flex gap-2 mb-6">
          <input
            type="text"
            value={newTask}
            onChange={(e) => setNewTask(e.target.value)}
            placeholder="Add a new task..."
            className="flex-1 rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
          />
          <button
            type="submit"
            className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition"
          >
            Add
          </button>
        </form>

        <div className="space-y-2">
          {tasks.length === 0 ? (
            <p className="text-gray-500 text-center py-8">
              No tasks yet. Add one above!
            </p>
          ) : (
            tasks.map((task) => (
              <div
                key={task.id}
                className={`flex items-center gap-3 p-3 rounded-md border ${
                  task.is_complete
                    ? "bg-green-50 border-green-200"
                    : "bg-white border-gray-200"
                }`}
              >
                <input
                  type="checkbox"
                  checked={task.is_complete}
                  onChange={() => toggleTask(task.id, task.is_complete)}
                  className="h-4 w-4"
                />
                <span
                  className={`flex-1 ${
                    task.is_complete
                      ? "line-through text-gray-400"
                      : "text-gray-800"
                  }`}
                >
                  {task.title}
                </span>
                <button
                  onClick={() => deleteTask(task.id)}
                  className="text-red-500 hover:text-red-700 text-sm"
                >
                  Delete
                </button>
              </div>
            ))
          )}
        </div>
      </div>
    </div>
  );

What does this code do?

  • The if (loading) block shows a simple loading message while the auth check runs. This prevents a flash of content before we know if the user is logged in.
  • The header displays the user's email and a Sign Out button.
  • The form calls addTask on submit. The input is controlled by the newTask state variable.
  • Each task row uses Tailwind CSS conditional classes. Completed tasks get a green background (bg-green-50) and line-through text. Active tasks have a white background.
  • The checkbox calls toggleTask and the Delete button calls deleteTask.
  • Save app/dashboard/page.tsx.
  • Make sure your dev server is still running (npm run dev in the terminal).
  • Open http://localhost:3000/login in your browser and log in with the test account you created earlier.
  • After logging in, you should be redirected to the dashboard page showing your email and an empty task list.

Page shows Loading... and never finishes?

Make sure your .env.local file has the correct Supabase URL and publishable key. If those values are wrong, the auth check will fail silently.

Also check that you logged in (not just signed up). If your Supabase project has email confirmations enabled, you need to confirm the email before you can log in.

Help me debug this

  • Add a few tasks using the input field and the Add button.
  • Click the checkbox on one task to mark it as complete. It should turn green with strikethrough text.
  • Click Delete on a task to remove it.
  • Refresh the page. All your changes should persist because they are stored in the Supabase database.
  • Click Sign Out, then sign up with a different email address and log in.
  • Confirm that this second account sees zero tasks. Each user's data is completely separate thanks to Row Level Security.

Tasks not appearing after you add them?

Open your browser's developer console (F12 or Ctrl+Shift+I) and check for errors. A common issue is the RLS policies not matching. Make sure the SQL you ran in Step 2 completed successfully by checking the tasks table in your Supabase dashboard under Table Editor.

Help me debug this

✔️ Awesome, I've got everything!

Great. Double check you have saved app/dashboard/page.tsx and that all your CRUD operations work as expected.

ⓧ I'd like to double check the full code

Here is the complete app/dashboard/page.tsx file. Compare yours against it:

"use client";

import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";

type Task = {
  id: number;
  title: string;
  is_complete: boolean;
  created_at: string;
  user_id: string;
};

export default function DashboardPage() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [newTask, setNewTask] = useState("");
  const [userEmail, setUserEmail] = useState("");
  const [loading, setLoading] = useState(true);
  const router = useRouter();
  const supabase = createClient();

  useEffect(() => {
    async function init() {
      const {
        data: { user },
      } = await supabase.auth.getUser();

      if (!user) {
        router.push("/login");
        return;
      }

      setUserEmail(user.email || "");
      await fetchTasks();
      setLoading(false);
    }
    init();
  }, []);

  async function fetchTasks() {
    const { data } = await supabase
      .from("tasks")
      .select("*")
      .order("created_at", { ascending: false });

    if (data) setTasks(data);
  }

  async function addTask(e: React.FormEvent) {
    e.preventDefault();
    if (!newTask.trim()) return;

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

    if (!user) return;

    await supabase
      .from("tasks")
      .insert({ title: newTask.trim(), user_id: user.id });

    setNewTask("");
    await fetchTasks();
  }

  async function toggleTask(id: number, isComplete: boolean) {
    await supabase
      .from("tasks")
      .update({ is_complete: !isComplete })
      .eq("id", id);

    await fetchTasks();
  }

  async function deleteTask(id: number) {
    await supabase.from("tasks").delete().eq("id", id);
    await fetchTasks();
  }

  async function handleSignOut() {
    await supabase.auth.signOut();
    router.push("/login");
  }

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <p>Loading...</p>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <div className="max-w-2xl mx-auto px-4">
        <div className="flex justify-between items-center mb-8">
          <div>
            <h1 className="text-2xl font-bold">Task Dashboard</h1>
            <p className="text-gray-600 text-sm">{userEmail}</p>
          </div>
          <button
            onClick={handleSignOut}
            className="text-sm bg-gray-200 px-3 py-1 rounded hover:bg-gray-300 transition"
          >
            Sign Out
          </button>
        </div>

        <form onSubmit={addTask} className="flex gap-2 mb-6">
          <input
            type="text"
            value={newTask}
            onChange={(e) => setNewTask(e.target.value)}
            placeholder="Add a new task..."
            className="flex-1 rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
          />
          <button
            type="submit"
            className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition"
          >
            Add
          </button>
        </form>

        <div className="space-y-2">
          {tasks.length === 0 ? (
            <p className="text-gray-500 text-center py-8">
              No tasks yet. Add one above!
            </p>
          ) : (
            tasks.map((task) => (
              <div
                key={task.id}
                className={`flex items-center gap-3 p-3 rounded-md border ${
                  task.is_complete
                    ? "bg-green-50 border-green-200"
                    : "bg-white border-gray-200"
                }`}
              >
                <input
                  type="checkbox"
                  checked={task.is_complete}
                  onChange={() => toggleTask(task.id, task.is_complete)}
                  className="h-4 w-4"
                />
                <span
                  className={`flex-1 ${
                    task.is_complete
                      ? "line-through text-gray-400"
                      : "text-gray-800"
                  }`}
                >
                  {task.title}
                </span>
                <button
                  onClick={() => deleteTask(task.id)}
                  className="text-red-500 hover:text-red-700 text-sm"
                >
                  Delete
                </button>
              </div>
            ))
          )}
        </div>
      </div>
    </div>
  );
}

Secret mission

Add Task Priorities

Your task dashboard works, but all tasks look equally important. Real SaaS apps let users prioritize. Can you add a priority system with colored badges and a filter bar?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on the Supabase free tier and a local dev server, so there are no ongoing costs.

Resources you used:

  • Local task-dashboard project folder on your Desktop.
  • Supabase project (database, authentication, and RLS policies).
  • Supabase account.

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the project further.

  • Your local files stay on your Desktop until you manually delete them.
  • Your Supabase project is on the free tier with no charges. It will automatically pause after 1 week of inactivity, but you can restore it instantly from the dashboard.

Pause - I'll come back to this later

Shut down running processes to free up system memory. Your files and Supabase project remain intact for when you return.

  • If your dev server is still running, press Ctrl+C in the terminal to stop it.
  • Your Supabase project will automatically pause after 1 week of inactivity. No manual action needed.
  • When you come back, run npm run dev from the task-dashboard folder to restart the app.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible for your Supabase data.

  • Stop the dev server if it is still running by pressing Ctrl+C in the terminal.
  • Delete the local project folder by running this command from your Desktop:
cd ~/Desktop
rmdir /s /q task-dashboard
  • Delete the .env.local file is inside the project folder, so removing the folder handles it.
  • Delete your Supabase project: open your project in the Supabase dashboard, go to Project Settings, then click Delete project. Confirm when prompted.
  • Optionally delete your Supabase account: go to your account preferences page, scroll to the Danger zone section, and click Request to delete account.

Nice Work!

Nice Work!

Nice work! You just built a fully authenticated SaaS application with a real database, user accounts, and protected data. This is the exact architecture behind products like Notion, Trello, and Linear.

You've learned how to:

  • Build a full-stack Next.js application with TypeScript and Tailwind CSS, connected to a real PostgreSQL database powered by Supabase.
  • Implement user authentication (sign up, log in, log out) and protect each user's data with Row Level Security policies.
  • Build full CRUD operations (Create, Read, Update, Delete) for tasks that persist across sessions and remain isolated per user.
  • Secret Mission: Added a priority system with colored badges and a filtering UI to show tasks by priority level.

Ready to quiz yourself?