Your First Express App
Time to build something real: a small JSON API for a list of tasks, with routes to list, fetch, create, and delete. Along the way you'll meet the response helpers, body parsing, and the request lifecycle — the everyday tools of Express development. This is the raw-http work from the HTTP section, now expressed cleanly.
The skeleton
server.js
JS
import express from 'express'
const app = express()
// Parse JSON request bodies into req.body (must come before routes):
app.use(express.json())
app.get('/', (req, res) => {
res.send('Task API')
})
app.listen(3000, () => console.log('http://localhost:3000'))Register `express.json()` BEFORE routes that read `req.body`
`express.json()` is the body-parsing middleware that reads the request stream and populates `req.body`. Because [middleware runs top-to-bottom](/nodejs/express-intro), it must be registered *before* any route handler that uses `req.body` — otherwise `req.body` is `undefined` in those handlers. This ordering trap catches nearly every Express beginner.
In-memory data (for now)
JS
let tasks = [
{ id: 1, title: 'Learn Express', done: false },
{ id: 2, title: 'Build an API', done: false },
]
let nextId = 3A real app would use a database
We use an in-memory array to focus on Express itself — restart the server and the data resets. Everything here works identically with a real database; only the data-access calls change. Database integration comes later in the tutorial.
List and fetch (GET)
JS
// GET /tasks → all tasks
app.get('/tasks', (req, res) => {
res.json(tasks)
})
// GET /tasks/:id → one task (or 404)
app.get('/tasks/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id))
if (!task) {
return res.status(404).json({ error: 'Task not found' })
}
res.json(task)
})`req.params.id` is a string — convert it
Route parameters arrive as **strings** (`req.params.id` is `'1'`, not `1`), so comparing with `===` against a numeric id fails unless you `Number(...)` it. This is the same string-coercion rule as [query parameters](/nodejs/query-parameters). And `return res.status(404)...` — the `return` prevents falling through to send a second response.
Create (POST)
JS
app.post('/tasks', (req, res) => {
const { title } = req.body // populated by express.json()
if (!title || typeof title !== 'string') {
return res.status(422).json({ error: 'title is required' })
}
const task = { id: nextId++, title, done: false }
tasks.push(task)
res.status(201).json(task) // 201 Created + the new resource
})Always validate `req.body` — clients send anything
`req.body` is untrusted user input. Check that required fields exist and have the right type *before* using them; respond `422` (unprocessable) on bad input. Skipping validation leads to corrupt data and crashes (e.g. calling `.trim()` on `undefined`). The same [validate-everything rule](/nodejs/handling-forms) from raw form handling applies.
Delete (DELETE)
JS
app.delete('/tasks/:id', (req, res) => {
const id = Number(req.params.id)
const before = tasks.length
tasks = tasks.filter((t) => t.id !== id)
if (tasks.length === before) {
return res.status(404).json({ error: 'Task not found' })
}
res.status(204).end() // 204 No Content — nothing to return
})`204 No Content` for a successful delete — and no body
A successful delete has nothing meaningful to return, so `204` with `res.status(204).end()` is idiomatic — note `.end()`, not `.json()`, because a [204 response must have no body](/nodejs/http-status-codes). Returning the deleted object with `200` is also acceptable; just be consistent across your API.
Response helper cheat sheet
Helper | Does |
|---|---|
| Send JSON (sets |
| Send string/Buffer/object (infers type) |
| Set status (chainable) |
| Set status + send its text |
| Send a 302 (or given code) redirect |
| Set a response header |
| End with no body (e.g. 204) |
Test it
Bash
curl localhost:3000/tasks
curl localhost:3000/tasks/1
curl -X POST localhost:3000/tasks -H 'Content-Type: application/json' -d '{"title":"Ship it"}'
curl -X DELETE localhost:3000/tasks/2 -i # -i shows the 204 status lineA note on async handlers and errors
When a handler does async work (database, fetch), make it `async` and `await`. In Express 5, a rejected promise is automatically routed to your error handler; in Express 4 you had to wrap handlers or call `next(err)` manually. We cover robust error handling in the [error-handling middleware](/nodejs/error-middleware) section.
Next
Go deeper on matching requests to handlers: [Express Routing](/nodejs/express-routing).