A safe harbor for coders and developers

Tutorials

How to Build a REST API with Node.js and Express: A Step-by-Step Tutorial for 2025

How to Build a REST API with Node.js and Express: A Step-by-Step Tutorial for 2025

Express has been the default HTTP layer for Node.js for over a decade, and it still earns the spot: small, unopinionated, and easy to reason about six months later. Over the next stretch of work you'll build a functioning REST API for a bookmarks manager — routes, validation, middleware, error handling, and tests that run with a single command. Everything here uses stable, current versions of Node.js and Express, and nothing you'll have to rip out later.

Start With the Resource, Not the Code

An API is a set of promises about a resource. Decide what that resource is, which fields it carries, and which operations you support, and the code almost writes itself. Ours is a bookmark: an id, a title, a URL, optional tags, and a createdAt timestamp.

The endpoints and the status codes each should return:

  • GET /api/bookmarks — 200 with an array of bookmarks
  • GET /api/bookmarks/:id — 200, or 404 when nothing matches
  • POST /api/bookmarks — 201 with the created record, 400 on invalid input
  • PUT /api/bookmarks/:id — 200 with the updated record, 404 if it's gone
  • DELETE /api/bookmarks/:id — 204 with no body, 404 if it's gone

Write that list down before you open an editor. It's the contract your client code and your tests will both lean on.

Project Setup in Four Commands

Start in an empty folder with Node.js 18 or newer installed, then run:

npm init -y

npm install express

npm install --save-dev nodemon

Add two scripts to package.json: "start": "node src/server.js" and "dev": "nodemon src/server.js". Nodemon restarts the process whenever a file changes, which saves a lot of manual Ctrl+C.

Keep the layout small and obvious:

  • src/server.js — starts the HTTP server
  • src/app.js — builds and exports the Express app
  • src/routes/bookmarks.js — the router
  • src/store.js — the data layer
  • test/bookmarks.test.js — the tests

Splitting app.js from server.js matters more than it looks. Tests import the app directly without binding a port, so you never fight "address already in use" errors or a stray process holding port 3000.

Wire Up Your Routes

Create a router with express.Router() and mount it at /api/bookmarks from app.js. Each handler receives (req, res); keep them thin and push real logic into the store.

router.get('/', (req, res) => res.json(store.all()));

For a single item, read req.params.id and return 404 when the store comes back empty. A response like res.status(404).json({ error: 'Not found' }) tells the client far more than a silent 200 with a null body.

POST needs one guard before anything else: check the body you were handed.

if (!req.body.title || !req.body.url) return res.status(400).json({ error: 'title and url are required' });

Generate the id server-side with crypto.randomUUID() and set createdAt yourself. Never let the client pick the id — that's how two records end up fighting over the same key. Return 201 and the created object so the client can cache it immediately instead of re-fetching the whole collection.

PUT replaces a record; PATCH updates individual fields. Pick one, document it, and stay consistent. If you're updating fields, spread the existing record first and then apply the incoming values, so a client can't accidentally erase fields it never mentioned.

Middleware Is Just a Function in the Middle

Middleware sits between the request arriving and your handler replying. Order is the whole game.

  1. app.use(express.json()) — parses JSON bodies into req.body and rejects malformed JSON with a 400.
  2. A short logger printing method, path, and duration. Ten lines of logging save hours of guessing later.
  3. Your router, mounted at /api.
  4. A catch-all 404 handler registered after all routes: app.use((req, res) => res.status(404).json({ error: 'Route not found' })). Put it first and every request lands there.
  5. An error handler with four arguments — (err, req, res, next). Express only treats it as an error handler when all four are present, and that detail trips up almost everyone once.

Inside the error handler, log the stack, then send res.status(err.status || 500).json({ error: 'Something went wrong' }). Keep internal messages out of the response body.

Where the Data Actually Lives

A module-level array is fine for a tutorial and honestly fine for a prototype. It resets on restart, and that's the only real cost. Wrap it in plain functions — all(), find(id), create(data), update(id, data), remove(id) — so swapping in SQLite or Postgres later means rewriting one file instead of every route.

Two habits make that swap painless. Return promises from the start, so callers already await and the function bodies just get longer. And keep HTTP concerns out of the store: no req, no res, plain data in and plain data out.

Test the API Before You Trust It

Manual checks first. With the dev server running, curl confirms the basics in seconds.

curl http://localhost:3000/api/bookmarks

curl -X POST http://localhost:3000/api/bookmarks -H "Content-Type: application/json" -d '{"title":"MDN","url":"https://developer.mozilla.org"}'

Then automate it. Supertest drives the app in-process without a live server, and Node's built-in test runner means no extra framework to configure.

const request = require('supertest');
const app = require('../src/app');

Focus on the four things that break in production: happy paths return the right status and shape, unknown ids return 404, invalid bodies return 400, and a fresh store starts empty. Reset the store in a beforeEach hook so tests never depend on the order they run in.

Ship It, Then Keep It Boring

Read the port from process.env.PORT, add a .gitignore for node_modules, and the API is ready for a container or a small host. The highest-value additions from here are schema validation, pagination on the list endpoint, and rate limiting on writes. None of them require changing the structure you just built.

The pattern worth keeping: decide the resource, separate app from server, keep handlers thin, put data behind functions, and test failure cases as carefully as happy ones. Do that and you have an API you can extend for years without a rewrite.

Photo: Lukas Blazek / Pexels

Related Posts

Top 10 VS Code Extensions for Python Developers in 2025

Tools

Top 10 VS Code Extensions for Python Developers in 2025

Ten VS Code extensions that genuinely help Python work in 2025, with honest pros, cons, and the order to install them in.

How to Deploy a Static Website with GitHub Pages and a Custom Domain

Tutorials

How to Deploy a Static Website with GitHub Pages and a Custom Domain

A practical walkthrough for hosting a static site free on GitHub Pages, from pushing your first commit to pointing a...