The Problem With Most Node/Express API Tutorials
Most Express tutorials are lying to you by omission. They show you how to wire up a route, return a JSON response, and call it a day. I’ve counted dozens of “build a REST API” posts that end with res.json({ message: 'hello world' }) and zero mention of what happens when a user’s token expires mid-session, or what error code a mobile app should receive when it’s offline and retrying a stale request. That’s not a tutorial ā that’s a syntax demo.
The thing that caught me off guard when I first started building APIs consumed by mobile clients specifically was how different the requirements are from browser-based frontends. Browsers handle a lot of failure gracefully. Mobile apps do not. A React web app will just reload. A React Native app hitting your API at 2am on spotty LTE will silently fail, corrupt local state, or drain the user’s battery retrying a 500 that you never bothered to make idempotent. Mobile clients need three things most tutorials never cover:
- Token refresh flows ā not just JWT generation, but what happens when the access token expires and the mobile app needs to swap a refresh token without logging the user out
- Compressed responses ā
gziporbrotlimiddleware isn’t optional when your users are on 3G; a 40KB JSON payload becomes 8KB with one line of config - Offline-friendly error codes ā there’s a real difference between a 503 (retry later) and a 422 (your data was wrong, don’t retry). Mobile apps need to branch on these. Most tutorial APIs just throw 500 at everything.
Here’s what we’re actually building: a Node/Express base that you can clone, rename a few environment variables, and have running in production. That means proper middleware stacking, a centralized error handler, route-level auth guards, request validation with zod, pagination on list endpoints, and response compression. Not a toy. Not a demo. The kind of thing you’d actually PR into a real codebase without your tech lead rejecting it immediately. I’ll show you every file, every config decision, and the specific spots where I’ve watched junior devs burn hours because the README doesn’t mention it.
5 Low-Code Platforms Iād Actually Trust for Healthcare Apps (After Building Real Ones)
One honest trade-off upfront: we’re using Express 4.x, not Fastify. Fastify is measurably faster ā benchmarks consistently show it handling roughly 2-3x more requests per second on equivalent hardware ā but Express wins on ecosystem familiarity, middleware compatibility, and the sheer volume of production war stories you can Google at midnight. If you’re building something that needs to handle sustained high-throughput with minimal infrastructure, Fastify is worth the learning curve. For the 95% of mobile backend use cases involving CRUD operations, auth, and some business logic, Express won’t be your bottleneck. Your database will.
For a complete list of tools that fit into a modern dev workflow ā including API clients, CI tools, and documentation generators ā check out our guide on Productivity Workflows. The rest of this guide is going to move fast and assume you know basic JavaScript. If you see a pattern you don’t recognize, that’s intentional ā look it up, because you’ll see it again in every serious Node codebase.
Project Setup ā From Zero to Running Server
Start With the Right Node ā Everything Else Depends on It
The OS-bundled Node version will betray you. Ubuntu 22.04 ships with Node 12 in some package mirrors. macOS with Homebrew might give you something newer, but probably not what you want for a production API. I’ve wasted hours debugging behavior differences that turned out to be version mismatches between my local environment and a teammate’s. Just use nvm from the start:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20 nvm use 20 nvm alias default 20
That last line matters ā nvm alias default 20 means every new terminal session uses Node 20, not whatever was installed before. Node 20 is the current LTS and gets security patches through April 2026. For a mobile backend that needs to stay up, you want LTS, not the latest features release. Also add a .nvmrc file with just 20 in the project root. Your teammates can then run nvm use with no arguments and land on the right version automatically.
ESM vs CommonJS ā Pick One and Commit
After npm init -y, you’ll see a fresh package.json with no "type" field, which defaults to CommonJS (require, module.exports). Here’s my honest take: for a new Express API targeting mobile clients in 2024, I’d stay with CommonJS. ESM ("type": "module") is cleaner and future-proof, but the Express ecosystem still has rough edges ā some middleware packages ship CommonJS-only, dynamic imports get awkward, and __dirname and __filename don’t exist out of the box in ESM (you have to reconstruct them from import.meta.url). If your team is already comfortable with ESM and you’re starting completely fresh, go for it. But if you’re reading a setup guide, you’re probably better served shipping something that works without fighting the toolchain. I’ll use CommonJS for the rest of this guide.
What You’re Actually Installing and Why
npm install express helmet cors morgan dotenv
- express ā the framework. No explanation needed, but do pin a version range like
^4.18.0rather than accepting whateverlatestresolves to. - helmet ā sets about a dozen security-relevant HTTP headers automatically. Without it, your API responds with headers that fingerprint your stack and leave XSS vectors open. One line:
app.use(helmet()). Mobile clients don’t care about those headers, but your pentest report will. - cors ā you need this the moment your mobile app’s web preview or admin dashboard runs on a different origin. Configure it explicitly, don’t just do
app.use(cors())with no options in production ā that allows every origin. - morgan ā HTTP request logger. Use
morgan('dev')locally andmorgan('combined')in production. The combined format gives you IP, user agent, and response time ā exactly what you’ll grep when a mobile user reports “the app is slow.” - dotenv ā loads
.envintoprocess.env. Callrequire('dotenv').config()at the very top of your entry file, before any other require. The thing that caught me off guard early on: dotenv silently does nothing if the.envfile is missing. It won’t throw. Add a startup check that validates required env vars are present.
npm install -D nodemon jest supertest
For dev dependencies: nodemon restarts the server on file changes, jest is your test runner, and supertest lets you make HTTP assertions against your Express app without actually binding to a port. The trick with nodemon is configuring it in package.json rather than a separate nodemon.json ā fewer files to manage:
{
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest --runInBand"
},
"nodemonConfig": {
"ignore": ["node_modules/", "tests/"],
"ext": "js,json",
"delay": 500
}
}
The delay: 500 stops nodemon from restarting mid-save when your editor writes the file in chunks. Without it you’ll see weird “module not found” crashes during fast edits.
Folder Structure That Won’t Embarrass You in Six Months
Every Express tutorial starts with everything in one file. That’s fine for a demo ā it’s a disaster for a mobile API with auth, multiple resource types, and a second developer. Here’s the structure I default to:
src/ config/ # db.js, env validation, third-party service init controllers/ # logic that handles request/response middleware/ # auth checks, error handlers, request validators models/ # data layer ā Mongoose schemas, SQL queries, whatever routes/ # just route definitions, imports controllers index.js # app bootstrap, nothing else
The rule: routes/ files should contain only router.get('/path', controller.method) calls. No logic. Controllers handle logic and call models. Middleware handles cross-cutting concerns like JWT verification. This sounds bureaucratic until you try to write a test for a route handler that’s also doing database calls and sending emails in the same function ā then you’ll understand why the separation exists. Your index.js should do exactly three things: load config, register middleware, mount routes, and start listening. If it’s longer than 50 lines, something belongs elsewhere.
Wiring Up Express the Right Way
The app.js / server.js Split You’ll Regret Skipping
I learned this the hard way after writing a 400-line server.js that I could never properly test without spinning up a live HTTP server. The fix is a two-file split that takes five minutes to set up and pays dividends forever. app.js builds and exports your Express app ā all middleware, routes, error handlers. server.js just imports that app and calls app.listen(). That’s it. Now your test suite can import the app directly without binding to a port, which means faster tests, no port conflicts in CI, and clean separation between “what my app does” and “how it runs.”
// app.js
const express = require('express');
const app = express();
// all your middleware and routes go here
module.exports = app;
// server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Middleware Order Is Load-Bearing
The docs don’t make this obvious enough: middleware runs in the order you register it, and getting that order wrong causes real bugs. I wire it up like this every time ā helmet first, then cors, then body parsers. Helmet sets security headers before anything else touches the response. CORS has to come before your routes fire, otherwise preflight OPTIONS requests from your mobile clients will 404 or hit your route handlers and confuse everyone. Body parser needs to run before any route that reads req.body, which sounds obvious until you spend 45 minutes wondering why your POST body is undefined.
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const express = require('express');
const app = express();
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
CORS for Mobile Clients Isn’t What You Think
Here’s the thing that caught me off guard the first time I built for mobile: native iOS and Android apps don’t send an Origin header the way browsers do. So if your only clients are native mobile apps, CORS basically doesn’t matter ā the browser sandbox doesn’t apply. Where it does matter is when your API is also consumed by a web app, a React Native WebView, or third-party integrations. In those cases you want explicit origin whitelisting, not origin: true or origin: '*' which lets anything through.
The pattern I use in production keeps origins in an environment variable as a comma-separated string:
# .env
ALLOWED_ORIGINS=https://app.yourdomain.com,https://staging.yourdomain.com
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(','),
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
If you need wildcard subdomain matching ā say, for feature branch preview URLs ā you can swap the array for a function that tests the origin against a regex. But start with the explicit list. You can always loosen it; tightening it after the fact means hunting down every client that’s been abusing the open policy.
Morgan Logging: Two Formats, Two Environments
The dev format is colored, compact, and perfect for watching requests scroll by in your terminal locally. The combined format is Apache-style, verbose, and what you want in production because log aggregators like Datadog, Logtail, and CloudWatch expect it. Switching between them based on NODE_ENV is a one-liner ā you already saw it in the snippet above. One extra thing: in production, pipe morgan’s output to a proper log stream instead of stdout if you’re running on anything that rotates logs, or you’ll lose data during deploys.
The Centralized Error Handler That Stops Stack Traces Leaking to Mobile Clients
Every Express app needs exactly one error handler, it must have four parameters (err, req, res, next ā Express uses the arity to identify it as an error handler), and it must be the last thing you register before exporting the app. I’ve seen apps where developers put error handling inline in every route, which means inconsistent error shapes hitting the mobile client and stack traces showing up in production responses. Both are bad. The centralized handler gives you one place to sanitize what gets sent back:
// routes go here, then:
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
// Never send stack traces to clients in production
const response = {
status: 'error',
message: process.env.NODE_ENV === 'production'
? 'Something went wrong'
: err.message,
};
if (process.env.NODE_ENV !== 'production') {
response.stack = err.stack;
}
// Log the full error server-side regardless of environment
console.error(err);
res.status(statusCode).json(response);
});
The mobile client always gets a clean JSON shape it can handle. Your server logs get the full stack trace. And you’re not writing this same try/catch block in 30 different route handlers. When you call next(err) from anywhere in your middleware chain, it skips all the normal middleware and lands here. That’s the behavior you want.
Your First Real Endpoint ā Users Resource
Router Files, Controller Separation, and Why Your Response Shape Will Make or Break the Mobile Team’s Trust
The most expensive mistake I see junior devs make isn’t a security hole or a performance problem ā it’s inconsistent API response shapes. The mobile developer (or you, in two weeks, building the iOS client) will hit three endpoints and get three different JSON structures back. One returns { data: [...] }, one returns { users: [...] }, and the error response from the fourth is just a plain string. Chaos. Pick a shape on day one and tattoo it to every endpoint. I use this envelope and never deviate:
// Success
{
"success": true,
"data": { ... }, // object or array depending on endpoint
"message": "User created"
}
// Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required",
"details": [] // optional field-level errors
}
}
That error.code field is the one thing I wish someone had told me early. Mobile apps don’t just display HTTP status codes to users ā they branch logic on them. A 409 CONFLICT with a vague message forces the mobile dev to guess. A 409 plus "code": "EMAIL_ALREADY_EXISTS" lets them show “that email is already registered, want to log in instead?” in two lines of Swift. That’s the difference between an API that’s a pleasure to integrate and one that generates Slack threads at 11pm.
Setting Up routes/users.js the Right Way
Create the file structure first:
mkdir -p src/routes src/controllers touch src/routes/users.js src/controllers/usersController.js
Your route file should be nearly brainless. Its only job is mapping HTTP verbs and paths to controller functions. No business logic, no database calls, no if statements. If your route file is longer than 30 lines, something crept in that shouldn’t be there.
// src/routes/users.js
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');
router.get('/', usersController.getAllUsers);
router.get('/:id', usersController.getUserById);
router.post('/', usersController.createUser);
router.put('/:id', usersController.updateUser);
router.delete('/:id', usersController.deleteUser);
module.exports = router;
Then in app.js (or wherever you bootstrap Express):
const usersRouter = require('./routes/users');
app.use('/api/v1/users', usersRouter);
The /api/v1/ prefix matters even if you think you’ll never need v2. I’ve had to version APIs twice now after shipping without it, and retrofitting versioning into a live mobile app that can’t force-update its userbase is genuinely painful. Start with the prefix.
The Controller ā Where the Real Work Lives
Here’s a full usersController.js with the response shape enforced and the status codes handled deliberately:
// src/controllers/usersController.js
const getAllUsers = async (req, res) => {
try {
// Replace with your DB call
const users = await UserModel.findAll();
return res.status(200).json({
success: true,
data: users,
message: 'Users retrieved'
});
} catch (err) {
return res.status(500).json({
success: false,
error: { code: 'SERVER_ERROR', message: 'Something went wrong' }
});
}
};
const createUser = async (req, res) => {
const { email, name, password } = req.body;
if (!email || !name || !password) {
return res.status(422).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Missing required fields',
details: [
!email && 'email is required',
!name && 'name is required',
!password && 'password is required'
].filter(Boolean)
}
});
}
try {
const existing = await UserModel.findByEmail(email);
if (existing) {
return res.status(409).json({
success: false,
error: { code: 'EMAIL_ALREADY_EXISTS', message: 'That email is taken' }
});
}
const user = await UserModel.create({ email, name, password });
return res.status(201).json({
success: true,
data: user,
message: 'User created'
});
} catch (err) {
return res.status(500).json({
success: false,
error: { code: 'SERVER_ERROR', message: 'Something went wrong' }
});
}
};
module.exports = { getAllUsers, createUser /*, updateUser, etc */ };
Notice I’m using 422 Unprocessable Entity for validation failures and 400 Bad Request for malformed requests (like non-JSON body). The distinction matters: 400 means “I can’t even parse this,” 422 means “I parsed it, the structure is valid, but the values don’t pass validation.” Mobile clients can handle these differently ā 422 with details is how you power inline field-level error messages in a form. Also: 201 on creation, not 200. Some mobile HTTP clients distinguish them, and it’s correct HTTP semantics. For the full status code breakdown: 200 GET/PUT success, 201 POST success, 400 bad syntax, 401 not authenticated, 403 authenticated but not allowed, 404 resource not found, 409 conflict (duplicate), 422 validation failed, 429 rate limited, 500 your fault.
Testing with Postman ā Do This Before You Write Another Line
The thing that caught me off guard early was how much time I wasted re-typing the base URL every time the server moved from local to staging. Environment variables in Postman solve this completely and take four minutes to set up. Here’s the workflow I use:
- Open Postman ā click Environments (left sidebar) ā New Environment ā name it “Local Dev”
- Add a variable: key
baseUrl, initial valuehttp://localhost:3000/api/v1, current value same - Add a second environment called “Staging” with the staging URL
- In every request, use
{{baseUrl}}/usersinstead of hardcoding the URL - Create a Collection called “Users API” ā right-click ā Add Folder for each resource
- Export the collection as JSON and commit it to
/postman/users-api.postman_collection.jsonin your repo
That last step ā committing the Postman collection ā is something most teams skip and regret. When a new dev or mobile engineer joins, they import the collection, switch the environment variable to point at their local server, and every endpoint is already there with example request bodies. No “how do I call the create user endpoint?” messages. I also add a POST /api/v1/auth/login request with a Postman test script that auto-extracts the JWT and saves it to {{authToken}} ā then every other request uses Authorization: Bearer {{authToken}} in its headers. The whole auth dance becomes one click.
Adding JWT Authentication ā The Part Everyone Gets Wrong
Most JWT tutorials show you the happy path ā sign a token, verify a token, done. Mobile apps live in a messier world: background refreshes at odd hours, spotty connections, sessions that need to survive weeks without re-login. If you set up JWT the way most blog posts describe, you’ll spend the next month debugging “why did all my users get logged out?” The answer is almost always refresh tokens stored in memory.
Install the Right Packages (This Matters More Than You Think)
Run this:
npm install jsonwebtoken bcryptjs
Notice it’s bcryptjs, not bcrypt. I learned this the hard way. The bcrypt package uses native C++ bindings that compile fine on your Mac but fail silently or outright explode on Alpine Linux ā which is what most Docker-based deployments use because of the smaller image size. bcryptjs is pure JavaScript, slightly slower on benchmarks, but it works everywhere without needing build tools in your Docker image. The performance difference is negligible at the scale you’re operating at unless you’re hashing thousands of passwords per second, which you’re not.
Access Tokens + Refresh Tokens ā Why Mobile Specifically Needs Both
Short answer: mobile apps aren’t browsers. They can’t rely on HTTP-only cookies the same way, and users expect to stay logged in for weeks. Here’s the pattern that actually works:
- Access token: short-lived (15 minutes), signed JWT, sent in every API request header. If it leaks, the damage window is tiny.
- Refresh token: long-lived (30 days), stored in the device’s secure storage (iOS Keychain, Android Keystore), only sent to one endpoint to get a new access token.
The mobile client stores the refresh token in secure storage on first login. Every API call uses the access token. When the access token expires, the mobile app silently hits POST /auth/refresh with the refresh token and gets a new access token ā the user never sees a login screen. This is the pattern iOS and Android apps use in production and it’s the only approach that gives you both security and a good UX.
The Auth Middleware That Actually Works
Create middleware/authenticate.js:
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer "
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
req.user = decoded;
next();
} catch (err) {
// THIS IS THE PART EVERYONE GETS WRONG
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
};
That code: 'TOKEN_EXPIRED' field is not decoration. Your mobile client needs to distinguish between “token expired, go refresh” and “token invalid, force logout.” Without that distinction, you’ll either log users out on every expiry or never force-logout compromised sessions. The mobile team will thank you for this specific error code ā it’s what triggers their silent refresh logic.
Storing Refresh Tokens: You Cannot Use In-Memory
I’ve seen this mistake in production code. Someone stores refresh tokens in a JavaScript Map or a plain object on the server. Everything works perfectly in development. Then the server restarts ā a deploy, a crash, a scaling event ā and every single logged-in mobile user gets a 401 on their next refresh attempt. Every one of them hits your support inbox at once.
Store refresh tokens in a database table or Redis. A simple database approach:
-- PostgreSQL
CREATE TABLE refresh_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Redis works too and is faster for lookups, but adds another dependency. For most apps starting out, a Postgres table is simpler and good enough. The key thing: when a refresh token is used, delete it and issue a new one (rotation). This way a stolen refresh token can only be used once before it’s invalidated.
The Token Refresh Endpoint ā What Mobile Clients Hit at 3am
// routes/auth.js
router.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
// Verify the refresh token signature first
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// Then check it exists in the DB (not revoked)
const storedToken = await db.query(
'SELECT * FROM refresh_tokens WHERE token = $1 AND expires_at > NOW()',
[refreshToken]
);
if (!storedToken.rows.length) {
return res.status(401).json({ error: 'Refresh token revoked or expired' });
}
// Rotate: delete old, issue new
await db.query('DELETE FROM refresh_tokens WHERE token = $1', [refreshToken]);
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
const newRefreshToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '30d' }
);
await db.query(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'30 days\')',
[decoded.userId, newRefreshToken]
);
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
} catch (err) {
// jwt.verify throws here on expiry ā return 401 not 500
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
The catch block at the bottom is critical. jwt.verify() throws a JsonWebTokenError or TokenExpiredError ā it does not return null, it does not return false, it throws. If you don’t wrap this in a try/catch, Express’s default error handler returns a 500. Your mobile app interprets a 500 as a server error and probably retries, hammering your endpoint. A 401 tells the client to stop retrying and send the user back to the login screen. That distinction is the difference between a graceful session expiry and a DDoS on your own API.
Input Validation ā Don’t Trust Mobile Clients
Mobile clients will send you garbage. Not maliciously ā just because network conditions truncate payloads, frontend validation gets skipped, someone builds a workaround script, or a junior dev on the mobile team forgets a null check. Your API needs to treat every incoming request as potentially malformed, and the validation layer is your first real line of defense before anything touches your business logic or database.
express-validator vs zod ā Pick Your Philosophy First
Install express-validator with npm install express-validator if you want validation to live close to your route definitions. Install zod with npm install zod if you want a single source of truth schema that you can reuse across validation, TypeScript types, and even documentation. I switched to zod on a recent project because I was maintaining the same field constraints in three places ā the validator, the TypeScript interface, and a Swagger doc. With zod you define it once and derive everything else from it.
The honest tradeoff: express-validator’s middleware chain style integrates more naturally with Express idioms and the docs are solid. Zod requires a thin adapter layer (look at zod-express-middleware or write your own 10-line wrapper) but the schema-first approach pays off fast when your API grows. If you’re building something small and moving quickly, express-validator is fine. If you’re building a real API that’ll be versioned and maintained, zod’s inference is worth the setup cost.
Middleware Chaining on Routes vs Validation Inside Controllers
Here’s the decision I see junior devs get wrong constantly. You can put validation middleware directly on the route:
import { body, validationResult } from 'express-validator';
router.post(
'/users',
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
body('username').trim().escape().notEmpty(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
// safe to proceed
}
);
Or you can do the validation check inside the controller. Route-level chaining wins for discoverability ā anyone reading the router file immediately sees what constraints exist on each endpoint. The downside is your route files get verbose fast if you have 10+ fields. My approach: put the validator chain in a separate file per resource (validators/userValidator.js), import it, and spread it into the route. Best of both worlds.
Return Errors in a Consistent Array Format ā Every Time
Mobile developers building UI need field-level error mapping. If you return { message: "Validation failed" }, they have to guess which field broke and show a generic error toast. That’s a bad user experience and it means more back-and-forth between your teams. Instead, always return this shape:
// What your mobile client should always get on 422
{
"errors": [
{ "field": "email", "message": "Must be a valid email address" },
{ "field": "password", "message": "Must be at least 8 characters" }
]
}
With express-validator, errors.array() gives you close to this but the field key is called path in v7+. Map it explicitly so your mobile team isn’t parsing inconsistent keys across versions. Agree on this contract early and document it ā a five-minute conversation saves days of debugging on the mobile side.
Sanitization Before the DB ā Don’t Skip This
Validation tells you whether data is acceptable. Sanitization strips the danger out of data you’ve decided to accept. The two functions you’ll use constantly are .trim() to kill leading/trailing whitespace (users copy-paste emails with trailing spaces more than you’d think) and .escape() to HTML-encode characters like <, >, and & that could cause XSS if you ever render user input anywhere. Even if you’re not rendering HTML now, sanitize anyway ā you don’t know what your data ends up in later.
body('username').trim().escape().notEmpty().withMessage('Username is required'),
body('bio').trim().escape().isLength({ max: 300 }),
One thing that caught me off guard: .escape() mutates req.body in place when using express-validator. So by the time you call validationResult(req) and pass data to your controller, the sanitized version is already what you’re working with. That’s actually the behavior you want ā just be aware it’s happening implicitly.
Rate Limiting ā Set It Before You Need It
Install with npm install express-rate-limit. Set it up globally and override per-route where needed:
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: {
errors: [{ field: null, message: 'Too many requests, please try again later.' }]
}
});
// Apply globally
app.use('/api/', apiLimiter);
// Tighter limit on auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: {
errors: [{ field: null, message: 'Too many login attempts.' }]
}
});
app.use('/api/auth/', authLimiter);
100 requests per 15 minutes is a reasonable baseline for general mobile API endpoints. Drop it to 10 per 15 minutes on login, password reset, and anything that sends SMS or email ā those endpoints are credential stuffing targets and also cost you money per hit. One important gotcha: if you’re running behind a reverse proxy like Nginx or deploying to Railway/Render, set app.set('trust proxy', 1) before your rate limiter config. Otherwise req.ip will always be your proxy’s IP and every user will share the same rate limit bucket, which completely defeats the point.
Pagination, Filtering, and the Stuff Mobile Actually Needs
Offset vs Cursor Pagination: Pick the Right One Early
I’ve rebuilt pagination logic mid-project because I picked the wrong strategy upfront, and it’s not fun. Offset pagination (LIMIT 20 OFFSET 40) is dead simple and works fine when your dataset is small and users navigate by page number. But mobile apps usually have infinite scroll, and infinite scroll kills offset pagination. The problem: if a new record gets inserted between page 1 and page 2 fetches, the user either sees a duplicate or skips a record. On a feed that updates frequently, this happens constantly. Cursor-based pagination anchors each request to a specific record ā typically the last item’s ID or timestamp ā so inserts and deletes between requests don’t corrupt the sequence.
Start with offset if you’re prototyping or the dataset is genuinely static. Switch to cursor if you have infinite scroll, real-time data, or records that get deleted. Here’s what cursor pagination looks like in practice:
// Offset approach
const { page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
const items = await db.find().skip(offset).limit(limit);
// Cursor approach ā much safer for mobile feeds
const { cursor, limit = 20 } = req.query;
const query = cursor ? { _id: { $lt: cursor } } : {};
const items = await db.find(query).sort({ _id: -1 }).limit(parseInt(limit));
const nextCursor = items.length === limit ? items[items.length - 1]._id : null;
Parse Query Params Once, Not Everywhere
The thing that caught me off guard early on was how scattered query parsing gets when every route handler does its own thing. Someone writes parseInt(req.query.limit) in one place and forgets to sanitize in another, and suddenly a client sends ?limit=99999 and you’re pulling your entire database. Write a shared helper once and import it everywhere:
// helpers/parsePagination.js
export function parsePagination(query) {
const page = Math.max(1, parseInt(query.page) || 1);
const limit = Math.min(100, Math.max(1, parseInt(query.limit) || 20));
const sort = ['createdAt', 'updatedAt', 'name'].includes(query.sort)
? query.sort
: 'createdAt';
const order = query.order === 'asc' ? 1 : -1;
return { page, limit, sort, order, offset: (page - 1) * limit };
}
That whitelist on sort matters. If you interpolate req.query.sort directly into a MongoDB sort or an SQL ORDER BY, you’ve just handed someone a free injection vector. Always validate against an allowed list.
Give Mobile a Consistent Response Envelope
Mobile clients are parsing JSON with strongly typed models. If your pagination metadata shows up inconsistently ā sometimes totalCount, sometimes total, sometimes missing entirely ā the app developer has to write defensive code on every single screen. Standardize on one envelope format and never deviate:
res.json({
data: items,
meta: {
total: totalCount,
page: parsedPage,
limit: parsedLimit,
hasNext: parsedPage * parsedLimit < totalCount,
hasPrev: parsedPage > 1
}
});
hasNext is the one that matters most for mobile ā it tells the app whether to show a “load more” trigger or hide it. Don’t make the client do the math. total is useful for showing “1,240 results” in a search UI but less critical for feed-style interfaces. Some teams skip total entirely on cursor-based endpoints since getting an accurate count on large tables is expensive (a full COUNT(*) on 10 million rows in Postgres without a cached estimate is noticeably slow).
Compression Is One Line and You’re Ignoring It
I checked response sizes on an API I took over once ā no compression, JSON payloads averaging 40KB per response. After adding the compression middleware, the same responses were 6ā8KB. That’s not a rounding error on a mobile connection. Install it:
npm install compression
import compression from 'compression';
app.use(compression({
level: 6, // 1-9, 6 is the sweet spot between speed and ratio
threshold: 1024 // don't bother compressing responses under 1KB
}));
Put it near the top of your middleware stack, before your routes. The threshold option is worth setting ā compressing tiny 200-byte responses adds CPU overhead for essentially no gain. The level: 6 default is fine; bumping to 9 barely improves compression ratio but noticeably increases CPU usage under load.
ETags and Last-Modified: The Caching Layer Everyone Skips
Most tutorials don’t cover conditional requests, but they’re genuinely valuable for mobile. The scenario: a user opens your app, data loads. They minimize it, come back 3 minutes later. Without conditional caching, the app re-fetches everything even if nothing changed. With ETags, the server returns a 304 Not Modified with zero body ā the client uses its cached version and you save both bandwidth and server processing.
Express doesn’t enable ETags by default for dynamic routes, but you can add them manually where it makes sense:
import etag from 'etag';
app.get('/api/products', async (req, res) => {
const products = await getProducts();
const body = JSON.stringify(products);
const etagValue = etag(body);
if (req.headers['if-none-match'] === etagValue) {
return res.status(304).end();
}
res.setHeader('ETag', etagValue);
res.setHeader('Cache-Control', 'private, max-age=60');
res.json(products);
});
Use Last-Modified when your data has a meaningful timestamp ā product catalog updates, user profile changes. Use ETags when you don’t have a reliable timestamp or the data is computed. Don’t apply this to every endpoint blindly; it adds overhead. Apply it to list endpoints that get hammered repeatedly with identical requests ā product listings, configuration data, reference tables. That’s where you’ll actually see the payoff.
Error Handling That Doesn’t Embarrass You in Production
Most Node.js error handling tutorials show you a try/catch block and call it a day. That approach breaks down fast ā you end up with inconsistent error shapes, stack traces leaking to mobile clients, and silent crashes that only surface when a user complains. I’ve shipped APIs that powered iOS and Android apps, and the error infrastructure below is what I settled on after getting burned enough times.
Start With a Custom AppError Class
The core idea is separating errors you expected from errors that mean your code is broken. I use a simple class for this:
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // This is a known, expected failure
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
The isOperational flag is doing real work here. When you throw new AppError('User not found', 404), you’re explicitly saying: “this is a situation I anticipated, the message is safe to send to the client.” Anything without that flag ā a TypeError, a ReferenceError, a failed database connection ā is a programmer error or infrastructure failure. Those two categories need completely different responses.
The Global Error Handler That Actually Handles Both Cases
Wire up a four-parameter Express middleware at the bottom of your app, after all routes:
// middleware/errorHandler.js
const AppError = require('../errors/AppError');
module.exports = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
if (process.env.NODE_ENV === 'production') {
if (err.isOperational) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message,
});
}
// Programmer error ā log it, don't expose internals
console.error('FATAL:', err);
return res.status(500).json({
status: 'error',
message: 'Something went wrong. Please try again.',
});
}
// Development: send everything for debugging
res.status(err.statusCode).json({
status: 'error',
message: err.message,
stack: err.stack,
error: err,
});
};
The NODE_ENV check is non-negotiable for mobile APIs. Android and iOS apps sometimes log API responses to crash reporters like Firebase Crashlytics or Sentry. If you’re sending full stack traces ā including file paths and dependency versions ā you’re handing an attacker a map of your server. Never do it. The development branch exists so you still get full context locally without compromising production.
Stop Writing try/catch in Every Controller
This is the change that cleaned up my codebase the most. Instead of wrapping every async controller in a try/catch, write this once:
// utils/asyncHandler.js
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
Then use it everywhere:
// routes/users.js
const asyncHandler = require('../utils/asyncHandler');
const AppError = require('../errors/AppError');
router.get('/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new AppError('User not found', 404);
res.json({ status: 'success', data: user });
}));
When you throw inside an async function wrapped with asyncHandler, the rejected promise gets caught and passed directly to next, which routes it straight to your global error handler. No try/catch clutter, consistent error flow. The thing that caught me off guard early on was forgetting to wrap a route and wondering why errors were silently swallowing ā Express doesn’t automatically forward async rejections in older versions, so the wrapper isn’t optional.
Handle the Crashes You Can’t Predict
Put these two handlers in your server.js, before you call app.listen. Without them, certain failures just vanish with no log output:
// server.js
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Give the server time to finish current requests, then crash
server.close(() => process.exit(1));
});
process.on('uncaughtException', (err) => {
console.error('UNCAUGHT EXCEPTION:', err);
process.exit(1); // Crash immediately ā state is unpredictable
});
The distinction matters: uncaughtException means something synchronous threw that nobody caught ā at that point your process state is potentially corrupt, so crash immediately. unhandledRejection is a Promise that rejected with no .catch ā slightly safer, so you can do a graceful shutdown. I use a process manager like PM2 in production (pm2 start server.js), which automatically restarts on exit. That means crashing on unrecoverable errors is actually the right call ā you come back clean instead of limping along in a broken state.
- Use
AppErrorfor anything expected: auth failures, missing records, bad input, rate limits - Let native errors bubble up unmodified ā they’ll hit the 500 branch and get logged
- Your mobile client should always get a consistent JSON shape:
{ status, message }, never an HTML error page or a stack trace - Test your error handler explicitly ā hit a route with a bad ID and verify the response body looks exactly like what your iOS/Android code expects to parse
Environment Config and Secrets
The single most common mistake I see in early-stage mobile API projects is secrets living in source code. Not in a malicious way ā someone hardcodes a JWT_SECRET just to test something, commits it, and two months later that repo is public. Game over. Set up your .env file and .gitignore on literally the first commit, before you write a single route. Not after you “get the basic structure working.” Day one, first thing.
# .gitignore ā add this before your first commit .env .env.local .env.*.local
Install dotenv and create your .env file at the project root. Here’s the full set of variables a mobile API actually needs ā not a watered-down hello-world list:
PORT=3000 NODE_ENV=development DATABASE_URL=postgresql://user:password@localhost:5432/myapp JWT_SECRET=your_long_random_secret_here JWT_REFRESH_SECRET=another_long_random_secret_here ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8081
For JWT_SECRET and JWT_REFRESH_SECRET, don’t make something up. Generate them properly:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Run that twice ā once per secret. 32 random bytes gives you 64 hex characters, which is 256 bits of entropy. Anything shorter and you’re leaving yourself open to brute-force attacks against your tokens. I’ve seen production APIs running with a JWT_SECRET of "secret". That’s not a secret, that’s a liability.
The Config Module That Fails Fast
Don’t scatter process.env.WHATEVER calls across your codebase. Create a single config/index.js that reads everything at startup and throws immediately if something’s missing. The alternative is a mysterious crash at 2am when a feature that touches an unchecked env var finally gets hit in production. Fail fast beats debugging a zombie process.
// config/index.js
require('dotenv').config();
function required(key) {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
module.exports = {
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
databaseUrl: required('DATABASE_URL'),
jwt: {
secret: required('JWT_SECRET'),
refreshSecret: required('JWT_REFRESH_SECRET'),
},
allowedOrigins: required('ALLOWED_ORIGINS').split(','),
};
Import this at the very top of your entry point ā server.js or app.js ā before anything else loads. If a required variable is missing, the process exits with a clear error message instead of starting up and failing unpredictably later. The thing that caught me off guard the first time I skipped this: a missing DATABASE_URL on a fresh deployment didn’t crash the app ā it crashed the first request that tried to query the database, which was a login endpoint, which looked like a completely unrelated auth bug. Twenty minutes of log-diving for a one-line fix.
Separate JWT_SECRET from JWT_REFRESH_SECRET
Mobile apps use refresh tokens. If you use the same secret for both access and refresh tokens, a compromised access token secret also compromises your long-lived refresh tokens ā and those typically live for 30 to 90 days. Keeping them separate means a rotation of one doesn’t require rotating the other. Generate both independently, store both in your secrets manager (AWS Secrets Manager, Doppler, whatever you use in production), and never let them share a value even in development.
One more practical note: commit a .env.example file with all the variable names but no real values. Every new developer cloning the repo immediately knows exactly what they need to configure, and you don’t have to maintain a separate onboarding doc for it.
# .env.example ā safe to commit PORT=3000 NODE_ENV=development DATABASE_URL= JWT_SECRET= JWT_REFRESH_SECRET= ALLOWED_ORIGINS=
Basic Testing Setup With Jest and Supertest
Why Supertest Is the Right Tool for This Job
The reason I reach for Supertest over anything else is one specific behavior: it calls app.listen() internally on an ephemeral port, meaning your Express app never actually binds to a real port during tests. No port conflicts in CI. No EADDRINUSE errors when you accidentally leave a process running. No need to manage teardown logic to close servers between test files. That one design decision eliminates an entire category of flaky test infrastructure problems I used to waste hours debugging.
The thing that caught me off guard when I first set this up: you need to export your Express app without calling app.listen() in your app.js. Move the listen call to a separate server.js entry point. If you don’t do this, Supertest still works, but you’ll see the server binding to a port on every test run and your CI logs get noisy. Here’s the split I use:
// app.js ā just the Express setup
const express = require('express');
const app = express();
app.use(express.json());
app.use('/auth', require('./routes/auth'));
app.use('/users', require('./routes/users'));
module.exports = app;
// server.js ā the actual entry point
const app = require('./app');
app.listen(3000, () => console.log('Running on 3000'));
Test File Structure That Doesn’t Make You Cry in 6 Months
Mirror your route structure in __tests__/. If you have routes/users.js and routes/auth.js, create __tests__/users.test.js and __tests__/auth.test.js. This sounds obvious but I’ve seen codebases with a single api.test.js file containing 800 lines. When one test fails at 3am you do not want to grep through that. Here’s a skeleton for your auth tests:
// __tests__/auth.test.js
const request = require('supertest');
const app = require('../app');
describe('POST /auth/login', () => {
it('returns 200 and a token on valid credentials', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email: '[email protected]', password: 'correctpassword' });
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('token');
});
it('returns 401 on wrong password', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email: '[email protected]', password: 'wrongpassword' });
expect(res.statusCode).toBe(401);
expect(res.body.message).toMatch(/invalid credentials/i);
});
it('returns 422 when fields are missing', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email: '[email protected]' }); // no password
expect(res.statusCode).toBe(422);
expect(res.body.errors).toBeDefined();
});
});
That 422 vs 400 distinction matters for mobile clients. A 400 says “bad request.” A 422 says “we understood the structure but the content fails validation.” Your mobile app can handle these differently ā show a generic error on 400, show specific field errors on 422. Build this distinction into your API from day one, not as an afterthought.
Mocking DB Calls vs. a Real Test Database ā Honest Take
I’ll give you both sides without pretending one is universally correct. Mocking with Jest’s jest.mock() is fast, zero-dependency, and works offline. The gotcha is that you’re testing your own mock, not your actual database queries. I’ve had bugs slip through because the mock returned { id: 1 } but the real DB was returning { _id: ObjectId('...') }. Your tests pass, production breaks. That’s the worst possible outcome from a testing suite.
A real test database ā either SQLite in-memory for SQL setups, or a local MongoDB instance via mongodb-memory-server ā actually exercises your queries. I switched our team to mongodb-memory-server and the setup cost was about two hours, but it’s caught real bugs every week since. The tradeoff: tests run slower (we went from ~800ms to ~4 seconds for the auth suite) and you need a seed/teardown strategy per test file. My honest recommendation: mock external services (Stripe, SendGrid, Twilio), use a real in-memory database for your own data layer. Don’t mock what you own.
# Install what you need
npm install --save-dev jest supertest mongodb-memory-server
# package.json scripts
{
"scripts": {
"test": "jest --detectOpenHandles",
"test:coverage": "jest --coverage --detectOpenHandles"
},
"jest": {
"testEnvironment": "node",
"globalSetup": "./tests/setup.js",
"globalTeardown": "./tests/teardown.js"
}
}
The --detectOpenHandles flag is not optional. Without it, Jest will hang after tests complete if you have any unclosed DB connections or timers. You won’t see the error, the process just hangs ā and your CI job times out after 10 minutes. I lost an afternoon to this. Add the flag, force the issue to surface.
Coverage Reports: What the Numbers Actually Tell You
Run npm run test:coverage and Jest generates a coverage report in /coverage/lcov-report/index.html. Open it. Don’t just look at the percentage ā look at which branches are uncovered. A 90% line coverage number can hide the fact that your error handling paths are completely untested. For a mobile API, the paths that matter most are: failed auth, validation errors, database timeouts, and rate limiting. These are exactly the things that will break your mobile app in the field. If those branches are red in your coverage report, you don’t actually have a tested API ā you have a tested happy path.
I set a coverage threshold directly in jest.config.js to fail the build if we drop below our baseline:
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 75,
functions: 80,
lines: 85,
statements: 85
}
}
};
75% branch coverage sounds low but branches are hard ā every if/else and try/catch counts. Start there and raise it as your test suite matures. Setting it too high on a legacy codebase just means developers comment out the threshold to get CI passing, which defeats the point entirely.
Before You Ship: The Checklist I Actually Use
The one that burned me hardest early on: shipping an API where half the routes had authenticate middleware and half didn’t, and I only discovered the gap during a security review three weeks after launch. Don’t eyeball this. Before you push to production, run a quick audit ā grep your route files:
grep -n "router\." src/routes/*.js | grep -v "authenticate"
That’ll surface every route definition. Cross-check manually against the ones that should be protected. It takes five minutes and has saved me from at least two embarrassing disclosures. If you’re using Express Router, also double-check that you haven’t applied authenticate to the router instance after registering a public route ā order matters in Express middleware, and it doesn’t warn you when you get it wrong.
Helmet, Rate Limiting, and the Auth Endpoint Tax
Drop helmet() in early and leave it there ā but do check your Content-Security-Policy header against your actual mobile client behavior. The default CSP that helmet sets can break WebSocket connections and any in-app browser that tries to load external resources. I usually inspect the response headers in Postman and then test specifically against a staging build of the mobile app before assuming it’s fine:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", "https://your-cdn.com"]
}
}
}));
Rate limiting is where I see people make a consistent mistake: they slap one global limiter on the entire API and call it done. That’s not enough. Your /auth/login and /auth/register endpoints are the ones that get hammered by credential stuffing attacks ā they need their own stricter limiter. I use express-rate-limit with different configs per route group:
import rateLimit from 'express-rate-limit';
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { error: 'Too many attempts, try again later' }
});
app.use('/api', globalLimiter);
app.use('/api/v1/auth', authLimiter);
Twenty attempts per 15 minutes on auth is already generous. If you’re seeing legitimate users hit that ceiling, something else is wrong with your UX.
Kill console.log ā Seriously, Right Now
I switched to pino specifically because it outputs structured JSON by default and the performance difference over winston is measurable under load ā pino’s own benchmarks show it can be 5x faster in high-throughput scenarios, and I’ve seen that hold up in practice on APIs processing heavy mobile traffic. The setup is minimal:
npm install pino pino-pretty
// logger.js
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined
});
The pino-pretty transport is only for local dev ā in production you want raw JSON so your log aggregator (Datadog, Logtail, whatever) can parse it properly. The thing that caught me off guard is that console.log calls in dependencies you’re pulling in can still leak sensitive data ā do a grep -r "console.log" node_modules/your-shady-dep if you’re paranoid, which you should be.
The Health Check Endpoint Isn’t Optional
Mobile apps have aggressive retry logic. Load balancers need to know when to pull a node out of rotation. Both of these depend on a working GET /health endpoint, and I’ve watched deployments go sideways because this was an afterthought. Keep it simple but actually useful:
const startTime = Date.now();
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
uptime: Math.floor((Date.now() - startTime) / 1000),
timestamp: new Date().toISOString()
});
});
Put this route before your auth middleware ā health checks don’t need a bearer token. Also consider adding a deeper check (database ping, Redis connection) behind /health/ready if you’re running Kubernetes ā the shallow /health is your liveness probe, the deeper one is your readiness probe. Conflating them means your pod gets traffic before the DB connection pool is ready.
API Versioning: Do It on Day One, Not Day 90
Retrofitting versioning onto a live API with mobile clients in the wild is miserable. Mobile apps don’t auto-update ā a meaningful chunk of your users will be running v1 of your app six months after you ship v2, and if you haven’t versioned your API, you’re either breaking old clients or maintaining two separate codebases with no clean separation. The pattern I use:
// routes/index.js
import v1Routes from './v1/index.js';
app.use('/api/v1', v1Routes);
// When v2 ships:
import v2Routes from './v2/index.js';
app.use('/api/v2', v2Routes);
Each version folder has its own controllers, middleware, and validation schemas. Yes, there’s duplication. That’s the point ā v2 shouldn’t be coupled to v1’s assumptions. The overhead of maintaining two versions for a transition period is far less painful than the alternative. One opinionated take: don’t version via headers. Mobile clients are not web browsers ā debugging a request where the version lives in an Accept header is genuinely painful compared to just reading a URL.