CRUD is the skeleton of most software. Get comfortable building one and a surprising share of app ideas become achievable.
It’s also where you meet the authorization problem for the first time, which is the real lesson of this project.
What You’re Building
A project tracker. Users sign in, create projects, edit them, mark them done, delete them. Each user sees only their own.
That last clause is the entire security surface, and it’s the part AI skips.
Model the Data First
Before generating anything, decide your fields. This takes two minutes and prevents a lot of churn.
projects
id uuid, primary key
user_id uuid, references auth.users ← the important one
title text, required
description text
status text: 'active' | 'done' | 'archived'
due_date date, nullable
created_at timestamptz
updated_at timestamptz
user_id is what makes ownership possible. If it’s not on the table from the start, every query below is unenforceable.
Build in Four Steps
Don’t ask for the whole app at once. You’ll get something sprawling that neither of you fully understands.
Step 1 — Schema and auth
Next.js App Router, TypeScript, Tailwind, Supabase. Create a projects table with: id uuid pk, user_id uuid referencing auth.users, title text not null, description text, status text defaulting to ‘active’, due_date date nullable, created_at and updated_at timestamptz.
Enable row-level security. Write policies so users can only select, insert, update and delete rows where user_id = auth.uid(). Give me the SQL.
Add Supabase Auth with email/password. Protect /projects server-side.
Run the SQL. Confirm the RLS badge appears on the table in the Supabase dashboard. Commit.
Step 2 — Read
Build /projects listing the signed-in user’s projects in a table: title, status, due date, created date. Sort by created_at descending. Include an empty state. Fetch server-side, filtering by the authenticated user’s ID.
Check it works. Commit.
Step 3 — Create and update
Add project creation via a modal form with title, description, status and due date. Validate server-side with Zod: title required, max 200 characters; description max 2000; status must be one of the three values.
Add editing via the same form pre-filled. The update query must filter by both id and the authenticated user’s ID.
That final sentence is the one that matters. Ask for it explicitly every time.
Commit.
Step 4 — Delete
Add delete with a confirmation dialog. The delete query must filter by both id and authenticated user ID. Show an undo toast for 5 seconds before committing, or use a soft delete with a deleted_at column.
Soft deletes are worth it here — a confirmation dialog is not much protection against a misclick.
Commit.
The Bug You Now Go Looking For
Read your update and delete handlers. You’re looking for this shape:
// WRONG — acts on any record by ID
await supabase.from('projects').delete().eq('id', id)
Versus this:
// RIGHT — acts only on the user's own record
await supabase.from('projects')
.delete()
.eq('id', id)
.eq('user_id', user.id)
Read operations usually get filtered correctly, because a user seeing someone else’s data is visible in the UI. Update and delete often don’t, because nothing looks wrong — the wrong record just silently changes.
RLS catches this if you enabled it. Both layers is correct; relying only on RLS means one misconfiguration removes all protection.
Test It
Two accounts, five minutes:
- Account A creates a project. Note the ID.
- Account B, separate browser.
- As B, open
/projects/[A's id]/edit— should 404 or redirect. - As B, submit an update to A’s ID via the API directly — should fail.
- As B, submit a delete for A’s ID — should fail.
Step 5 is the one people skip and the one that bites.
What to Add Next
Once the core works, these are the natural extensions, in the order they usually become necessary:
Search and filter. Ask for server-side filtering, not client-side — filtering after fetching everything means you fetched everything.
Pagination. Before you have a thousand rows, not after.
Add cursor-based pagination, 25 per page. Use range queries, not offset.
Optimistic updates. Makes the app feel instant. Ask for rollback on failure or you’ll show success for things that failed.
Bulk actions. Every bulk endpoint needs the same ownership filter as the single-record one — and it’s skipped even more often.
The Checklist
-
user_idon the table, referencing auth.users - RLS enabled with policies for all four operations
- Every read filters by authenticated user
- Every update filters by id AND user_id
- Every delete filters by id AND user_id
- Server-side validation on all inputs, with max lengths
- Two-account test passed for read, update and delete
- Empty states and error states exist
- Pagination before the list gets long
Why This Project Matters
Almost everything else is CRUD with extra steps. A booking app is CRUD with timezones. An invoicing tool is CRUD with PDFs. An admin panel is CRUD with roles.
Build one carefully and you’ve learned the pattern that underlies most of the rest — including the ownership check that every one of them needs.
Next: Vibe code a SaaS dashboard puts a real interface on this data, or vibe code a REST API exposes it to other applications.
Related reading: