NodeJSCRUD Operations

CRUD Operations

CRUD — Create, Read, Update, Delete — is the backbone of almost every API. With the endpoint design settled, this page implements the full cycle for one resource, paying attention to the details that separate a toy example from a correct API: the right status codes, PUT vs PATCH semantics, returning the right body, and handling "not found" consistently.

The complete CRUD-to-HTTP map

Operation

Method + path

Success status

Body returned

Create

POST /tasks

201 Created

The new resource

Read (list)

GET /tasks

200 OK

Array of resources

Read (one)

GET /tasks/:id

200 OK

The resource

Replace

PUT /tasks/:id

200 OK

The updated resource

Update

PATCH /tasks/:id

200 OK

The updated resource

Delete

DELETE /tasks/:id

204 No Content

(empty)

Setup

tasks.js — a CRUD router

JS
import { Router } from 'express'
const router = Router()

// Stand-in data store (swap for a database later):
let tasks = [{ id: 1, title: 'Learn REST', done: false }]
let nextId = 2

const findTask = (id) => tasks.find((t) => t.id === Number(id))

export default router
Create — POST

JS
router.post('/', (req, res) => {
  const { title } = req.body
  if (typeof title !== 'string' || !title.trim()) {
    return res.status(422).json({ error: 'title is required' })
  }

  const task = { id: nextId++, title: title.trim(), done: false }
  tasks.push(task)

  res
    .status(201)                                   // 201 Created
    .location(`/tasks/${task.id}`)                 // where to find it
    .json(task)                                    // echo the created resource
})
`201 Created` + a `Location` header + the new resource
A successful create returns **201**, not 200. Include a `Location` header pointing to the new resource's URL, and return the created object (now bearing its server-assigned `id`) so the client doesn't need a follow-up `GET`. Validate before inserting — `req.body` is [untrusted](/nodejs/request-validation).
Read — GET (list and one)

JS
// List — return an array (or a paginated envelope):
router.get('/', (req, res) => {
  res.json(tasks)
})

// One — 404 if it doesn't exist:
router.get('/:id', (req, res) => {
  const task = findTask(req.params.id)
  if (!task) return res.status(404).json({ error: 'Task not found' })
  res.json(task)
})
An empty collection is `200 []`, not `404`
`GET /tasks` when there are no tasks returns `200` with an empty array `[]` — the collection *exists*, it's just empty. Reserve `404` for a *specific* resource that doesn't exist (`GET /tasks/999`). Conflating "empty list" with "not found" breaks clients that iterate the result.
PUT vs PATCH — the crucial distinction

JS
// PUT = full replacement. Missing fields are RESET, not preserved.
router.put('/:id', (req, res) => {
  const task = findTask(req.params.id)
  if (!task) return res.status(404).json({ error: 'Task not found' })

  const { title, done } = req.body
  if (typeof title !== 'string') {
    return res.status(422).json({ error: 'title is required for PUT' })
  }
  task.title = title
  task.done = Boolean(done)        // omitted → reset to false (full replace)
  res.json(task)
})

// PATCH = partial update. Only provided fields change.
router.patch('/:id', (req, res) => {
  const task = findTask(req.params.id)
  if (!task) return res.status(404).json({ error: 'Task not found' })

  if (req.body.title !== undefined) task.title = req.body.title
  if (req.body.done  !== undefined) task.done  = Boolean(req.body.done)
  res.json(task)
})
`PUT` replaces the whole resource — omitted fields are cleared
The semantic difference matters: `PUT` means "make the resource equal to this representation" — fields you leave out are reset to defaults/removed. `PATCH` means "apply these changes" — untouched fields stay. A frequent bug is implementing `PUT` as a partial merge (so it behaves like `PATCH`); clients relying on `PUT` to clear fields then break. Pick the verb that matches the behavior you actually implement.
Delete — DELETE

JS
router.delete('/:id', (req, res) => {
  const before = tasks.length
  tasks = tasks.filter((t) => t.id !== Number(req.params.id))

  if (tasks.length === before) {
    return res.status(404).json({ error: 'Task not found' })
  }
  res.status(204).end()                  // 204 No Content — empty body
})
`204 No Content` and `.end()` — no body allowed
A delete has nothing to return, so respond `204` and call `.end()` (not `.json()`) — a [204 must carry no body](/nodejs/http-status-codes). Returning the deleted object with `200` is also acceptable; just be consistent. Note deleting an already-absent resource here returns `404`, though some APIs return `204` to keep DELETE fully idempotent — both are defensible.
Async with a real database

JS
// Same shapes, now awaiting the data layer (Express 5 auto-catches rejections):
router.get('/:id', async (req, res) => {
  const task = await db.tasks.findById(req.params.id)
  if (!task) return res.status(404).json({ error: 'Task not found' })
  res.json(task)
})

router.post('/', async (req, res) => {
  const task = await db.tasks.create({ title: req.body.title })
  res.status(201).location(`/tasks/${task.id}`).json(task)
})
The HTTP shape is identical — only the data access changes
Swapping the in-memory array for a database doesn't alter the status codes, methods, or response bodies — only the lookups become `await`ed calls. Make handlers `async`; in [Express 5](/nodejs/error-middleware) a rejected query is forwarded to your error handler automatically. Keep validation and status-code logic exactly as designed.
CRUD correctness checklist
  • Create → 201 + Location + the new resource.

  • List → 200 + array ([] when empty, never 404).

  • Fetch/update/delete a missing id → 404, consistently.

  • PUT fully replaces; PATCH merges — implement what the verb promises.

  • Validate req.body before writing; respond 422 on bad input.

  • Delete → 204 with no body (or 200 + the object — pick one).

Next
Validate incoming data rigorously before it touches your store: [Request Validation](/nodejs/request-validation).