How to Build a REST API With Node.js and Express
Express has been the default way to build HTTP APIs in Node.js for well over a decade, and with Express 5 now stable, it is a cleaner foundation than ever. Yet most tutorials still produce a single file of routes with no validation, no error handling, and status codes chosen at random. A REST API built with Node.js and Express is a server that exposes resources, such as users or orders, at predictable URLs and lets clients read and change them through standard HTTP methods, returning JSON and meaningful status codes.
Quick Answer: Initialize a Node.js project, install Express, create an app with express.json(), organize routes by resource with express.Router, validate every request body with a schema library such as Zod, return correct status codes, centralize errors in one error-handling middleware, and add security middleware, logging, and tests before deploying.
What Makes an API "RESTful" in Practice?
An API is RESTful in practice when its URLs name resources, its HTTP methods describe actions, and its responses use status codes consistently. Resources are nouns in the path, such as /api/v1/products and /api/v1/products/42. Methods describe what happens: GET reads, POST creates, PUT replaces, PATCH partially updates, and DELETE removes. The server keeps no session state between requests; each request carries what it needs, such as an authentication token.
Express is a minimal web framework that maps those methods and paths to handler functions and lets you compose behavior with middleware, functions that run in sequence for each request and can modify it, end it, or pass it on. Express 5 notably forwards rejected promises from async handlers to your error middleware, which removes much of the try-catch boilerplate older code needed. If your API will feed a Next.js front end, our article on MongoDB connection patterns for Next.js explains when you might query the database directly from Next.js instead of through a separate API.
If you are upgrading an older project, note that Express 5 also changed route path matching. Patterns are stricter, wildcard routes need a name, and some regular-expression shortcuts from Express 4 are no longer supported, so review your route definitions and run your tests after upgrading. The official migration guide lists each change, and most applications need only small adjustments.
Two more terms matter. Idempotent methods produce the same result when repeated; GET, PUT, and DELETE should be idempotent, while POST usually is not. Versioning, typically via a /v1 path prefix, lets you change the API later without breaking existing clients.
Step-by-Step: Building the API
A maintainable Express API comes together in a consistent sequence. Each step below builds on the previous one, and the structure scales from a prototype to a production service.
- Initialize the project. Run
npm init -y, set"type": "module"in package.json, and installexpress,zod,helmet, andcors. - Create the app module. In
src/app.js, create the Express app, addhelmet(), configuredcors(), andexpress.json({ limit: '100kb' }). - Organize routes by resource. Create
src/routes/products.jswith anexpress.Router()and mount it at/api/v1/products. - Separate logic from routing. Keep handlers thin; put database queries and business rules in a service module so they can be tested without HTTP.
- Validate input. Define Zod schemas for request bodies and query parameters, and reject invalid requests with 400 or 422 and a clear message.
- Centralize errors. Add a final four-argument error middleware that logs the error and returns a consistent JSON shape.
- Start the server separately. In
src/server.js, import the app and calllisten, reading the port from an environment variable. Keeping this separate lets tests import the app without opening a port.
A compact router showing these patterns:
import { Router } from 'express';
import { z } from 'zod';
import * as products from '../services/products.js';
const router = Router();
const ProductInput = z.object({ name: z.string().min(1).max(120), price: z.number().positive() });
router.get('/', async (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1);
res.json(await products.list({ page, limit: 20 }));
});
router.post('/', async (req, res) => {
const parsed = ProductInput.safeParse(req.body);
if (!parsed.success) return res.status(422).json({ error: 'Invalid input', details: parsed.error.issues });
const created = await products.create(parsed.data);
res.status(201).location(`/api/v1/products/${created.id}`).json(created);
});
router.get('/:id', async (req, res) => {
const item = await products.get(req.params.id);
if (!item) return res.status(404).json({ error: 'Product not found' });
res.json(item);
});
export default router;And the error middleware, registered after all routes:
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({ error: err.expose ? err.message : 'Internal server error' });
});HTTP Methods and Status Codes Reference
Consistent status codes are what make an API predictable for client developers. This reference covers the combinations you will use in nearly every resource.
| Operation | Method and Path | Success Status | Common Error Statuses |
|---|---|---|---|
| List resources | GET /products | 200 OK | 400 for invalid query parameters |
| Read one resource | GET /products/:id | 200 OK | 404 Not Found |
| Create resource | POST /products | 201 Created with Location header | 400 or 422 invalid body, 409 conflict |
| Partial update | PATCH /products/:id | 200 OK with updated resource | 404, 422, 409 |
| Delete resource | DELETE /products/:id | 204 No Content | 404, or 403 if not permitted |
Use 401 when a request lacks valid authentication and 403 when the user is authenticated but not allowed. Mixing those two is one of the most common sources of client confusion.
Design Decisions That Keep an API Maintainable
Long-term maintainability comes from a few early decisions that are tedious to retrofit later. This is practitioner analysis from building and inheriting Express services rather than a survey, but these patterns consistently separate APIs that age well from those that need rewrites.
First, pick one error response shape and never deviate. A body such as { "error": "message", "details": [...] } on every failure lets clients write one error handler instead of special cases for each endpoint.
Second, paginate every list from day one. Unpaginated endpoints work with ten records and fail with ten thousand. Offset pagination with page and limit is simple; cursor pagination based on a sortable field is more robust for large or frequently changing collections.
Third, keep business logic out of route files. When services contain the rules and routes only translate HTTP, you can reuse logic in background jobs, CLI scripts, and tests without spinning up a server.
Fourth, document as you build. An OpenAPI description, whether written by hand or generated from your schemas, gives front-end developers a contract and enables generated client types. AI assistants are particularly good at drafting these specs and repetitive handlers; our roundup of AI assistants that speed up JavaScript work covers free options that handle this kind of structured code well.
Fifth, plan for change before you need it. Version the URL from the first release, add fields rather than renaming them, and never change the meaning of an existing field. When a breaking change is unavoidable, ship it under a new version prefix, keep the old version running for a published period, and announce the deprecation in response headers and documentation. Clients that integrate with your API will judge it far more on stability than on elegance.
Finally, measure. Request logging with method, path, status, and duration, using a fast structured logger such as pino, turns production issues from guesswork into searchable data.
Common Mistakes and How to Avoid Them
The most serious mistake is trusting request input. Every body, query parameter, and route parameter is attacker-controlled until validated.
- Returning 200 for everything. Clients and monitoring tools rely on status codes. Return 201 for creation, 204 for deletion, and proper 4xx codes for client errors.
- Leaking stack traces. Never send raw error messages or stacks to clients in production. Log them server-side and return a generic message.
- Allowing any origin with CORS. Configure
cors()with the specific origins that should call the API rather than a wildcard, especially when credentials are involved. - No rate limiting. Add middleware such as express-rate-limit, with stricter limits on login and other expensive routes.
- Unlimited request bodies. Set a size limit on
express.json()to block oversized payloads. - Hard-coded secrets. Load database URLs and keys from environment variables and keep them out of version control.
- Calling
listeninside the app module. This makes testing awkward. Export the app and start it from a separate entry file.
Implementation Checklist: Shipping a Products API
Here is the checklist I run through before a new Express API goes to production, using a products service as the example.
Structure. The repository contains src/app.js, src/server.js, src/routes/, src/services/, src/schemas/, and src/middleware/. Each resource has its own router and service.
Validation. Zod schemas cover create and update bodies, list query parameters, and ID formats. Update schemas use .partial() for PATCH semantics.
Authentication. A middleware verifies the bearer token on protected routes and attaches the user to req. Ownership checks happen in services, so a user cannot modify another user's records by changing an ID.
Security middleware. Helmet sets protective headers, CORS allows only the front-end origin, rate limiting applies globally with a stricter limit on authentication routes, and JSON bodies are capped.
Testing. Integration tests use Supertest against the exported app to cover success paths, validation failures, 404s, and authorization failures. Service functions have unit tests.
Operations. A GET /health endpoint returns 200 when the database is reachable. Structured logs include a request ID. The process runs under a manager such as PM2 or systemd, behind a reverse proxy that terminates HTTPS, and handles shutdown signals to finish in-flight requests.
Documentation. An OpenAPI file describes every endpoint, and the README lists required environment variables and setup commands. With these in place, a new developer can run the API locally in minutes.
Data layer. The service layer opens one database pool at startup and reuses it, list queries always apply a limit, and every filtered field used by a frequent endpoint has an index. Write operations that touch multiple records run inside a transaction where the database supports it, so a failure halfway through never leaves partial data behind.
Release. A continuous integration pipeline runs linting, type checks, and the test suite on every pull request, and deploys only from the main branch. After each deployment, a smoke test calls the health endpoint and one read endpoint, and the team watches error rates and latency for the first hour. Rolling back means redeploying the previous build, which is only painless if releases are small and frequent.
Key Takeaways
- RESTful design means noun-based resource URLs, meaningful HTTP methods, and consistent status codes on every endpoint.
- Express 5 forwards rejected promises from async handlers to error middleware, removing most manual try-catch blocks.
- Validate every body, query, and route parameter with a schema library before it reaches business logic.
- A single error response shape and pagination on every list endpoint prevent the most painful later refactors.
- Exporting the app separately from the listening server makes integration testing with Supertest straightforward.
Frequently Asked Questions
Is Express still a good choice for REST APIs?
Yes. Express remains one of the most widely used Node.js frameworks, with a huge middleware ecosystem and extensive documentation. Express 5 modernized promise handling and routing behavior. Alternatives such as Fastify and Hono offer performance or edge-runtime advantages, but Express is still a solid, well-understood choice for most APIs.
What database should I use with a Node.js and Express API?
Choose based on your data. PostgreSQL suits relational data with strict consistency, while MongoDB suits flexible, document-shaped data. Both have mature Node.js drivers and ORMs. Whatever you choose, create one shared client or pool at startup and reuse it across requests rather than connecting per request.
How do I validate requests in Express?
Use a schema validation library such as Zod, Joi, or express-validator. Define a schema for each request body and query, parse incoming data in the route or a reusable middleware, and return a 400 or 422 response with clear details when validation fails. Pass only the parsed data onward.
How should I handle errors in an Express API?
Register a single error-handling middleware with four arguments after all routes. In Express 5, errors thrown in async handlers reach it automatically. Log the full error server-side, map known error types to appropriate status codes, and return a consistent JSON body without exposing stack traces to clients.
How do I secure a Node.js REST API?
Validate all input, use Helmet for security headers, restrict CORS to known origins, rate-limit requests, cap body sizes, and authenticate with short-lived tokens. Enforce authorization in services so users can only access their own data. Keep secrets in environment variables and serve the API only over HTTPS.
Should I use TypeScript for an Express API?
TypeScript is recommended for anything beyond a small prototype. It catches mismatched request shapes, service return types, and refactoring errors before runtime. Combining TypeScript with Zod lets you infer types directly from validation schemas, so your runtime checks and compile-time types never drift apart.
Conclusion
The most important insight is that a good API is defined by its consistency: predictable URLs, correct status codes, one error shape, and validated input everywhere. Express gives you the routing and middleware; discipline in structure and validation gives you an API people trust. Once your API is tested and documented, follow our VPS deployment walkthrough, since its process manager, Nginx reverse proxy, and HTTPS setup apply equally to Express services.




