|

Express.js 2026: Complete Beginner Guide to Building Node.js APIs

Express.js is the most popular web framework for Node.js. It provides a thin layer on top of Node’s built-in HTTP module, giving you routing, middleware, and request/response utilities without the complexity of a full-featured framework. In 2026, Express powers millions of production APIs — from startups to Fortune 500 companies. This guide teaches you how to build REST APIs with Express from scratch.

Before starting, make sure you have completed the Node.js basics lesson and understand how to work with files in Node. You should also be comfortable with npm and async/await.

Why Express?

Express is a minimal, unopinionated framework. It does not force you into a specific project structure or require a specific ORM. You get the essentials — routing, middleware, and request handling — and choose everything else yourself.

In 2026, alternatives exist: Fastify is faster, Hono is lighter, and NestJS is more structured. But Express remains the industry standard because of its ecosystem, simplicity, and the sheer volume of tutorials, middleware packages, and production experience behind it. Learning Express teaches you patterns that transfer to every other Node.js framework.

Project Setup

# Create a new project
mkdir my-api && cd my-api
npm init -y

# Install Express
npm install express

# Install useful dev dependencies
npm install --save-dev nodemon

Add scripts to package.json:

{
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  }
}

Create server.js:

import express from 'express';

const app = express();
const PORT = process.env.PORT || 3000;

// Parse JSON request bodies
app.use(express.json());

// Basic route
app.get('/', (req, res) => {
  res.json({ message: 'API is running', timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

Run npm run dev and nodemon will auto-restart the server whenever you save changes. Visit http://localhost:3000 to see your API response.

Routing

Express routing maps HTTP methods and URL paths to handler functions.

// HTTP methods
app.get('/users', getUsers);       // Read
app.post('/users', createUser);    // Create
app.put('/users/:id', updateUser); // Full update
app.patch('/users/:id', patchUser); // Partial update
app.delete('/users/:id', deleteUser); // Delete

// Route parameters
app.get('/users/:id', (req, res) => {
  const userId = req.params.id;
  res.json({ userId });
});

// Multiple parameters
app.get('/posts/:postId/comments/:commentId', (req, res) => {
  const { postId, commentId } = req.params;
  res.json({ postId, commentId });
});

// Query string: GET /search?q=nodejs&page=2
app.get('/search', (req, res) => {
  const { q, page = 1, limit = 10 } = req.query;
  res.json({ query: q, page: Number(page), limit: Number(limit) });
});

Router Modules

Organize routes into separate files using express.Router():

// routes/users.js
import { Router } from 'express';
const router = Router();

router.get('/', (req, res) => {
  res.json({ users: [] });
});

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

router.post('/', (req, res) => {
  res.status(201).json({ user: req.body });
});

export default router;

// server.js
import userRoutes from './routes/users.js';
app.use('/api/users', userRoutes);

This mounts all user routes under /api/users. The GET / route in the router becomes GET /api/users/ in the application. This pattern keeps your main server file clean and your routes modular.

Middleware

Middleware functions are the core architectural pattern in Express. They run between receiving a request and sending a response, forming a pipeline that processes each request.

// Middleware signature: (req, res, next)
function logger(req, res, next) {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.url} ${res.statusCode} ${duration}ms`);
  });
  next(); // Pass to next middleware
}

// Apply globally (runs on every request)
app.use(logger);
app.use(express.json());         // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse form data

// Apply to specific routes
app.get('/admin', authMiddleware, adminHandler);

// Apply to a router
router.use(authMiddleware); // All routes in this router require auth

Common Middleware Stack

import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

const app = express();

// Security headers
app.use(helmet());

// CORS
app.use(cors({ origin: 'https://myapp.com' }));

// Rate limiting
app.use(rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                   // 100 requests per window
  message: { error: 'Too many requests, slow down' },
}));

// Parse request bodies
app.use(express.json({ limit: '10mb' }));

The order of middleware matters. helmet() should run before your routes so every response gets security headers. Rate limiting should run early to reject abusive requests before wasting resources on routing and business logic.

Request & Response Objects

Express extends Node’s native request and response objects with helpful methods.

app.post('/api/data', (req, res) => {
  // Request properties
  req.body;          // Parsed JSON body
  req.params;        // URL parameters (:id)
  req.query;         // Query string (?page=1)
  req.headers;       // Request headers
  req.method;        // GET, POST, etc.
  req.path;          // URL path
  req.ip;            // Client IP address
  req.get('Host');   // Get a specific header

  // Response methods
  res.status(200).json({ data: 'ok' });  // JSON response
  res.status(201).send('Created');        // Text response
  res.status(204).end();                  // No content
  res.status(301).redirect('/new-url');   // Redirect
  res.status(404).json({ error: 'Not found' });
  res.sendFile('/path/to/file.pdf');      // Send file
  res.set('X-Custom-Header', 'value');    // Set header
  res.cookie('session', 'abc123');        // Set cookie
});

Important: You can only send one response per request. Calling res.json() twice throws an error. Always return after sending a response in conditional branches to prevent accidental double-sends.

Building a REST API

Let us build a complete CRUD API for a task manager. This uses an in-memory array for simplicity — in production, you would use a database.

import express from 'express';
import crypto from 'node:crypto';

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

// In-memory data store
let tasks = [
  { id: '1', title: 'Learn Express', completed: false, createdAt: new Date().toISOString() },
  { id: '2', title: 'Build an API', completed: false, createdAt: new Date().toISOString() },
];

// GET /api/tasks - List all tasks
app.get('/api/tasks', (req, res) => {
  const { completed, sort } = req.query;

  let result = [...tasks];

  // Filter by completion status
  if (completed !== undefined) {
    result = result.filter(t => t.completed === (completed === 'true'));
  }

  // Sort by creation date
  if (sort === 'newest') {
    result.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  }

  res.json({ tasks: result, total: result.length });
});

// GET /api/tasks/:id - Get a single task
app.get('/api/tasks/:id', (req, res) => {
  const task = tasks.find(t => t.id === req.params.id);
  if (!task) {
    return res.status(404).json({ error: 'Task not found' });
  }
  res.json({ task });
});

// POST /api/tasks - Create a task
app.post('/api/tasks', (req, res) => {
  const { title } = req.body;

  if (!title || typeof title !== 'string' || title.trim().length === 0) {
    return res.status(400).json({ error: 'Title is required' });
  }

  const task = {
    id: crypto.randomUUID(),
    title: title.trim(),
    completed: false,
    createdAt: new Date().toISOString(),
  };

  tasks.push(task);
  res.status(201).json({ task });
});

// PATCH /api/tasks/:id - Update a task
app.patch('/api/tasks/:id', (req, res) => {
  const task = tasks.find(t => t.id === req.params.id);
  if (!task) {
    return res.status(404).json({ error: 'Task not found' });
  }

  const { title, completed } = req.body;
  if (title !== undefined) task.title = title.trim();
  if (completed !== undefined) task.completed = Boolean(completed);

  res.json({ task });
});

// DELETE /api/tasks/:id - Delete a task
app.delete('/api/tasks/:id', (req, res) => {
  const index = tasks.findIndex(t => t.id === req.params.id);
  if (index === -1) {
    return res.status(404).json({ error: 'Task not found' });
  }

  tasks.splice(index, 1);
  res.status(204).end();
});

app.listen(3000, () => console.log('Task API running on port 3000'));

Test this with curl:

# List tasks
curl http://localhost:3000/api/tasks

# Create a task
curl -X POST http://localhost:3000/api/tasks   -H "Content-Type: application/json"   -d '{"title": "Read Express guide"}'

# Update a task
curl -X PATCH http://localhost:3000/api/tasks/1   -H "Content-Type: application/json"   -d '{"completed": true}'

# Delete a task
curl -X DELETE http://localhost:3000/api/tasks/1

Error Handling

Express has a special error-handling middleware pattern: four arguments instead of three. This catches errors thrown in route handlers and sends a clean response.

// Async error wrapper (avoids try/catch in every route)
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

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

// Custom error class
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
  }
}

// Error-handling middleware (MUST have 4 parameters)
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const message = statusCode === 500 ? 'Internal server error' : err.message;

  // Log the full error in development
  if (process.env.NODE_ENV !== 'production') {
    console.error(err.stack);
  }

  res.status(statusCode).json({
    error: message,
    ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
  });
});

Place the error-handling middleware after all routes. Express routes errors to the first four-argument middleware it finds. If you place it before your routes, it will never receive errors from routes defined after it.

Input Validation

Never trust client input. Validate every field before using it in your application logic.

// Simple validation middleware
function validateTask(req, res, next) {
  const { title } = req.body;

  const errors = [];

  if (!title) errors.push('Title is required');
  else if (typeof title !== 'string') errors.push('Title must be a string');
  else if (title.trim().length < 3) errors.push('Title must be at least 3 characters');
  else if (title.trim().length > 200) errors.push('Title must be under 200 characters');

  if (errors.length > 0) {
    return res.status(400).json({ errors });
  }

  // Sanitize input
  req.body.title = title.trim();
  next();
}

app.post('/api/tasks', validateTask, asyncHandler(async (req, res) => {
  // req.body.title is now validated and sanitized
  const task = await createTask(req.body);
  res.status(201).json({ task });
}));

For complex validation, use libraries like Zod or Joi. They provide schema-based validation that is declarative, composable, and produces clear error messages automatically.

Production Patterns

Graceful Shutdown

const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

function shutdown(signal) {
  console.log(`${signal} received. Shutting down gracefully...`);
  server.close(() => {
    console.log('Server closed. Exiting.');
    process.exit(0);
  });

  // Force shutdown after 10 seconds
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10000);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Health Check Endpoint

app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
    memory: process.memoryUsage(),
  });
});

Request ID Tracking

import crypto from 'node:crypto';

app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || crypto.randomUUID();
  res.set('X-Request-ID', req.id);
  next();
});

Common Express Mistakes

Forgetting to call next(): If your middleware does not send a response or call next(), the request hangs indefinitely. Every middleware must either respond or call next().

Not returning after res.send(): In conditional logic, forgetting to return after sending a response leads to “headers already sent” errors when the code continues to another res.json() call.

Using app.use(express.json()) after routes: Middleware order matters. If you define routes before the JSON parser, req.body will be undefined in those routes.

Not handling async errors: Express does not catch errors thrown in async handlers by default. Without the asyncHandler wrapper or Express 5 (which catches them automatically), unhandled promise rejections will crash your server.

Storing state in module-level variables: Variables like our tasks array disappear on server restart. Use a database for any data that needs to persist. In-memory stores are fine for caches and sessions with proper fallbacks, but never for primary data storage.

Express is the foundation of Node.js web development. Master routing, middleware, and error handling, and you can build anything from a simple API to a complex microservice. From here, you are ready to add databases, authentication, deployment, and all the other pieces of a production-ready backend.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *