Skip to content
Codeloom
Node.js

Build Your First REST API with Express

A hands-on tutorial — install Express, define GET/POST/PUT/DELETE routes, parse JSON bodies, use route params, and return proper status codes for an in-memory todos API.

·9 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How to install Express and start a server
  • How to define routes for GET, POST, PUT, and DELETE
  • How to read query strings, route params, and JSON bodies
  • Which HTTP status codes to return for which outcomes
  • How to build a tiny CRUD API for /todos backed by an in-memory list

Prerequisites

Express is the most-used web framework in the Node ecosystem. It is small, unopinionated, and has been the default choice for a decade. In this post, you will build a working CRUD API for a list of todos, with proper status codes, error handling, and the routes a real REST client expects.

Set up the project

Start fresh:

mkdir todo-api && cd todo-api
npm init -y
npm install express

Open package.json and add "type": "module" so we can use modern import syntax — see CommonJS vs ES Modules for context. Also add a dev script:

{
  "type": "module",
  "scripts": {
    "dev": "node --watch index.js"
  },
  "dependencies": {
    "express": "^4.19.2"
  }
}

node --watch restarts the server whenever you save a file — no separate tool needed.

A minimal server

Create index.js:

import express from 'express';

const app = express();

app.get('/', (req, res) => {
  res.send('Hello from Express!');
});

app.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});

Run it:

npm run dev
# output: Listening on http://localhost:3000

Visit http://localhost:3000 in your browser. You should see Hello from Express!. That is a complete HTTP server in ten lines.

How routing works

Each route is a method call: app.get, app.post, app.put, app.delete. The first argument is the path, the second is a handler that receives req and res.

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

res.json serialises the argument to JSON and sets Content-Type: application/json. It is the response method you will use most.

In-memory data

For learning, a plain array beats a database. We’ll keep todos in memory and reset on restart:

let todos = [
  { id: 1, text: 'Learn Express', done: false },
  { id: 2, text: 'Build first API', done: false },
];

let nextId = 3;

List all todos: GET /todos

app.get('/todos', (req, res) => {
  res.json(todos);
});

Test it from another terminal with curl:

curl http://localhost:3000/todos
# output:
# [{"id":1,"text":"Learn Express","done":false},
#  {"id":2,"text":"Build first API","done":false}]

Filter with query strings

Query parameters arrive on req.query:

app.get('/todos', (req, res) => {
  const { done } = req.query;
  if (done === 'true') {
    return res.json(todos.filter(t => t.done));
  }
  if (done === 'false') {
    return res.json(todos.filter(t => !t.done));
  }
  res.json(todos);
});
curl 'http://localhost:3000/todos?done=true'
# output: []

req.query values are always strings (or arrays of strings), even when they look like booleans or numbers. Coerce them explicitly.

Get one todo: GET /todos/:id

Route parameters use :name syntax and land on req.params:

app.get('/todos/:id', (req, res) => {
  const id = Number(req.params.id);
  const todo = todos.find(t => t.id === id);
  if (!todo) {
    return res.status(404).json({ error: 'Todo not found' });
  }
  res.json(todo);
});

Two things here that matter:

  1. req.params.id is a string. Convert before comparing.
  2. res.status(404) sets the status code, then .json(...) sends the body. Chain them.
curl http://localhost:3000/todos/1
# output: {"id":1,"text":"Learn Express","done":false}

curl -i http://localhost:3000/todos/999
# output:
# HTTP/1.1 404 Not Found
# {"error":"Todo not found"}

Parsing JSON bodies

Before handling POSTs, tell Express to parse incoming JSON:

app.use(express.json());

This middleware runs on every request and populates req.body when the content type is application/json. Without it, req.body is undefined.

Create a todo: POST /todos

app.post('/todos', (req, res) => {
  const { text } = req.body ?? {};
  if (typeof text !== 'string' || text.trim() === '') {
    return res.status(400).json({ error: 'text is required' });
  }
  const todo = { id: nextId++, text: text.trim(), done: false };
  todos.push(todo);
  res.status(201).json(todo);
});

Notes:

  • 400 Bad Request for malformed input. Always validate.
  • 201 Created for a successful creation, with the new resource in the body.
  • The server assigns the id. Never trust an id from the client for creation.

Test it:

curl -X POST http://localhost:3000/todos \
  -H 'Content-Type: application/json' \
  -d '{"text":"Write a blog post"}'

# output:
# {"id":3,"text":"Write a blog post","done":false}

Try it yourself. Add a POST request that creates a todo, then a GET that fetches it by its returned id. Confirm a POST with a missing text field returns 400 and that fetching id 9999 returns 404. Use curl -i to see status codes alongside the bodies.

Update a todo: PUT /todos/:id

PUT replaces the whole resource. PATCH would modify selected fields — we will use PUT here for simplicity but accept partial updates:

app.put('/todos/:id', (req, res) => {
  const id = Number(req.params.id);
  const todo = todos.find(t => t.id === id);
  if (!todo) {
    return res.status(404).json({ error: 'Todo not found' });
  }

  const { text, done } = req.body ?? {};

  if (text !== undefined) {
    if (typeof text !== 'string' || text.trim() === '') {
      return res.status(400).json({ error: 'text must be a non-empty string' });
    }
    todo.text = text.trim();
  }

  if (done !== undefined) {
    if (typeof done !== 'boolean') {
      return res.status(400).json({ error: 'done must be a boolean' });
    }
    todo.done = done;
  }

  res.json(todo);
});
curl -X PUT http://localhost:3000/todos/1 \
  -H 'Content-Type: application/json' \
  -d '{"done":true}'

# output: {"id":1,"text":"Learn Express","done":true}

Delete a todo: DELETE /todos/:id

app.delete('/todos/:id', (req, res) => {
  const id = Number(req.params.id);
  const index = todos.findIndex(t => t.id === id);
  if (index === -1) {
    return res.status(404).json({ error: 'Todo not found' });
  }
  todos.splice(index, 1);
  res.status(204).end();
});

204 No Content is the conventional response for a successful DELETE with no body. res.end() sends the response without a payload.

curl -i -X DELETE http://localhost:3000/todos/2
# output:
# HTTP/1.1 204 No Content

Status codes worth memorising

CodeMeaningWhen
200OKSuccessful GET/PUT/PATCH
201CreatedSuccessful POST that creates a resource
204No ContentSuccessful DELETE, or any success without a body
400Bad RequestInvalid or missing input
401UnauthorizedMissing or bad credentials
403ForbiddenAuthenticated but not allowed
404Not FoundResource does not exist
409ConflictThe request conflicts with current state
500Internal Server ErrorYour bug — return only when you have nothing better

You will rarely need more than these. See What Is REST? for the longer list.

Centralised error handling

Wrap risky logic and forward errors to Express:

app.get('/crash', (req, res, next) => {
  try {
    throw new Error('something blew up');
  } catch (err) {
    next(err);
  }
});

// Error-handling middleware has four args
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal Server Error' });
});

The four-argument signature is how Express recognises an error handler. Put it at the bottom of your app.

The full file

import express from 'express';

const app = express();
app.use(express.json());

let todos = [
  { id: 1, text: 'Learn Express', done: false },
  { id: 2, text: 'Build first API', done: false },
];
let nextId = 3;

app.get('/todos', (req, res) => {
  const { done } = req.query;
  if (done === 'true')  return res.json(todos.filter(t => t.done));
  if (done === 'false') return res.json(todos.filter(t => !t.done));
  res.json(todos);
});

app.get('/todos/:id', (req, res) => {
  const id = Number(req.params.id);
  const todo = todos.find(t => t.id === id);
  if (!todo) return res.status(404).json({ error: 'Todo not found' });
  res.json(todo);
});

app.post('/todos', (req, res) => {
  const { text } = req.body ?? {};
  if (typeof text !== 'string' || text.trim() === '') {
    return res.status(400).json({ error: 'text is required' });
  }
  const todo = { id: nextId++, text: text.trim(), done: false };
  todos.push(todo);
  res.status(201).json(todo);
});

app.put('/todos/:id', (req, res) => {
  const id = Number(req.params.id);
  const todo = todos.find(t => t.id === id);
  if (!todo) return res.status(404).json({ error: 'Todo not found' });

  const { text, done } = req.body ?? {};
  if (text !== undefined) {
    if (typeof text !== 'string' || text.trim() === '') {
      return res.status(400).json({ error: 'text must be a non-empty string' });
    }
    todo.text = text.trim();
  }
  if (done !== undefined) {
    if (typeof done !== 'boolean') {
      return res.status(400).json({ error: 'done must be a boolean' });
    }
    todo.done = done;
  }
  res.json(todo);
});

app.delete('/todos/:id', (req, res) => {
  const id = Number(req.params.id);
  const index = todos.findIndex(t => t.id === id);
  if (index === -1) return res.status(404).json({ error: 'Todo not found' });
  todos.splice(index, 1);
  res.status(204).end();
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal Server Error' });
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

That is a real, working REST API in roughly 60 lines.

Try it yourself. Add a new endpoint POST /todos/:id/toggle that flips a todo’s done field and returns the updated resource. It should return 404 if the id is unknown. Then add a GET /todos/search?q=foo route that returns todos whose text contains the query string (case-insensitive).

Where to go from here

This server forgets everything on restart because the data lives in memory. The next layer of real APIs is persistence — a database like Postgres or SQLite, accessed through a driver or ORM. The HTTP code you wrote barely changes; the array just becomes a table.

Other natural next steps:

  • Validation with a schema library like zod to replace the hand-rolled checks
  • Authentication with JWTs or session cookies
  • Testing with vitest and a request library like supertest
  • Logging with a structured logger like pino

Recap

You now know:

  • express() builds an app; app.get/post/put/delete register routes
  • express.json() middleware parses JSON request bodies into req.body
  • Route params land on req.params, query strings on req.query
  • res.status(code).json(body) sends a typed response with a status code
  • A complete CRUD API needs GET list, GET one, POST create, PUT update, DELETE
  • 201, 204, 400, 404 are the status codes you will reach for most often

Next steps

You have built a REST API; you should also understand the architectural style behind the letters in REST.

Next: What Is REST? The Architectural Style Explained

Questions or feedback? Email codeloomdevv@gmail.com.