Building Express APIs teaches you fast. Some projects go smoothly. Some teach you what not to do at 3am when production’s on fire.
Here’s the distilled version — how to build an API that won’t embarrass you later.
The Skeleton
npm init -y
npm install express
import express from 'express';
const app = express();
app.use(express.json());
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.listen(3000, () => console.log('Running on 3000'));
That’s a working API. Everything else is just making it better. If you want to jump straight into calling real endpoints, the quickstart guide walks you through authentication and your first request.
Routes That Make Sense
REST has conventions. Follow them and your API becomes predictable.
// Resources are plural nouns
app.get('/users', getUsers); // List
app.get('/users/:id', getUser); // Read
app.post('/users', createUser); // Create
app.put('/users/:id', updateUser); // Replace
app.delete('/users/:id', deleteUser); // Delete
The HTTP method tells you what’s happening. The URL tells you what you’re working with. Don’t get creative here.
Nested Resources
When resources have parent-child relationships, nest the routes:
app.get('/users/:userId/orders', getUserOrders);
app.get('/users/:userId/orders/:orderId', getUserOrder);
app.post('/users/:userId/orders', createUserOrder);
But don’t go deeper than two levels. /users/:id/orders/:id/items/:id/variants is a nightmare to maintain and a nightmare to call. If you find yourself nesting three levels deep, flatten it. Give sub-resources their own top-level endpoint and use query parameters to filter.
API Versioning
Your API will change. Plan for it from day one.
The simplest approach is URL versioning:
import { Router } from 'express';
const v1 = Router();
v1.get('/users', getUsersV1);
const v2 = Router();
v2.get('/users', getUsersV2);
app.use('/api/v1', v1);
app.use('/api/v2', v2);
Header-based versioning (Accept: application/vnd.myapi.v2+json) is more “correct” according to REST purists, but URL versioning is easier for everyone to understand, easier to test in a browser, and easier to document. Pragmatism beats purity.
Middleware for Everything
Middleware is code that runs before your route handler. Authentication, logging, validation — it all goes here.
// Auth middleware
function requireAuth(req, res, next) {
const key = req.headers['x-api-key'];
if (!key || !isValidKey(key)) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.user = getUserFromKey(key);
next();
}
// Apply to routes that need it
app.get('/users', requireAuth, getUsers);
next() continues to the next middleware or route. Without it, the request hangs forever.
Middleware Ordering Matters
Express runs middleware in the order you register it. This seems obvious, but it bites people constantly.
// Correct: parsing happens before routes
app.use(express.json());
app.use(cors());
app.use(requestLogger);
app.use('/api', routes);
app.use(errorHandler); // Must be last
// Wrong: error handler registered before routes catches nothing
app.use(errorHandler);
app.use('/api', routes); // Errors here won't reach the handler above
The error handler goes last, always. Express identifies error-handling middleware by its four-parameter signature (err, req, res, next). If you forget the next parameter, Express treats it as regular middleware and your errors vanish into the void.
Request Logging
You need to know what’s hitting your API. In development, morgan gives you a quick win:
import morgan from 'morgan';
// Development: readable output
app.use(morgan('dev'));
// Production: structured JSON for log aggregation
app.use(morgan('combined'));
For production, consider structured logging with a library like pino or winston. Structured logs (JSON format) are searchable and parseable by log aggregation tools. Unstructured logs are just text you’ll squint at during an outage.
import pino from 'pino';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
// Middleware to log every request
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
method: req.method,
url: req.originalUrl,
status: res.statusCode,
duration: Date.now() - start
});
});
next();
});
Log the method, URL, status code, and duration at minimum. Add the requesting user’s ID once they’re authenticated. Skip logging health check endpoints — they just add noise.
Error Handling That Doesn’t Leak
Never expose stack traces to users. Never.
// Async wrapper (catches promise rejections)
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next);
app.get('/users/:id', wrap(async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) throw new NotFoundError('User not found');
res.json({ data: user });
}));
// Global error handler (must be last)
app.use((err, req, res, next) => {
console.error(err); // Log the real error
// Send safe response
const status = err.statusCode || 500;
const message = status === 500 ? 'Something went wrong' : err.message;
res.status(status).json({ error: message });
});
Log everything. Return nothing sensitive.
Custom Error Classes
A flat error handler works, but custom error classes make your code much more expressive:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404);
}
}
class ValidationError extends AppError {
constructor(message = 'Invalid input') {
super(message, 400);
}
}
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403);
}
}
Now your route handlers read like English:
app.get('/users/:id', wrap(async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) throw new NotFoundError();
if (user.organizationId !== req.user.orgId) throw new ForbiddenError();
res.json({ data: user });
}));
The error handler checks err.isOperational to distinguish between expected errors (bad input, missing resources) and genuine bugs (null reference, network failure). Expected errors get their message forwarded. Bugs get a generic “Something went wrong” and a loud alert to your monitoring system.
Response Format: Pick One, Stick With It
I use this structure for everything:
// Success
res.json({
status: 'ok',
data: { /* actual payload */ }
});
// Error
res.status(400).json({
status: 'error',
error: 'What went wrong'
});
Consistent structure means clients can parse responses without checking the status code first.
Pagination
Any endpoint that returns a list needs pagination. Full stop. Without it, your database query returns 50,000 rows and your server runs out of memory.
app.get('/users', wrap(async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 20, 100); // Cap at 100
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
db.users.find().skip(offset).limit(limit),
db.users.countDocuments()
]);
res.json({
status: 'ok',
data: users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
});
}));
Always cap the limit. If a client requests ?limit=1000000, that’s their bug, but it’s your server that falls over. A reasonable default (20) and a hard maximum (100) keeps things safe.
Validation
Never trust input. Ever.
function validateUser(req, res, next) {
const { email, name } = req.body;
if (!email?.includes('@')) {
return res.status(400).json({ error: 'Invalid email' });
}
if (!name || name.length < 2) {
return res.status(400).json({ error: 'Name too short' });
}
next();
}
app.post('/users', validateUser, createUser);
Validate early, fail fast. Don’t let bad data reach your database.
Validation Libraries
Hand-written validation works for simple cases. For anything complex, use a library. Joi and Zod are the popular choices.
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
age: z.number().int().min(13).optional(),
role: z.enum(['user', 'admin']).default('user')
});
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues
});
}
req.body = result.data; // Parsed and type-safe
next();
};
}
app.post('/users', validate(createUserSchema), createUser);
Zod has the advantage of TypeScript integration — your validated data is automatically typed. For serious email validation beyond format checking, you can verify deliverability with an email validation API to catch disposable addresses and typos before they hit your database.
Calling External APIs
Your API will probably call other APIs. Here’s how to do it cleanly:
async function validateEmail(email) {
const response = await fetch(
`https://api.apiverve.com/v1/emailvalidator?email=${encodeURIComponent(email)}`,
{ headers: { 'x-api-key': process.env.APIVERVE_KEY } }
);
if (!response.ok) throw new Error('Validation service unavailable');
return response.json();
}
// In your route
app.post('/register', wrap(async (req, res) => {
const validation = await validateEmail(req.body.email);
if (!validation.data.isValid) {
return res.status(400).json({ error: 'Invalid email address' });
}
// Continue registration...
}));
Abstract external calls into functions. Makes testing easier, keeps routes readable. This same pattern works for any API — whether you’re generating UUIDs for database records or validating phone numbers.
Handling External API Failures
External APIs go down. Networks fail. Timeouts happen. Your API shouldn’t crash when a dependency is unavailable.
async function validateEmailSafe(email) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(
`https://api.apiverve.com/v1/emailvalidator?email=${encodeURIComponent(email)}`,
{
headers: { 'x-api-key': process.env.APIVERVE_KEY },
signal: controller.signal
}
);
clearTimeout(timeout);
if (!response.ok) return { available: false, fallback: true };
return response.json();
} catch (err) {
// Service unavailable — degrade gracefully
logger.warn({ err, email }, 'Email validation service unavailable');
return { available: false, fallback: true };
}
}
Set timeouts on every external call. Five seconds is generous. If a third-party API hasn’t responded in five seconds, it’s not going to respond in a way that helps your user. Fail fast and either return a degraded response or fall back to a simpler validation. Understanding error handling patterns helps you build resilient integrations.
Environment Variables
Don’t hardcode secrets. Don’t commit them to git.
// At the top of your entry file
import 'dotenv/config';
const API_KEY = process.env.APIVERVE_KEY;
const PORT = process.env.PORT || 3000;
.env goes in .gitignore. Always.
Environment Configuration Pattern
For anything beyond a toy project, you need structured configuration:
// config.js
const config = {
port: parseInt(process.env.PORT) || 3000,
db: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
name: process.env.DB_NAME || 'myapp_dev',
},
api: {
key: process.env.APIVERVE_KEY,
timeout: parseInt(process.env.API_TIMEOUT) || 5000,
},
logging: {
level: process.env.LOG_LEVEL || 'info',
}
};
// Validate required config on startup
const required = ['api.key', 'db.host'];
for (const key of required) {
const value = key.split('.').reduce((obj, k) => obj?.[k], config);
if (!value) {
console.error(`Missing required config: ${key}`);
process.exit(1);
}
}
export default config;
Fail at startup if required configuration is missing. Finding out your database URL is undefined when the first request hits is much worse than a clear error on boot.
Rate Limiting
Protect yourself from abuse:
import rateLimit from 'express-rate-limit';
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: { error: 'Too many requests' }
}));
Aggressive rate limiting on auth endpoints. Gentler limits elsewhere.
Per-Route Rate Limits
Different endpoints need different limits. A login endpoint should be much more restrictive than a public data endpoint:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 attempts per 15 minutes
message: { error: 'Too many login attempts, try again later' }
});
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60, // 60 requests per minute
message: { error: 'Rate limit exceeded' }
});
app.use('/auth', authLimiter);
app.use('/api', apiLimiter);
Return a Retry-After header so well-behaved clients know when to try again. And always return a 429 status code — some rate limiting libraries return 200 with an error body, which is technically wrong and confuses HTTP-aware clients.
Status Codes That Mean Something
| Code | When to use |
|---|---|
| 200 | Success, returning data |
| 201 | Created something new |
| 204 | Success, nothing to return |
| 400 | Client sent bad data |
| 401 | Not authenticated |
| 403 | Authenticated but not allowed |
| 404 | Resource doesn’t exist |
| 429 | Rate limited |
| 500 | You broke something |
Get these right. Clients depend on them.
Health Check Endpoints
The /health endpoint in our skeleton isn’t just for show. Load balancers, container orchestrators, and monitoring tools all use it to determine if your service is alive.
A basic health check returns 200. A useful one checks dependencies:
app.get('/health', async (req, res) => {
const checks = {};
try {
await db.query('SELECT 1');
checks.database = 'ok';
} catch {
checks.database = 'failing';
}
try {
await redis.ping();
checks.cache = 'ok';
} catch {
checks.cache = 'failing';
}
const healthy = Object.values(checks).every(s => s === 'ok');
res.status(healthy ? 200 : 503).json({
status: healthy ? 'ok' : 'degraded',
checks,
uptime: process.uptime()
});
});
Return 503 if any critical dependency is down. Your load balancer will stop routing traffic to this instance and your users hit a healthy one instead. Don’t expose internal details (connection strings, hostnames) in the health response — just status.
Project Structure
Once you have more than 10 routes, organize:
src/
routes/
users.js
products.js
middleware/
auth.js
validate.js
services/
email.js
config.js
index.js
Routes define endpoints. Middleware handles cross-cutting concerns. Services wrap external dependencies.
Scaling the Structure
As your project grows, the flat structure gets unwieldy. Group by feature instead of by type:
src/
features/
users/
routes.js
controller.js
service.js
validation.js
orders/
routes.js
controller.js
service.js
validation.js
shared/
middleware/
auth.js
errorHandler.js
utils/
logger.js
config.js
app.js
server.js
Separate app.js (Express configuration, middleware, routes) from server.js (actually starting the server). This lets you import the app in tests without starting a listener.
// app.js
import express from 'express';
import { userRoutes } from './features/users/routes.js';
import { orderRoutes } from './features/orders/routes.js';
import { errorHandler } from './shared/middleware/errorHandler.js';
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
app.use('/api/orders', orderRoutes);
app.use(errorHandler);
export default app;
// server.js
import app from './app.js';
import config from './config.js';
app.listen(config.port, () => {
console.log(`Running on ${config.port}`);
});
Database Connection Patterns
Your database connection should be established once and shared, not created per request.
// db.js
import pg from 'pg';
const pool = new pg.Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
max: 20, // Max connections in pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => {
console.error('Unexpected database error', err);
process.exit(1);
});
export default pool;
Connection pooling is essential. Without it, every request opens and closes a database connection. That’s slow, wasteful, and will eventually exhaust your database’s connection limit under load. A pool of 20 connections can serve hundreds of concurrent requests.
Before You Deploy
- All secrets in environment variables
- Error handler catches everything
- Rate limiting configured
- Validation on all inputs
- Logging enabled
- Health check endpoint works
- Graceful shutdown handles SIGTERM
- Database connections are pooled
- External API calls have timeouts
- CORS configured for your frontend domain
That last one — graceful shutdown — is easy to forget and painful to skip:
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
pool.end();
process.exit(0);
});
// Force shutdown after 10 seconds
setTimeout(() => process.exit(1), 10000);
});
This lets in-flight requests finish before the process exits. Without it, deployments drop active requests, and your users see random 502 errors every time you ship code.
Keep Reading
- REST APIs: What They Are and How to Use Them
- Extract Clean Content from Any Webpage
- Take Your App Multilingual (No Rewrite)
That’s the foundation. It’s not fancy, but it works. The same patterns scale from side projects to production systems handling real traffic.
Need functionality without building it yourself? APIVerve has 350+ APIs you can drop into your Express app — same patterns, just call fetch instead of writing the logic.