March 26, 20265 min read

Express.js: Building Real APIs Without the Toy Examples

A practical guide to Express.js covering routing, middleware, error handling, JSON APIs, and router modules. Enough to build something real, skip the hello-world fluff.

nodejs express backend javascript api
Ad 336x280

Every Express tutorial starts with app.get('/', (req, res) => res.send('Hello World')) and then somehow jumps to "now deploy to production." There's a lot of ground between those two points. This is the stuff that actually matters when you're building a real API.

Setting Up

npm init -y
npm install express
const express = require('express');
const app = express();

// Parse JSON bodies -- you need this for any POST/PUT endpoint
app.use(express.json());

app.listen(3000, () => console.log('Running on port 3000'));

That express.json() line is important. Without it, req.body is undefined on every request. I've watched people debug that for 30 minutes.

Routing: The Core of Everything

Express gives you methods that map directly to HTTP verbs:

app.get('/users', (req, res) => {
  // list users
});

app.post('/users', (req, res) => {
// create a user
const { name, email } = req.body;
// ...
});

app.put('/users/:id', (req, res) => {
// update a user
const userId = req.params.id;
// ...
});

app.delete('/users/:id', (req, res) => {
// delete a user
const userId = req.params.id;
// ...
});

Route params (:id) show up on req.params. Query strings (?page=2&limit=10) show up on req.query. Form/JSON bodies show up on req.body. That's it -- those three cover 99% of how data gets into your server.

Middleware: The Thing That Makes Express Work

Middleware is just a function with access to req, res, and next. It runs before your route handler, and you call next() to pass control to the next middleware in the chain.

function logger(req, res, next) {
  console.log(${req.method} ${req.path});
  next(); // don't forget this or the request hangs forever
}

app.use(logger);

Order matters. Middleware runs in the order you register it. If you put your auth middleware after your routes, it never runs. Classic mistake:
// WRONG -- routes execute before auth check
app.get('/dashboard', getDashboard);
app.use(authMiddleware);

// RIGHT -- auth runs first
app.use(authMiddleware);
app.get('/dashboard', getDashboard);

You can also attach middleware to specific routes:

app.get('/admin', authMiddleware, adminOnly, (req, res) => {
  res.json({ message: 'Welcome, admin' });
});

This is powerful. You're building a pipeline: authenticate, then authorize, then handle the request.

The Request and Response Objects

req has everything about the incoming request. The properties you'll use constantly:
  • req.params -- route parameters (/users/:id)
  • req.query -- query string (?search=hello)
  • req.body -- parsed request body (needs express.json())
  • req.headers -- HTTP headers
  • req.method -- GET, POST, etc.
res is how you send stuff back:
res.json({ users: [] });         // send JSON (sets Content-Type automatically)
res.status(201).json({ id: 1 }); // set status + send JSON
res.status(404).send('Not found');
res.redirect('/login');

Always send a response. If you don't, the client hangs until it times out.

Error Handling Middleware

This is where most Express apps fall apart. Throwing errors inside async handlers doesn't work the way you'd expect -- Express won't catch rejected promises by default.

The pattern that works:

// Wrap async handlers to catch errors
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
res.json(user);
}));

Then add an error-handling middleware at the very end. It takes four arguments -- that's how Express knows it's an error handler:

app.use((err, req, res, next) => {
  const status = err.status || 500;
  res.status(status).json({
    error: err.message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
});

Put this after all your routes. Errors bubble down to it.

Router Modules: Keeping Things Organized

Once you have more than a handful of routes, stuff gets messy. Express Router lets you split routes into separate files:

// routes/users.js
const router = require('express').Router();

router.get('/', async (req, res) => {
const users = await db.getUsers();
res.json(users);
});

router.post('/', async (req, res) => {
const user = await db.createUser(req.body);
res.status(201).json(user);
});

router.get('/:id', async (req, res) => {
const user = await db.getUser(req.params.id);
res.json(user);
});

module.exports = router;

// app.js
const userRoutes = require('./routes/users');
const productRoutes = require('./routes/products');

app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);

Now /api/users/123 hits the /:id handler in your users router. Clean separation, each file handles one resource.

Serving Static Files

If you need to serve images, CSS, or a frontend build:

app.use(express.static('public'));
// GET /style.css serves public/style.css

For an API that also serves a React/Vue build:

app.use(express.static('client/build'));

// Catch-all for client-side routing
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'client/build', 'index.html'));
});

Put API routes before the catch-all, otherwise every request returns your index.html.

Putting It Together

A realistic Express app structure looks like this:

project/
  routes/
    users.js
    products.js
    auth.js
  middleware/
    auth.js
    validation.js
  app.js
  server.js

The app file wires everything together. Routes are modular. Middleware is reusable. Error handling is centralized. That's the whole framework -- it's deliberately minimal, and that's the point.

If you want to practice building APIs with Express and get hands-on with routing and middleware patterns, CodeUp has interactive exercises that let you write and test server code right in your browser.

Ad 728x90