A "hello world" Express API takes ten lines. A production API that a store, a mobile app or a partner integration can depend on takes a few more decisions: how requests are validated, how errors look, how the database connection behaves under load, and what happens when the server restarts in the middle of a request.
This guide builds a small orders API with Express 5 and MySQL and covers those decisions in order. The same principles apply whether the API sits behind a Next.js frontend, feeds a mobile app or receives webhooks from WooCommerce or Shopify.
Project structure that scalesLink to this section
orders-api/
├── src/
│ ├── app.js Express app: middleware and routes (no listen())
│ ├── server.js starts the HTTP server, handles shutdown
│ ├── config.js reads and checks environment variables
│ ├── db.js MySQL connection pool
│ ├── routes/orders.js HTTP layer: parse, validate, respond
│ ├── services/orders.js business rules
│ ├── schemas/order.js validation schemas
│ └── middleware/ auth, errors, request id
└── test/orders.test.jsKeeping app.js separate from server.js means tests can import the app and send requests to it without opening a port. Keeping SQL out of the route handlers means business rules can be tested without HTTP.
Configuration that fails fastLink to this section
Read environment variables once, check them, and stop at startup if something is missing. A server that boots without a database password fails later, at the worst moment.
// src/config.js
import { z } from 'zod';
const Env = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const config = Env.parse(process.env);Validate every request with a schemaLink to this section
Most API bugs and many security issues come from trusting input. A schema library such as Zod describes what a valid request looks like and rejects everything else, including fields you didn't expect:
// src/schemas/order.js
import { z } from 'zod';
export const CreateOrder = z
.object({
customerId: z.number().int().positive(),
items: z
.array(z.object({ sku: z.string().regex(/^[A-Z0-9-]{3,32}$/), qty: z.number().int().min(1).max(99) }))
.min(1)
.max(50),
note: z.string().max(500).optional(),
})
.strict(); // unknown fields are an error, not silently ignored
export const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({ error: 'validation_failed', details: result.error.flatten().fieldErrors });
}
req.body = result.data;
next();
};Test patterns such as the SKU format against real data in the regex tester before you ship them; a pattern that's too strict rejects real orders.
Database access: pool, parameters, transactionsLink to this section
Open a pool once and reuse it. Opening a new connection per request is slow and quickly exhausts the database's connection limit.
// src/db.js
import mysql from 'mysql2/promise';
import { config } from './config.js';
export const pool = mysql.createPool({ uri: config.DATABASE_URL, connectionLimit: 10, namedPlaceholders: true });Always use placeholders, never string concatenation, and wrap multi-row writes in a transaction so an order is never saved without its items:
// src/services/orders.js
import { pool } from '../db.js';
export async function createOrder({ customerId, items, note }) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [res] = await conn.execute(
"INSERT INTO orders (customer_id, note, status) VALUES (:customerId, :note, 'pending')",
{ customerId, note: note ?? null },
);
for (const item of items) {
await conn.execute('INSERT INTO order_items (order_id, sku, qty) VALUES (:orderId, :sku, :qty)', {
orderId: res.insertId,
...item,
});
}
await conn.commit();
return { id: res.insertId, status: 'pending' };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}Routes stay thinLink to this section
With validation and business logic elsewhere, route handlers become short. In Express 5, a rejected promise from an async handler is passed to the error handler automatically, so you no longer need try/catch or a wrapper in every route:
// src/routes/orders.js
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { CreateOrder, validate } from '../schemas/order.js';
import * as orders from '../services/orders.js';
export const router = Router();
router.post('/orders', requireAuth, validate(CreateOrder), async (req, res) => {
const order = await orders.createOrder(req.body);
res.status(201).location(`/orders/${order.id}`).json(order);
});
router.get('/orders/:id', requireAuth, async (req, res) => {
const order = await orders.findOrder(Number(req.params.id));
if (!order) return res.status(404).json({ error: 'not_found' });
res.json(order);
});Use the status codes clients expect: 201 with a Location header for created resources, 404 for missing ones, 422 for validation errors, 401 when the caller isn't authenticated and 403 when they are but aren't allowed.
AuthenticationLink to this section
For server-to-server integrations, an API key in a header is often enough. For users, short-lived signed tokens (JWTs) or session cookies are common. Whichever you use, verify on every request and keep secrets out of the code:
// src/middleware/auth.js
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
export function requireAuth(req, res, next) {
const token = req.get('authorization')?.replace(/^Bearer\s+/i, '');
if (!token) return res.status(401).json({ error: 'unauthenticated' });
try {
req.user = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'] });
next();
} catch {
res.status(401).json({ error: 'invalid_token' });
}
}Pinning algorithms stops a token from choosing its own verification method. When a client reports "invalid token", decode it in the JWT decoder to check the expiry and claims, and convert the exp value with the timestamp converter if the times look wrong.
One error format, one error handlerLink to this section
Clients should be able to handle every error the same way. A single handler, registered last, turns exceptions into a consistent JSON shape and logs the details server-side:
// src/middleware/errors.js
export function errorHandler(err, req, res, _next) {
req.log?.error({ err, requestId: req.id }, 'request failed');
const status = err.status ?? 500;
res.status(status).json({
error: status >= 500 ? 'internal_error' : err.code ?? 'bad_request',
requestId: req.id,
});
}Never send stack traces or SQL errors to the client. The requestId lets support find the exact log line when a customer reports a problem.
Security and resilience middlewareLink to this section
// src/app.js
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import pinoHttp from 'pino-http';
import { router as orders } from './routes/orders.js';
import { errorHandler } from './middleware/errors.js';
export const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1); // behind Nginx or a load balancer
app.use(helmet());
app.use(pinoHttp());
app.use(express.json({ limit: '100kb' }));
app.use('/orders', rateLimit({ windowMs: 60_000, limit: 120 }));
app.get('/health', (req, res) => res.json({ ok: true }));
app.use(orders);
app.use(errorHandler);helmetsets sensible security headers.- A body size limit stops oversized payloads from tying up the server.
- Rate limits protect write endpoints and logins from abuse.
- Structured JSON logs (pino) can be searched by request ID, status and duration.
trust proxymakesreq.ipand rate limits use the real client IP behind a proxy.
Graceful shutdownLink to this section
Deploys and restarts send SIGTERM. Stop accepting new connections, let in-flight requests finish, then close the database pool:
// src/server.js
import { app } from './app.js';
import { pool } from './db.js';
import { config } from './config.js';
const server = app.listen(config.PORT, () => console.log(`API listening on :${config.PORT}`));
process.on('SIGTERM', () => {
server.close(async () => {
await pool.end();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref(); // don't hang forever
});Testing the APILink to this section
Node's built-in test runner and Supertest are enough to test routes without a running server:
// test/orders.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { app } from '../src/app.js';
import { signTestToken } from './helpers.js'; // signs a JWT with the test secret
const testToken = signTestToken({ sub: 1 });
test('rejects an order without items', async () => {
const res = await request(app).post('/orders').set('authorization', `Bearer ${testToken}`).send({ customerId: 1, items: [] });
assert.equal(res.status, 422);
assert.ok(res.body.details.items);
});Run the tests against a disposable test database, and add one test for every bug you fix so it can't come back.
Common problems in productionLink to this section
- "Too many connections" from MySQL. A new pool or connection is created per request, or connections aren't released. Create the pool once and always
release()infinally. - The process crashes on one bad request. An error is thrown outside the request cycle, for example in a timer or event handler. Catch errors there too, and run the API under a process manager such as PM2 or systemd that restarts it.
- Dates shift by hours. The database, the server and the client use different time zones. Store UTC, send ISO 8601, convert for display.
- Webhooks arrive twice. Most providers retry. Make handlers idempotent by recording the event ID and ignoring duplicates.
- Base64 payloads don't decode. Some providers use Base64URL. Check which variant with the Base64 and URL encoder.
Frequently asked questionsLink to this section
Express, Fastify or NestJS?Link to this section
Express is the most widely known and has the largest middleware ecosystem. Fastify is faster and has schema validation built in. NestJS adds a full structure on top, which helps large teams. For a focused API maintained by a small team, Express 5 with the structure above is a solid default.
Should this API be written in Node.js or PHP?Link to this section
Use what fits the rest of the stack and the team. If the business already runs PHP and MySQL, a Laravel API keeps one language. If the frontend is Next.js and the team writes TypeScript daily, Node.js keeps one language there instead.
Where should I host it?Link to this section
Anywhere that runs a long-lived Node process: a VPS with Nginx and PM2, a container platform, or a managed Node host. Serverless functions also work for low-traffic APIs, but long-lived connections such as database pools need extra care there.
Do I need TypeScript?Link to this section
Not to start, but it pays off as the API grows. Schema libraries like Zod can generate types from the same schemas you validate with, so types and runtime checks never drift apart.
Building an API for a store, an app or an integration? Let's talk about it.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.