I've built a lot of Express APIs. Some were good. Some taught me 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Project Structure
Once you have more than 10 routes, organize:
src/
routes/
users.js
products.js
middleware/
auth.js
validate.js
services/
email.js
index.js
Routes define endpoints. Middleware handles cross-cutting concerns. Services wrap external dependencies.
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
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 500+ APIs you can drop into your Express app — same patterns, just call fetch instead of writing the logic.
Originally published at APIVerve Blog
Top comments (0)