DevStacked
LinkedIn Logo'GitHub Logo'Upwork Logo'
May 23, 202615 min read

Build a Todo App with Next.js 16 and Supabase (2026 Guide)

Learn how to build a production-ready Todo App using Next.js 16, Supabase, TypeScript, Tailwind CSS, Server Actions, Authentication, and Row Level Security (RLS).

In this guide, we’ll build a production-ready Todo App using:

  • Next.js 16
  • Supabase
  • TypeScript
  • Tailwind CSS
  • Server Actions
  • Authentication
  • Row Level Security (RLS)

This tutorial focuses more on logic and architecture rather than UI design.

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

  • Authentication with Supabase
  • Protected routes
  • Todo CRUD operations
  • Row Level Security (RLS)
  • Form validation with Zod
  • Server Actions
  • Secure session handling

What We’re Building

Our Todo App will include:

  • User Registration
  • User Login
  • Protected Dashboard
  • Create Todo
  • Complete Todo
  • Delete Todo
  • Secure Database Access with RLS

Prerequisites

Before starting, make sure you have:

  • Node.js installed
  • A Supabase account
  • Basic understanding of React and Next.js

Folder Structure

proxy.ts
src
├── actions
   ├── auth.ts
   └── todo.ts
├── app
   ├── layout.tsx
   ├── (auth)
      ├── layout.tsx
      ├── login
         └── page.tsx
      ├── register
         └── page.tsx
   ├── (private)
   |   ├── layout.tsx
   |   ├── page.tsx
   |   ├── create
         └── page.tsx
├── components
   ├── form-error.tsx
   ├── input.tsx
   ├── loading-button.tsx
   ├── navbar.tsx
   └── todo-actions.tsx
├── utils
   ├── supabase
   |   ├── client.ts
   |   ├── proxy.ts
   |   └── server.ts
   ├── validators
   |   ├── auth.ts
      └── todo.ts

Create a Next.js 16 Project

Run the following command:

npx create-next-app@latest todo-app

Choose options according to your preferences.

Recommended setup:

  • TypeScript
  • Tailwind CSS
  • App Router
  • React Compiler
  • src/ directory

Now move into the project:

cd todo-app

Setup Supabase

Go to Supabase Dashboard:

Create a New Project

  1. Click New Project
  2. Enter project name
  3. Enter database password
  4. Select region
  5. Enable automatic RLS
  6. Click Create Project

Add Environment Variables

Inside your project Dashboard:

  • Under Get Connected section
  • Click Frameworks

Copy the environment variables.

Create .env.local in your project root:

NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=

Install Supabase Packages

Run:

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

We’ll also install Zod later for validation.


Understanding Supabase Clients

Supabase provides two client types:

Browser Client

Used inside Client Components.

Server Client

Used inside:

  • Server Components
  • Server Actions
  • Route Handlers

Create Supabase Browser Client

Create:

/src/utils/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

export const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );

Create Supabase Server Client

Create:

/src/utils/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },

        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Ignore if called from Server Component
          }
        },
      },
    }
  );
}

Setup Supabase Session Middleware

Create:

/src/utils/supabase/proxy.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },

        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );

          supabaseResponse = NextResponse.next({
            request,
          });

          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );

          Object.entries(headers).forEach(([key, value]) =>
            supabaseResponse.headers.set(key, value)
          );
        },
      },
    }
  );

  await supabase.auth.getClaims();

  return supabaseResponse;
}

Setup Next.js Middleware

Create:

/src/proxy.ts
import { type NextRequest } from "next/server";
import { updateSession } from "@/utils/supabase/proxy";

export async function proxy(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
    */
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

Create Route Groups

We’ll use route groups for better organization.

app
 ├── (auth)
 └── (private)

Route groups help organize routes without affecting URLs.
Learn more about Nextjs Route Groups.


Create Auth Layout

Create:

/app/(auth)/layout.tsx
export default function AuthLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <div className="bg-gray-900 flex items-center justify-center flex-1">
      {children}
    </div>
  );
}

Create Login Page

Create:

/app/(auth)/login/page.tsx
"use client";

import { login } from "@/actions/auth";
import FormError from "@/components/form-error";
import Input from "@/components/input";
import LoadingButton from "@/components/loading-button";
import Image from "next/image";
import Link from "next/link";
import { useActionState } from "react";

export default function Login() {
  const [state, formAction] = useActionState(login, {
    errors: {
      email: [],
      password: [],
      formError: "",
    },
    data: {
      email: "",
      password: "",
    },
  });

  return (
    <div className="flex flex-col justify-center w-full">
      <div className="sm:mx-auto sm:w-full sm:max-w-sm">
        <Image
          src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
          alt="Logo"
          className="mx-auto"
          width={50}
          height={50}
        />

        <h2 className="mt-10 text-center text-2xl/9 font-bold tracking-tight text-white">
          Sign in to your account
        </h2>
      </div>

      <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
        <form action={formAction} className="space-y-6">
          <Input
            defaultValue={state?.data?.email || ""}
            name="email"
            type="email"
            label="Email address"
          />

          <FormError errors={state.errors.email} />

          <Input
            defaultValue={state?.data?.password || ""}
            name="password"
            type="password"
            label="Password"
          />

          <FormError errors={state.errors.password} />

          <LoadingButton title="Sign in" />

          <FormError errors={state?.errors?.formError} />
        </form>

        <p className="mt-10 text-center text-sm/6 text-gray-400">
          Not a member?{" "}
          <Link
            href="/register"
            className="font-semibold text-indigo-400 hover:text-indigo-300"
          >
            Register
          </Link>
        </p>
      </div>
    </div>
  );
}

Important Note About Input Names

The name attribute is extremely important because we later access form values using:

formData.get("email")

If names don’t match, your form won’t work correctly.


Create Reusable Input Component

Create:

/src/components/input.tsx
interface InputProps {
  name: string;
  label?: string;
  type?: string;
  defaultValue?: string;
  required?: boolean;
}

const Input = ({
  name,
  label,
  type = "text",
  defaultValue,
  required = true,
}: InputProps) => {
  return (
    <div>
      {label && (
        <label
          htmlFor={name}
          className="block text-sm/6 font-medium text-gray-100"
        >
          {label}
        </label>
      )}

      <div className="mt-2">
        <input
          id={name}
          type={type}
          name={name}
          defaultValue={defaultValue}
          required={required}
          autoComplete={type === "email" ? "email" : undefined}
          className="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 outline-white/10"
        />
      </div>
    </div>
  );
};

export default Input;

Create Loading Button

Create:

/src/components/loading-button.tsx
"use client";

import { useFormStatus } from "react-dom";

const LoadingButton = ({
  title,
  className = "",
}: {
  title: string;
  className?: string;
}) => {
  const { pending } = useFormStatus();

  return (
    <button
      disabled={pending}
      type="submit"
      className={`cursor-pointer flex w-full justify-center rounded-md bg-indigo-500 px-3 py-1.5 text-white ${className}`}
    >
      {pending ? "Loading..." : title}
    </button>
  );
};

export default LoadingButton;

What is useFormStatus?

useFormStatus() gives information about the latest form submission state.

We use it to:

  • Disable button while submitting
  • Show loading state
  • Prevent duplicate submissions

Create Form Error Component

Create:

/src/components/form-error.tsx
export default function FormError({
  errors,
}: {
  errors?: string | string[];
}) {
  if (!errors || (Array.isArray(errors) && errors.length === 0)) {
    return null;
  }

  return (
    <p aria-live="polite" className="text-sm text-red-500">
      {typeof errors === "string" ? errors : errors.join(", ")}
    </p>
  );
}

Install Zod

Run:

npm install zod

Create Auth Validation Schema

Create:

/src/utils/validators/auth.ts
import z from "zod";

export const authSchema = z.object({
  email: z.email(),
  password: z
    .string()
    .min(6, "Password must be at least 6 characters long")
    .max(100, "Password must not exceed 100 characters"),
});

Create Login Action

Create:

/src/actions/auth.ts
"use server";

import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { authSchema } from "@/utils/validators/auth";

export async function login(prev: unknown, formData: FormData) {
  const data = {
    email: formData.get("email") as string,
    password: formData.get("password") as string,
  };

  const validatedFields = authSchema.safeParse(data);

  if (!validatedFields.success) {
    return {
      data,
      errors: {
        ...validatedFields.error.flatten().fieldErrors,
        formError: "",
      },
    };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword(
    validatedFields.data
  );

  if (error) {
    return {
      data,
      errors: {
        formError: error.message,
        email: [],
        password: [],
      },
    };
  }

  redirect("/");
}

Understanding the Login Action

Here’s what happens:

  1. Get form values using formData
  2. Validate using Zod
  3. Call Supabase Auth API
  4. Redirect user after successful login

Because this is a Server Action, we must use the server Supabase client.


Create Register Page

Create:

/app/(auth)/register/page.tsx

Use the same structure as login page but replace:

import { signup } from "@/actions/auth";

and:

<LoadingButton title="Sign up" />

Create Signup Action

Add this inside:

/src/actions/auth.ts
export async function signup(prev: unknown, formData: FormData) {
  const data = {
    email: formData.get("email") as string,
    password: formData.get("password") as string,
  };

  const validatedFields = authSchema.safeParse(data);

  if (!validatedFields.success) {
    return {
      data,
      errors: {
        ...validatedFields.error.flatten().fieldErrors,
        formError: "",
      },
    };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.signUp(validatedFields.data);

  if (error) {
    return {
      data,
      errors: {
        formError: error.message,
        email: [],
        password: [],
      },
    };
  }

  redirect("/");
}

Supabase may require email confirmation depending on project settings after signup.


Create Private Layout

Create:

/app/(private)/layout.tsx
import Navbar from "@/components/navbar";

export default function PrivateLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <>
      <Navbar />

      <div className="mt-16">{children}</div>
    </>
  );
}

Create Navbar

Create:

/src/components/navbar.tsx
import { logout } from "@/actions/auth";
import { createClient } from "@/utils/supabase/server";
import LoadingButton from "./loading-button";
import Link from "next/link";

const Navbar = async () => {
  const supabase = await createClient();

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

  return (
    <div className="w-full flex items-center gap-4 p-4 bg-gray-800 justify-between">
      <div className="text-lg font-bold">
        Hi {user?.email}
      </div>

      <div className="flex items-center gap-4">
        <nav className="flex items-center gap-4">
          <Link href="/">Home</Link>
          <Link href="/create">Create Todo</Link>
        </nav>

        <form action={logout}>
          <LoadingButton title="Logout" />
        </form>
      </div>
    </div>
  );
};

export default Navbar;

Create Logout Action

Add this to:

/src/actions/auth.ts
export async function logout() {
  const supabase = await createClient();

  const { data: claimsData } = await supabase.auth.getClaims();

  if (claimsData?.claims) {
    await supabase.auth.signOut();
  }

  redirect("/login");
}

Protect Private Routes

Right now, users can manually access protected pages.

We need route protection.

Update:

/src/utils/supabase/proxy.ts

Replace this

await supabase.auth.getClaims()

With

const { data } = await supabase.auth.getClaims();

const user = data?.claims;
const publicRoutes = ["/login", "/register"];

if (user && publicRoutes.includes(request.nextUrl.pathname)) {
  const url = request.nextUrl.clone();
  url.pathname = "/";
  return NextResponse.redirect(url);
}

if (!user && !publicRoutes.includes(request.nextUrl.pathname)) {
  const url = request.nextUrl.clone();
  url.pathname = "/login";
  return NextResponse.redirect(url);
}

Understanding Logic

Here’s what happens:

  1. Get User
  2. Create Public Routes
  3. First if block checks if the user is logged in & trying to access public routes then navigates user to Home
  4. Second if block checks if the user is not logged in & trying to access private routes then navigates user to Login

How Route Protection Works

Public Routes

Anyone can access:

  • /login
  • /register

Private Routes

Only authenticated users can access them.

Additional Protection

Logged-in users cannot revisit auth pages.


Create Todos Table

Open:

  • Supabase Project Dashboard
  • From sidebar click SQL Editor

Run:

create table todos (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  title text not null,
  completed boolean default false,
  created_at timestamptz default now()
);

Add Row Level Security Policies

Run:

create policy "Users can view own todos"
on todos
for select
using (auth.uid() = user_id);

create policy "Users can insert own todos"
on todos
for insert
with check (auth.uid() = user_id);

create policy "Users can update own todos"
on todos
for update
using (auth.uid() = user_id);

create policy "Users can delete own todos"
on todos
for delete
using (auth.uid() = user_id);

Why RLS is Important

Without RLS:

  • Any user could access all data.

With RLS:

  • Users can only access their own data.

This is one of the most important security features in Supabase.


Create Todo Validation Schema

Create:

/src/utils/validators/todo.ts
import z from "zod";

export const todoSchema = z.object({
  title: z
    .string()
    .min(2, "Title must be at least 2 characters long")
    .max(100, "Title must not exceed 100 characters"),
});

Create Todo Form

Create:

/app/(private)/create/page.tsx
"use client";

import { createTodo } from "@/actions/todo";
import FormError from "@/components/form-error";
import Input from "@/components/input";
import LoadingButton from "@/components/loading-button";
import { useActionState } from "react";

const CreateTodo = () => {
  const [state, formAction] = useActionState(createTodo, {
    error: "",
    data: {
      title: "",
    },
  });

  return (
    <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
      <form action={formAction} className="space-y-6">
        <Input
          defaultValue={state?.data?.title || ""}
          name="title"
          label="Todo Title"
        />

        <FormError errors={state?.error} />

        <LoadingButton title="Create Todo" />
      </form>
    </div>
  );
};

export default CreateTodo;

Create Todo Action

Create:

/src/actions/todo.ts
"use server";

import { todoSchema } from "@/utils/validators/todo";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";

export async function createTodo(
  prev: unknown,
  formData: FormData
) {
  const supabase = await createClient();

  const data = {
    title: formData.get("title") as string,
  };

  const validatedFields = todoSchema.safeParse(data);

  if (!validatedFields.success) {
    return {
      data,
      error:
        validatedFields.error.flatten().fieldErrors.title?.[0],
    };
  }

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

  if (!user) {
    return {
      data,
      error: "Please login first",
    };
  }

  const { error } = await supabase.from("todos").insert({
    title: validatedFields.data.title,
    user_id: user.id,
  });

  if (error) {
    return {
      data,
      error: error.message,
    };
  }

  redirect("/");
}

Understanding the Create Todo Action

Here’s what happens:

  1. Get form values using formData
  2. Validate using Zod
  3. Get logged in user
  4. Call Supabase Auth API
  5. Redirect user after successful insert

Create Home Page

Move:

/app/page.tsx

to:

/app/(private)/page.tsx

Now update it:

import TodoActions from "@/components/todo-actions";
import { createClient } from "@/utils/supabase/server";

export default async function Home() {
  const supabase = await createClient();

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

  return (
    <div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm max-w-xl mx-auto">
      <table className="divide-y divide-gray-200 w-full">
        <thead className="bg-gray-50">
          <tr>
            {['Todo', 'Status', 'Actions'].map((header) => (
              <th
                key={header}
                className="px-6 py-4 text-left text-sm font-semibold text-gray-700">
                {header}
              </th>
            ))}
          </tr>
        </thead>

        <tbody className="divide-y divide-gray-100 bg-white">
          {data?.map((todo) => (
            <tr
              key={todo.id}
              className="transition-colors hover:bg-gray-50"
            >
              <td className="px-6 py-4">
                <p className="font-medium text-gray-900">
                  {todo.title}
                </p>
              </td>

              <td className="px-6 py-4">
                <span
                  className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${todo.completed
                    ? "bg-green-100 text-green-700"
                    : "bg-yellow-100 text-yellow-700"
                    }`}
                >
                  {todo.completed ? "Completed" : "Pending"}
                </span>
              </td>

              <td className="px-6 py-4">
                <TodoActions todoId={todo.id} isCompleted={todo.completed} />
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {data?.length === 0 && (
        <div className="py-10 text-center text-sm text-gray-500">
          No todos found
        </div>
      )}
    </div>
  );
}

We’re fetching todos in descending order so that the latest todo comes at top.

Todo App Home Page


Create Todo Actions Component

Create:

/src/components/todo-actions.tsx
'use client';

import { handleTodoAction } from "@/actions/todo";
import { useActionState } from "react";

const TodoActions = ({ todoId, isCompleted }: { todoId: string; isCompleted: boolean; }) => {
    const [_, action, isPending] = useActionState(handleTodoAction, undefined);

    const completeTodo = () => action({ todoId, action: "complete" });
    const deleteTodo = () => action({ todoId, action: "delete" });

    return (
        <form className="flex justify-end gap-3">
            {!isCompleted &&
                <button
                    formAction={completeTodo}
                    disabled={isPending}
                    className="rounded-lg disabled:opacity-50 bg-blue-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-600"
                >
                    Complete
                </button>
            }
            <button
                formAction={deleteTodo}
                disabled={isPending}
                className="rounded-lg disabled:opacity-50 bg-red-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-600"
            >
                Delete
            </button>
        </form>
    )
}

export default TodoActions

Complete and Delete Todo Action

Add inside:

/src/actions/todo.ts
import { revalidatePath } from "next/cache";

export async function handleTodoAction(prev: unknown, data: { todoId: string; action: "delete" | "complete" }) {

    const { todoId, action } = data;

    if (!todoId) return;

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

    if (!user) return;

    if (action === 'delete') {
        await supabase
            .from("todos")
            .delete()
            .eq("id", todoId)
            .eq("user_id", user.id);
    } else {
        await supabase
            .from("todos")
            .update({
                completed: true,
            })
            .eq("id", todoId)
            .eq("user_id", user.id);
    }
    revalidatePath('/');
}

Final Result

You now have a fully functional Todo App with:

  • Next.js 16
  • Supabase Authentication
  • Protected Routes
  • Server Actions
  • Zod Validation
  • Secure RLS Policies
  • CRUD Operations

Conclusion

This project demonstrates a modern full-stack architecture using:

  • Next.js 16
  • Server Actions
  • Supabase
  • RLS
  • Authentication

Even though it’s a simple Todo App, the structure is production-ready and scalable.

You can now build larger applications using the same architecture and patterns.


Useful Resources

Source Code: https://github.com/Muhammad-Ali-sma/Todo-App


Next.jsSupabaseTypeScriptTailwind CSSAuthenticationFull StackTodo AppRLS