5+ years software engineer
5+ years software engineer
5+ years software engineer
5+ years software engineer
CRUD stands for Create, Read, Update, Delete — the four operations you perform on data. Almost every app you use is CRUD underneath: a to-do list creates and deletes tasks, a store creates orders and reads a catalog, a CMS updates posts. Learn to build one clean CRUD resource and you've learned the pattern behind 80% of application code. I'll build a task API in this article, and the same skeleton scales straight up to the order and product tables I've shipped on e-commerce projects.
This is a deep dive under my full-stack app guide. If you want the end-to-end picture — auth, deploy, the works — start there. Here I'm going slow on just the data layer.
CRUD flows through three layers, and a request travels the whole chain and back:
text1Browser (React) ──HTTP──▶ API (Express) ──SQL──▶ Database (PostgreSQL) 2 ▲ │ 3 └──────────────── JSON response ◀─────────────────────┘
The browser never touches the database directly — it always goes through the API. That's not bureaucracy; it's the layer where you enforce validation, authorization, and business rules. Anything the browser sends is untrusted, so the API is where trust gets established. The frontend and API are two separate apps talking over HTTP, which is worth reading if the client-server split is new to you.
REST gives each operation an HTTP method and a URL. For a tasks resource:
| Operation | Method | Path | Success status | Body |
|---|---|---|---|---|
| Create | POST | /api/tasks | 201 Created | The created task |
| Read all | GET | /api/tasks | 200 OK | Array of tasks |
| Read one | GET | /api/tasks/:id | 200 OK | One task, or 404 |
| Update | PATCH | /api/tasks/:id | 200 OK | The updated task |
| Delete | DELETE | /api/tasks/:id | 204 No Content | Empty |
A note on PUT vs PATCH: PUT replaces the whole resource, PATCH updates part of it. I use PATCH for edits because most UIs change one field at a time — toggling is_done shouldn't force the client to resend the title. Use PUT when the client genuinely sends the complete object.
Everything starts with the schema. Here's the tasks table:
sql1CREATE TABLE tasks ( 2 id uuid PRIMARY KEY DEFAULT gen_random_uuid(), 3 title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200), 4 is_done boolean NOT NULL DEFAULT false, 5 created_at timestamptz NOT NULL DEFAULT now() 6);
The CHECK constraint is validation at the deepest layer — even if a bug slips past the API, the database refuses an empty or absurdly long title. Defense in depth: validate in the API for good error messages, constrain in the database as the last line.
Now the four operations as Express handlers. I'll use the pg pool for queries and keep each route small.
js1import express from 'express' 2import { pool } from './db.js' 3 4const router = express.Router() 5 6// CREATE 7router.post('/tasks', async (req, res) => { 8 const { title } = req.body 9 if (typeof title !== 'string' || title.trim().length === 0) { 10 return res.status(400).json({ error: 'title is required' }) 11 } 12 const { rows } = await pool.query( 13 'INSERT INTO tasks (title) VALUES ($1) RETURNING *', 14 [title.trim()] 15 ) 16 res.status(201).json(rows[0]) 17}) 18 19// READ all 20router.get('/tasks', async (req, res) => { 21 const { rows } = await pool.query( 22 'SELECT * FROM tasks ORDER BY created_at DESC' 23 ) 24 res.json(rows) 25}) 26 27// READ one 28router.get('/tasks/:id', async (req, res) => { 29 const { rows } = await pool.query( 30 'SELECT * FROM tasks WHERE id = $1', 31 [req.params.id] 32 ) 33 if (!rows[0]) return res.status(404).json({ error: 'not found' }) 34 res.json(rows[0]) 35}) 36 37// UPDATE 38router.patch('/tasks/:id', async (req, res) => { 39 const { title, is_done } = req.body 40 const { rows } = await pool.query( 41 `UPDATE tasks 42 SET title = COALESCE($1, title), 43 is_done = COALESCE($2, is_done) 44 WHERE id = $3 45 RETURNING *`, 46 [title ?? null, is_done ?? null, req.params.id] 47 ) 48 if (!rows[0]) return res.status(404).json({ error: 'not found' }) 49 res.json(rows[0]) 50}) 51 52// DELETE 53router.delete('/tasks/:id', async (req, res) => { 54 const { rowCount } = await pool.query( 55 'DELETE FROM tasks WHERE id = $1', 56 [req.params.id] 57 ) 58 if (rowCount === 0) return res.status(404).json({ error: 'not found' }) 59 res.status(204).end() 60}) 61 62export default router
Three patterns worth pulling out. Every query uses parameterized placeholders ($1, $2) instead of string concatenation — this is what stops SQL injection, full stop. The COALESCE trick in the update lets PATCH change only the fields the client actually sent, leaving the rest untouched. And RETURNING * hands back the saved row in one round trip, so the client sees the real stored state including server-set fields like created_at.
Status codes are how the API tells the client what happened without the client having to parse prose. The ones that matter for CRUD:
Getting these right isn't pedantry — clients, caches, and monitoring tools all key off status codes. A create that returns 200 instead of 201 will confuse every tool downstream.
On the frontend, a component fetches the list and posts new tasks. Here's a working slice:
jsx1import { useEffect, useState } from 'react' 2 3export function Tasks() { 4 const [tasks, setTasks] = useState([]) 5 const [title, setTitle] = useState('') 6 7 async function load() { 8 const res = await fetch('/api/tasks') 9 setTasks(await res.json()) 10 } 11 12 useEffect(() => { load() }, []) 13 14 async function create(e) { 15 e.preventDefault() 16 const res = await fetch('/api/tasks', { 17 method: 'POST', 18 headers: { 'Content-Type': 'application/json' }, 19 body: JSON.stringify({ title }), 20 }) 21 if (res.ok) { 22 const task = await res.json() 23 setTasks((prev) => [task, ...prev]) 24 setTitle('') 25 } 26 } 27 28 async function toggle(id, is_done) { 29 await fetch(`/api/tasks/${id}`, { 30 method: 'PATCH', 31 headers: { 'Content-Type': 'application/json' }, 32 body: JSON.stringify({ is_done: !is_done }), 33 }) 34 load() 35 } 36 37 async function remove(id) { 38 await fetch(`/api/tasks/${id}`, { method: 'DELETE' }) 39 setTasks((prev) => prev.filter((t) => t.id !== id)) 40 } 41 42 return ( 43 <div> 44 <form onSubmit={create}> 45 <input value={title} onChange={(e) => setTitle(e.target.value)} /> 46 <button>Add</button> 47 </form> 48 <ul> 49 {tasks.map((t) => ( 50 <li key={t.id}> 51 <input 52 type="checkbox" 53 checked={t.is_done} 54 onChange={() => toggle(t.id, t.is_done)} 55 /> 56 {t.title} 57 <button onClick={() => remove(t.id)}>×</button> 58 </li> 59 ))} 60 </ul> 61 </div> 62 ) 63}
Notice the two update styles. After create and delete I update local state directly (setTasks) for a snappy UI; after toggle I just re-load() for simplicity. On a bigger app I'd reach for a data library like TanStack Query to handle caching and refetching, but plain fetch is enough to understand the mechanics. The Content-Type: application/json header is required — without it, express.json() won't parse the body and req.body comes through empty.
Don't trust CRUD until you've exercised all four verbs. curl is the fastest way:
bash1# Create 2curl -X POST localhost:4000/api/tasks \ 3 -H 'Content-Type: application/json' \ 4 -d '{"title":"Write the CRUD article"}' 5 6# Read all 7curl localhost:4000/api/tasks 8 9# Read one 10curl localhost:4000/api/tasks/<id> 11 12# Update 13curl -X PATCH localhost:4000/api/tasks/<id> \ 14 -H 'Content-Type: application/json' \ 15 -d '{"is_done":true}' 16 17# Delete 18curl -X DELETE localhost:4000/api/tasks/<id>
Then test the failure paths, which is where bugs hide: POST with no title should return 400, GET a random UUID should return 404, DELETE the same id twice should return 204 then 404. If those behave, your CRUD resource is solid.
The pattern is stack-agnostic. Swap components freely:
| Layer | This article | Also common |
|---|---|---|
| Frontend | React + fetch | Vue, Svelte, Next.js, React Native |
| API | Node + Express | Fastify, NestJS, Django, Rails, Go |
| Database | PostgreSQL + pg | MongoDB, MySQL, SQLite, Prisma ORM |
If you're on MongoDB, INSERT ... RETURNING becomes insertOne, and the SQL table becomes a collection — but the four operations, the REST endpoints, and the status codes are identical. That's the beauty of CRUD: it's a shape, not a technology.
You now have a working CRUD resource, but it's wide open — anyone can read and delete anyone's tasks. The next two steps are wiring it cleanly to your frontend and locking it down. Read how to connect a React front end to a Node.js back end for the client-server plumbing, then come back up to the full-stack app guide to add authentication and security on top of this foundation.
The exact path I follow to ship a full-stack app end to end — data model, Node/Express API, database, React frontend, auth, security, and deploy — with links to the deep dives for each step.
Authentication has four parts: verifying identity, managing sessions, authorizing actions, and expiring access safely. I cover password hashing, sessions vs JWT, HttpOnly cookies, OAuth, passkeys, and the architecture I actually recommend.
A React app and a Node.js API are two separate programs talking over HTTP. I show the Express server, the React fetch, why CORS exists, how to set up a dev proxy, project structure, and how to run and deploy both.