What I Learned Building a Production API with Express 5

Full-Stack Developer with 3+ years of expertise in the JavaScript ecosystem (React, Next.js, TypeScript, Node.js). Currently operating as an independent developer, architecting and shipping scalable products including a global travel platform. I am passionate about building efficient, type-safe web architectures and actively expanding my expertise into Cloud Engineering with AWS, Docker, and Kubernetes.
I built the backend for Roavelo — a travel app — using Express 5, TypeScript, and MongoDB. This post is less about what I built and more about why I made the choices I did. Framework decisions, middleware ordering, error handling, all that stuff, with actual code from the project.
Why Express 5
I picked Express because I already knew it.
I was aware that NestJS is more structured and probably better for larger-scale apps. But NestJS meant learning decorators, the whole Module-Controller-Service-Provider thing, Dependency Injection — basically a different way of thinking about backend code. And I didn’t want to learn a new framework while also trying to ship something real. This project needed to work. It wasn’t a learning exercise.
So I went with what I knew. Build it with Express now, migrate to NestJS later. That’s actually the plan — NestJS migration is next on my list. Get the production experience first, then bring in NestJS when I actually understand why I need its structure, not just because someone on Reddit said so.
As for version 5 specifically:
Async error handling got way better — you can just throw inside an async middleware and it lands in the error handler automatically. In Express 4 you had to wrap everything manually.
5.x is officially out. Starting a new project on 4 didn’t make sense.
Clerk, Helmet, Multer — everything I was using already supported Express 5.
{
"express": "^5.1.0",
"@types/express": "^5.0.6"
}
I’m also using ES Modules ("type": "module" in package.json). import/export over require is pretty much the default now and works better with newer packages. But there’s a catch — __dirname doesn’t exist in ES Modules, so you have to do this:
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Took me way too long to figure out why my file paths were broken.
Why Middleware Order Matters
In Express, middleware runs in registration order. Everyone knows this, but it’s one of those things where getting a single line wrong produces bugs you’ll stare at for hours.
Here’s what server.ts looks like:
// 1. Proxy configuration
app.set('trust proxy', 1);
// 2. Security headers
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
// 3. CORS
app.use(cors({
origin: process.env.NODE_ENV === 'production'
? ['<https://roavelo.com>', '<https://www.roavelo.com>']
: '*',
credentials: true,
}));
// 4. Webhook route (BEFORE express.json!)
app.post('/api/v2/webhooks/clerk',
express.raw({ type: 'application/json' }),
verifyClerkWebhook,
handleClerkWebhook,
);
// 5. JSON parser
app.use(express.json());
// 6. Rate limiting
app.use('/api/', apiLimiter);
// 7. Authentication
app.use(clerkMiddleware());
// 8. Routes
app.use('/api/v2', allRoutesV2);
// 9. Error handler (must be last)
app.use(errorHandler);
Every line is where it is for a reason.
Why trust proxy comes first
Roavelo’s backend runs on Render with Cloudflare sitting in front. So the real client IP comes in through the X-Forwarded-For header. If you don’t set trust proxy, Express ignores that header and only sees the proxy’s IP.
Why does that matter? Rate limiting is IP-based. Without this setting, every single user looks like the same IP. One person hits the rate limit, everyone gets blocked. Or from an attacker’s point of view — rate limiting is basically turned off.
app.set('trust proxy', 1);
1 means “trust the first proxy in the chain.” Cloudflare → Render → Express — Render is hop #1, so 1 is right.
Why Helmet and CORS come before routes
They both affect every response that goes out. Helmet slaps on security headers like X-Content-Type-Options and X-Frame-Options automatically. CORS decides which origins are allowed to call the API.
contentSecurityPolicy: false — because this is a JSON API server. CSP is meant to prevent XSS on pages that serve HTML. On an API that only returns JSON, it does nothing useful and actually gets in the way if you ever hook up Swagger UI.
The part I wasted the most time on: Webhooks
Clerk sends webhook events when users sign up or delete accounts. Verifying those webhook signatures requires the raw HTTP body as a Buffer.
Here’s the problem: express.json() parses the body and throws away the original bytes. Svix (Clerk’s webhook library) needs those exact raw bytes to compute the signature hash. You can try JSON.stringify()-ing the parsed object back, but it won’t be byte-identical — different key ordering, different whitespace, whatever.
So the webhook route has to go above express.json(), with its own express.raw():
// Has to be BEFORE express.json() — otherwise raw body is gone
app.post('/api/v2/webhooks/clerk',
express.raw({ type: 'application/json' }),
verifyClerkWebhook,
handleClerkWebhook,
);
// Then the JSON parser
app.use(express.json());
Flip these and webhook verification fails. Every time. And the error message is just “Invalid signature” — completely useless for figuring out the actual problem. I’ve seen a ton of posts in the Clerk community from people stuck on this.
Why rate limiting comes before authentication
Think about it: if rate limiting sits after Clerk’s auth middleware, every single request — including ones from an attacker flooding your server — goes through token verification first. That’s CPU time wasted on requests you’re about to reject anyway. Put rate limiting first, block the junk early, and only run auth on what gets through.
app.use('/api/', apiLimiter); // Block first
app.use(clerkMiddleware()); // Authenticate what's left
Why the error handler comes last
Express error handlers have a specific (err, req, res, next) signature. They only catch errors from middleware and routes registered before them. Put it anywhere else and it won’t catch everything. Not much to debate here — it’s just how Express works.
Why I Standardized API Responses
In the early days, every controller returned whatever shape it felt like. One endpoint sent back { users: [...] }, another sent { data: [...], count: 10 }. On the frontend, I kept having to check “wait, what does this endpoint actually return?” every time I made an API call.
So I forced every V2 endpoint into one format:
// Success: { data: T, meta: { timestamp, ... } }
// Error: { error: { code, message, details? } }
I did this by extending Express’s Response object through middleware:
export const responseWrapper = (req: Request, res: Response, next: NextFunction): void => {
res.success = function <T>(data: T, meta?: ApiResponse<T>['meta']) {
const response: ApiResponse<T> = {
data,
meta: {
timestamp: new Date().toISOString(),
...meta,
},
};
this.json(response);
};
res.error = function (code: string, message: string, statusCode = 400, details?: unknown) {
const response: ApiErrorResponse = {
error: { code, message, details: details || undefined },
};
this.status(statusCode).json(response);
};
next();
};
TypeScript needs a global type extension to know about these methods:
declare global {
namespace Express {
interface Response {
success<T>(data: T, meta?: ApiResponse<T>['meta']): void;
error(code: string, message: string, statusCode?: number, details?: unknown): void;
}
}
}
Controllers went from this:
// Before
res.status(200).json({ countries: data, total: data.length });
// After
res.success(data, { total: data.length });
Now every response has the same shape. res.success() adds the timestamp automatically. Frontend always reads response.data.data for actual data, response.data.error.code for errors. No guessing.
I thought about doing sendSuccess(res, data) as a utility function instead. That’s more explicit and testable, sure. But you have to import it everywhere, and there’s nothing stopping you from just calling res.json() directly and breaking the pattern. With the middleware approach, the standard is just… there. You’d have to go out of your way to bypass it. Since I’m the only one writing this code, protecting myself from my own laziness felt more important.
This only applies to V2:
// routes/v2/index.ts
const router = Router();
router.use(responseWrapper); // V2 only
V1 keeps its format, V2 gets the new one. Each version has its own middleware chain, so they don’t step on each other.
Why Hiding Errors Matters
The error handler is pretty short:
export const errorHandler = (err: CustomError, req: Request, res: Response, next: NextFunction): void => {
logger.error('Unhandled error:', err);
const isProduction = process.env.NODE_ENV === 'production';
const statusCode = err.statusCode || 500;
const errorResponse = {
error: isProduction
? 'An unexpected error occurred'
: err.message || 'Internal server error',
};
res.status(statusCode).json(errorResponse);
};
The isProduction check is the important part. Dev mode shows the real err.message. Production always returns 'An unexpected error occurred', no matter what actually went wrong.
This is because error messages leak things you don’t want exposed. A MongoDB connection failure gives you something like "MongooseServerSelectionError: connect ECONNREFUSED 10.0.0.42:27017" — now an attacker knows your internal DB IP and port. Mongoose validation errors reveal your schema field names. Auth errors can expose user ID formats. None of that should reach the client.
Simple rule: log everything on the server, return nothing to the client.
The downside is that when a user hits an error in production and contacts support, all they can say is “it said an unexpected error occurred.” Tough to trace. I want to add error reference IDs eventually — return something like "An error occurred. Reference: ERR-a3f2b1" and log that same ID server-side so support can actually look it up.
Why Two Rate Limiters
// General API: 100 requests per 15 minutes
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
// Auth API: 30 requests per 15 minutes
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});
Auth endpoints get about 3x stricter limits than everything else. The logic is pretty simple — GET /countries and GET /cities are list endpoints that people browse through normally, so they need a higher ceiling. Auth endpoints? You don’t sign in 50 times in 15 minutes unless something’s wrong. If someone’s hitting auth that fast, it’s probably brute force. Tighter limit.
standardHeaders: true adds the RateLimit-Remaining header to responses. The frontend could use this to back off before actually getting blocked. legacyHeaders: false turns off the old X-RateLimit-* headers — just using the newer RFC-standard RateLimit-* ones.
One thing to note: this is all in-memory right now. If the server restarts, the counters reset. And if I ever run multiple instances, they won’t share state. Redis store is the answer for that, but with a single server it’s not a problem yet.
Why Environment Variables Need Types
process.env.PORT in Node.js is string | undefined. Sprinkling process.env.PORT || '5001' all over the codebase is tedious and easy to mess up.
So I made a config file:
interface EnvironmentVariables {
PORT: string;
NODE_ENV: 'development' | 'production' | 'test';
MONGODB_URI: string;
CLERK_SECRET_KEY: string;
CLERK_PUBLISHABLE_KEY: string;
CLERK_WEBHOOK_SECRET?: string;
REVENUECAT_API_KEY: string;
SKIP_REVENUECAT_VERIFY: boolean;
}
const required: (keyof EnvironmentVariables)[] = [
'MONGODB_URI',
'CLERK_SECRET_KEY',
'CLERK_PUBLISHABLE_KEY',
'REVENUECAT_API_KEY',
];
required.forEach((key) => {
if (!process.env[key]) {
throw new Error(`Missing required env var:${key}`);
}
});
export const ENV: EnvironmentVariables = {
PORT: process.env.PORT ?? '5001',
NODE_ENV: (process.env.NODE_ENV as EnvironmentVariables['NODE_ENV']) ?? 'development',
MONGODB_URI: process.env.MONGODB_URI!,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY!,
// ...
SKIP_REVENUECAT_VERIFY: process.env.SKIP_REVENUECAT_VERIFY === 'true',
};
The point is to fail at startup, not later. If MONGODB_URI is missing, the server just doesn’t start. Way easier to figure out “server won’t boot, env var missing” than “server started fine, ran for 10 minutes, then exploded on the first DB call.”
It also handles type conversion in one place. process.env is all strings, but SKIP_REVENUECAT_VERIFY needs to be a boolean. That === 'true' comparison happens here, once. Everywhere else in the code, ENV.SKIP_REVENUECAT_VERIFY is already a proper boolean.
And the interface itself works as documentation. Anyone looking at this file immediately knows what env vars the project needs and what types they should be. Like a .env.example but with type info baked in.
In hindsight, I should have used Zod here. Right now I’m only checking if values exist — not if they’re actually valid. MONGODB_URI could be set to "lol" and it’d pass the check. Zod’s .url() would catch that at startup.
How to Shut Down a Server Properly
At first I just killed the server whenever. Ctrl+C, done. Then I thought about what happens in production.
Say a user is buying a country guide. The API has already sent a verification request to RevenueCat and is about to write the purchase record to MongoDB. Server dies right there. RevenueCat thinks the payment went through. Database has nothing. User paid but can’t access the content. Bad.
Graceful shutdown handles this:
const gracefulShutdown = async (signal: string): Promise<void> => {
logger.info(`${signal} received. Shutting down gracefully...`);
if (server) {
server.close(() => {
logger.info('HTTP server closed');
});
}
try {
await mongoose.connection.close();
logger.info('MongoDB connection closed');
} catch (error) {
logger.error('Error closing MongoDB connection', error instanceof Error ? error : null);
}
process.exit(0);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
What server.close() does — it stops accepting new connections but lets in-flight requests finish. The callback runs only after every ongoing request completes. Then we close the MongoDB connection and exit.
SIGTERM is what platforms like Render or Kubernetes send when they’re deploying a new version — “wrap up and shut down.” SIGINT is your local Ctrl+C.
I also handle crashes:
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
unhandledRejection is a Promise that nobody .catch()-ed. I log it and move on — the server keeps running. uncaughtException is worse. It means something in synchronous code blew up completely, and the process might be in a broken state. So the server exits immediately and lets the process manager (PM2, Kubernetes, whatever) restart it.
Logging Decisions
Logging sounds like a simple thing until you actually have to decide what to log and what’s safe to show in production.
const isDev = process.env.NODE_ENV !== 'production';
export const logger = {
debug: (...args: unknown[]): void => {
if (isDev) {
console.log('[DEBUG]', ...args);
}
},
info: (...args: unknown[]): void => {
console.log('[INFO]', ...args);
},
error: (message: string, ...args: unknown[]): void => {
if (isDev) {
console.error('[ERROR]', message, ...args);
} else {
console.error('[ERROR]', message, ...args);
}
},
};
debug is dev-only. In production, debug-level logs are just noise — expensive noise, if you’re paying for log storage. info and error go out in every environment.
Then there’s the ID masking thing:
export const maskId = (id: string | null | undefined): string => {
if (!id || typeof id !== 'string') return '[invalid]';
if (id.length <= 8) return '***';
return id.substring(0, 4) + '...' + id.substring(id.length - 4);
};
// 'user_2abc123def456ghi789' → 'user...i789'
I didn’t want full user IDs sitting in log files — if those logs ever get exposed, that’s a privacy problem. But I also need to be able to trace which user hit which error. Showing the first 4 and last 4 characters is enough to track someone through the logs without giving away the whole ID.
Long term, console.log won’t cut it. Something like Pino that outputs structured JSON would let tools like CloudWatch or Datadog automatically parse and index everything. For now the scale doesn’t justify it, but it’s on the list.
Looking Back
The main thing I took away from this: most of the code in a production API isn’t the features. It’s everything around the features. Helmet, CORS, rate limiting, graceful shutdown, error handling, env validation, logging. None of these are the actual product. But without them, you don’t really have a production server — you have a demo that happens to be internet-facing.
The questions that matter: when the server goes down at 3 AM, can you figure out what happened from the logs? If someone hammers your auth endpoint, does it get blocked automatically? If you deploy while someone’s in the middle of a payment, does their transaction survive?
There’s stuff I’d do differently:
| Current | What I’d change | Why |
console.log logging | Pino + JSON format | Works with log aggregation out of the box |
| Manual env var checks | Zod schemas | Actually validates formats, not just existence |
| No error tracking | Sentry | Get alerted when prod breaks, not when users complain |
| In-memory rate limits | Redis store | Needed once there’s more than one server |
| Generic error messages | Error reference IDs | So support can actually trace what happened |
None of this is perfect. It doesn’t need to be. It works for the current scale, and when the scale changes, I know what to swap out.




