From zero to production: authentication, validation, error handling and best practices for modern APIs.
Project Structure
A well-structured API is maintainable long-term. This is the organization I use in real projects:
src/
routes/ → Endpoint definitions
controllers/ → Logic for each endpoint
services/ → Business logic
middleware/ → Auth, validation, errors
models/ → DB schemas
lib/ → DB connection, config
types/ → TypeScript types
The controller → service separation is key: the controller handles HTTP, the service handles the logic.
Initial Setup
// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { errorHandler } from './middleware/errorHandler';
import { router } from './routes';
const app = express();
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));
app.use(express.json({ limit: '10kb' }));
app.use('/api/v1', router);
app.use(errorHandler); // Always last
export { app };
Request Validation
Use zod to validate the body before it reaches the controller:
// src/middleware/validate.ts
import { z, ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
}
// Usage in route:
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2).max(50),
});
router.post('/users', validate(createUserSchema), userController.create);
Centralized Error Handling
Define custom error classes and a global handler:
// src/lib/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number,
public code?: string
) {
super(message);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
// src/middleware/errorHandler.ts
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
});
}
console.error('Unexpected error:', err);
res.status(500).json({ error: 'Internal server error' });
}
Now in any controller you can do throw new NotFoundError('User') and the handler catches it.
JWT Authentication
// src/middleware/auth.ts
import jwt from 'jsonwebtoken';
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new AppError('No token provided', 401, 'UNAUTHORIZED');
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = payload;
next();
} catch {
throw new AppError('Invalid token', 401, 'INVALID_TOKEN');
}
}
Rate Limiting
Protect sensitive endpoints:
import rateLimit from 'express-rate-limit';
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: { error: 'Too many attempts, try again later' },
});
router.post('/auth/login', authLimiter, authController.login);
Consistent Responses
Define a fixed format for all responses:
// src/lib/response.ts
export function success<T>(res: Response, data: T, status = 200) {
return res.status(status).json({ success: true, data });
}
export function paginated<T>(res: Response, data: T[], meta: PaginationMeta) {
return res.json({ success: true, data, meta });
}
Conclusion
A robust API isn’t just code that works — it’s code that fails predictably, validates at the edges, and is readable for the next developer. The patterns in this article (validation with Zod, typed errors, consistent responses) are what make the difference between a prototype and production code.