Build a Todo App with Next.js 16 & Supabase
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
- Click New Project
- Enter project name
- Enter database password
- Select region
- Enable automatic RLS
- 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:
- Get form values using
formData - Validate using Zod
- Call Supabase Auth API
- 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:
- Get User
- Create Public Routes
- First
ifblock checks if the user is logged in & trying to access public routes then navigates user to Home - Second
ifblock 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:
- Get form values using
formData - Validate using Zod
- Get logged in user
- Call Supabase Auth API
- 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.

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.
Continue Learning
- Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR & Cache Components
- Image Optimization in Next.js 16: The Complete Beginner's Guide
- Error Handling in Next.js 16: The Complete Beginner's Guide
- Supabase Auth vs Firebase Auth: Which One Should You Choose?
Useful Resources
📦 Source Code: View on GitHub
More in Backend
View AllZod vs Yup in 2026: Which Validation Library Should You Use?
Zod vs Yup compared for 2026 — TypeScript inference, bundle size, React Hook Form integration, async validation, and real Next.js code examples.
How to Display Images from S3 in Next.js 16 (2026 Guide)
Learn 3 ways to display S3-uploaded images in Next.js 16: public URLs, presigned GET links for private files, and CloudFront CDN delivery.
Next.js 16 AWS S3 File Upload Guide: Presigned URLs, Proxy & Multipart
Learn AWS S3 file uploads in Next.js 16 using server proxy, presigned URLs, and multipart uploads with TypeScript, AWS SDK v3, and production best practices.
Integrate Sanity CMS with Next.js 16 App Router
Integrate Sanity CMS with the Next.js 16 App Router. A complete 2026 guide covering embedded Sanity Studio, GROQ queries, SanityLive, Portable Text, & async params.