Project Setup
Initialize a modern Node.js project with ES modules:
mkdir my-api && cd my-api
npm init -y
npm install express cors helmet dotenv zod
npm install -D nodemon
Update package.json:
{
"type": "module",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
}
}
Create the server entry point:
// server.js
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import 'dotenv/config';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(helmet()); // Security headers
app.use(cors()); // CORS
app.use(express.json()); // Parse JSON bodies
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(Server running on port ${PORT});
});
Structured Route Organization
Organize routes in separate files:
// routes/users.js
import { Router } from 'express';
const router = Router();
let users = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
];
// GET /api/users
router.get('/', (req, res) => {
res.json({ users, total: users.length });
});
// GET /api/users/:id
router.get('/:id', (req, res) => {
const user = users.find(u => u.id === req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST /api/users
router.post('/', (req, res) => {
const { name, email } = req.body;
const user = { id: crypto.randomUUID(), name, email };
users.push(user);
res.status(201).json(user);
});
export default router;
Register in server.js:
import userRoutes from './routes/users.js';
app.use('/api/users', userRoutes);
Input Validation with Zod
Never trust client input. Use Zod for type-safe validation:
import { z } from 'zod';
// Define schemas
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8),
});
// Validation middleware
function validate(schema) {
return (req, res, next) => {
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; // Use parsed (clean) data
next();
};
}
// Usage
router.post('/', validate(CreateUserSchema), (req, res) => {
// req.body is guaranteed to be valid here
const { name, email, password } = req.body;
// ... create user
});
Zod catches invalid data before it reaches your business logic and provides clear error messages for the frontend.
Global Error Handling
Add a centralized error handler so you never leak stack traces to clients:
// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
console.error(${req.method} ${req.path}:, err.message);
// Known errors
if (err.status) {
return res.status(err.status).json({
error: err.message,
});
}
// Zod validation errors
if (err.name === 'ZodError') {
return res.status(400).json({
error: 'Validation failed',
details: err.flatten().fieldErrors,
});
}
// Unknown errors — don't leak details
res.status(500).json({
error: 'Internal server error',
});
}
// Custom error class
export class AppError extends Error {
constructor(message, status = 400) {
super(message);
this.status = status;
}
}
Register it last (after all routes):
app.use(errorHandler);
Now any route can throw new AppError('User not found', 404) and the error handler formats the response consistently.
JWT Authentication Middleware
Protect routes with JWT tokens:
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET;
export function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = payload; // { id, email, role }
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Generate token on login
function generateToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
}
// Protect routes
router.get('/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
Install the dependency: npm install jsonwebtoken
Deploying to Production
A production Express app needs:
- 1. Environment variables — never hardcode secrets
- 2. Process manager — use PM2 or systemd (not
node server.js) - 3. Reverse proxy — Nginx in front for SSL and static files
- 4. Logging — structured JSON logs for debugging
# Install PM2 globally
npm install -g pm2
# Start with PM2
pm2 start server.js --name my-api -i max
# Auto-restart on crash, auto-start on boot
pm2 startup
pm2 save
# View logs
pm2 logs my-api
Or use a systemd service file (see our tutorial on systemd services) for tighter control over resources and security.
For the Nginx reverse proxy setup with SSL, check our Nginx Reverse Proxy with SSL tutorial.