Amine Elbarry

Amine

5+ years software engineer

~/AI_Chat~/projects~/experience~/blogs~/hire-me~/services
Amine Elbarry

Amine

5+ years software engineer

~/AI_Chat~/projects~/experience~/blogs~/hire-me~/services

Blog Posts

Amine Elbarry

Amine

5+ years software engineer

~/AI_Chat~/projects~/experience~/blogs~/hire-me~/services
Amine Elbarry

Amine

5+ years software engineer

~/AI_Chat~/projects~/experience~/blogs~/hire-me~/services
Back to all blogs

How to Build a CRUD Application

Jun 8, 2026•10 min read

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.

The architecture

CRUD flows through three layers, and a request travels the whole chain and back:

text
1Browser (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.

Map CRUD to REST endpoints

REST gives each operation an HTTP method and a URL. For a tasks resource:

OperationMethodPathSuccess statusBody
CreatePOST/api/tasks201 CreatedThe created task
Read allGET/api/tasks200 OKArray of tasks
Read oneGET/api/tasks/:id200 OKOne task, or 404
UpdatePATCH/api/tasks/:id200 OKThe updated task
DeleteDELETE/api/tasks/:id204 No ContentEmpty

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.

Create the database table

Everything starts with the schema. Here's the tasks table:

sql
1CREATE 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.

Build the Express routes

Now the four operations as Express handlers. I'll use the pg pool for queries and keep each route small.

js
1import 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.

Validation and status codes

Status codes are how the API tells the client what happened without the client having to parse prose. The ones that matter for CRUD:

  • 200 — read/update succeeded, body attached
  • 201 — created, return the new resource
  • 204 — deleted, nothing to return
  • 400 — the client sent bad input (missing title, wrong type)
  • 404 — the resource doesn't exist
  • 500 — the server broke (never leak the stack trace to the client)

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.

The React component

On the frontend, a component fetches the list and posts new tasks. Here's a working slice:

jsx
1import { 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.

Test each operation

Don't trust CRUD until you've exercised all four verbs. curl is the fastest way:

bash
1# 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.

Example stacks

The pattern is stack-agnostic. Swap components freely:

LayerThis articleAlso common
FrontendReact + fetchVue, Svelte, Next.js, React Native
APINode + ExpressFastify, NestJS, Django, Rails, Go
DatabasePostgreSQL + pgMongoDB, 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.

Where to go next

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.

Related Posts

How to Build a Full-Stack App: A Step-by-Step Guide

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.

How to Authenticate Users in a Web Application

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.

How to Connect a React Front End to a Node.js Back End

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.