What "Production-Ready" Actually Means
Most tutorial APIs break in the real world because they skip everything that happens between "it works on localhost" and "it handles 10,000 real users." A production-ready API has:
| Feature | Why It Matters |
|---|---|
| Input validation | Prevents bad data from corrupting your database |
| Authentication | Only authorised users can access protected data |
| Rate limiting | Prevents abuse and keeps your server alive |
| Error handling | Graceful failures instead of server crashes |
| Pagination | Prevents database overload from large result sets |
| Logging | You can diagnose problems after they happen |
| CORS | Frontend apps can actually call your API |
| Environment config | Secrets don't live in your codebase |
This guide covers every one of these.

Free Stack
| Tool | Purpose | Free | Paid Upgrade |
|---|---|---|---|
| Node.js + Express | Server framework | Free | N/A |
| PostgreSQL | Database | Free (self-hosted) | Supabase $25/mo, Neon free tier |
| Supabase | Managed Postgres + Auth | 500MB free | $25/month |
| Railway | Hosting | $5 credit/month | $5–$20/month |
| Render | Alternative hosting | Free (sleeps on inactivity) | $7/month |
| Zod | Input validation | Free (npm) | N/A |
| Jose | JWT authentication | Free (npm) | N/A |
Project Setup
mkdir my-api && cd my-api
npm init -y
npm install express pg dotenv zod jose cors helmet morgan
npm install -D nodemon
# Create folder structure
mkdir -p src/{routes,middleware,db,utils}
touch src/index.js src/db/client.js src/middleware/{auth,validate,rateLimit}.js
Folder Structure
src/
├── index.js ← Express app setup
├── db/
│ └── client.js ← Database connection
├── routes/
│ ├── auth.js ← /api/auth/*
│ ├── users.js ← /api/users/*
│ └── posts.js ← /api/posts/*
├── middleware/
│ ├── auth.js ← JWT verification
│ ├── validate.js ← Zod schema validation
│ └── rateLimit.js ← Request throttling
└── utils/
├── errors.js ← Custom error classes
└── jwt.js ← Token helpers
The Express App
// src/index.js
require('dotenv').config()
const express = require('express')
const cors = require('cors')
const helmet = require('helmet')
const morgan = require('morgan')
const authRoutes = require('./routes/auth')
const userRoutes = require('./routes/users')
const postRoutes = require('./routes/posts')
const { errorHandler } = require('./utils/errors')
const app = express()
const PORT = process.env.PORT || 3000
app.use(helmet()) // Sets secure HTTP headers
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true,
}))
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'))
app.use(express.json({ limit: '10kb' })) // Prevent huge payloads
app.use(express.urlencoded({ extended: true }))
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
app.use('/api/auth', authRoutes)
app.use('/api/users', userRoutes)
app.use('/api/posts', postRoutes)
app.use((req, res) => {
res.status(404).json({ error: 'Route not found', path: req.path })
})
app.use(errorHandler)
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
module.exports = app
Database Connection (PostgreSQL)
// src/db/client.js
const { Pool } = require('pg')
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: false }
: false,
max: 10, // Max 10 concurrent connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
// Test connection on startup
pool.connect((err, client, release) => {
if (err) {
console.error('Database connection failed:', err.message)
process.exit(1)
}
console.log('Database connected')
release()
})
// Convenience query function
async function query(text, params) {
const start = Date.now()
const result = await pool.query(text, params)
const duration = Date.now() - start
if (duration > 1000) {
console.warn('Slow query detected:', { text, duration, rows: result.rowCount })
}
return result
},
module.exports = { query, pool }
JWT Authentication Middleware
// src/middleware/auth.js
const { SignJWT, jwtVerify } = require('jose')
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET)
// Generate a JWT token
async function signToken(payload, expiresIn = '7d') {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(SECRET)
},
// Verify a JWT token
async function verifyToken(token) {
const { payload } = await jwtVerify(token, SECRET)
return payload
},
// Express middleware
async function requireAuth(req, res, next) {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' })
}
try {
const token = header.slice(7)
const payload = await verifyToken(token)
req.user = payload
next()
} catch (err) {
if (err.code === 'ERR_JWT_EXPIRED') {
return res.status(401).json({ error: 'Token expired. Please sign in again.' })
}
return res.status(401).json({ error: 'Invalid token' })
}
},
module.exports = { signToken, verifyToken, requireAuth }
Input Validation With Zod
// src/middleware/validate.js
const { ZodError } = require('zod')
// Middleware factory: validate req.body against a Zod schema
function validate(schema) {
return (req, res, next) => {
try {
req.body = schema.parse(req.body)
next()
} catch (err) {
if (err instanceof ZodError) {
const errors = err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}))
return res.status(400).json({ error: 'Validation failed', details: errors })
}
next(err)
}
}
},
module.exports = { validate }
Auth Routes
// src/routes/auth.js
const express = require('express')
const { z } = require('zod')
const bcrypt = require('bcryptjs')
const { query } = require('../db/client')
const { signToken, requireAuth } = require('../middleware/auth')
const { validate } = require('../middleware/validate')
const router = express.Router()
npm install bcryptjs // run this first
// Validation schemas
const signupSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(1).max(100).optional(),
})
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
// POST /api/auth/signup
router.post('/signup', validate(signupSchema), async (req, res, next) => {
try {
const { email, password, name } = req.body
// Check if email exists
const existing = await query('SELECT id FROM users WHERE email = $1', [email])
if (existing.rows.length > 0) {
return res.status(409).json({ error: 'Email already registered' })
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12)
// Create user
const result = await query(
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name, created_at',
[email.toLowerCase(), passwordHash, name]
)
const user = result.rows[0]
// Generate token
const token = await signToken({ userId: user.id, email: user.email })
res.status(201).json({ user, token })
} catch (err) {
next(err)
}
})
// POST /api/auth/login
router.post('/login', validate(loginSchema), async (req, res, next) => {
try {
const { email, password } = req.body
const result = await query(
'SELECT id, email, name, password_hash FROM users WHERE email = $1',
[email.toLowerCase()]
)
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' })
}
const user = result.rows[0]
const valid = await bcrypt.compare(password, user.password_hash)
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' })
}
const token = await signToken({ userId: user.id, email: user.email })
// Remove password hash from response
delete user.password_hash
res.json({ user, token })
} catch (err) {
next(err)
}
})
// GET /api/auth/me — get current user
router.get('/me', requireAuth, async (req, res, next) => {
try {
const result = await query(
'SELECT id, email, name, created_at FROM users WHERE id = $1',
[req.user.userId]
)
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' })
}
res.json({ user: result.rows[0] })
} catch (err) {
next(err)
}
})
module.exports = router
Pagination Pattern (Use This Everywhere)
// Standard paginated response
async function getPaginatedResults(tableName, filters, page = 1, limit = 20) {
const offset = (page - 1) * limit
const safeLimit = Math.min(limit, 100) // Cap at 100
const [data, count] = await Promise.all([
query(`SELECT * FROM ${tableName} WHERE ${filters} LIMIT $1 OFFSET $2`, [safeLimit, offset]),
query(`SELECT COUNT(*) FROM ${tableName} WHERE ${filters}`, [])
])
const total = parseInt(count.rows[0].count)
return {
data: data.rows,
pagination: {
page,
limit: safeLimit,
total,
totalPages: Math.ceil(total / safeLimit),
hasNext: page < Math.ceil(total / safeLimit),
hasPrev: page > 1,
}
}
},
Rate Limiting (In-Memory, Upgrade to Redis at Scale)
// src/middleware/rateLimit.js
const requests = new Map()
function createRateLimit({ windowMs = 60000, max = 100, message = 'Too many requests' } = {}) {
return (req, res, next) => {
const key = req.ip || req.headers['x-forwarded-for'] || 'unknown'
const now = Date.now()
const hits = (requests.get(key) || []).filter(ts => ts > now - windowMs)
if (hits.length >= max) {
return res.status(429).json({
error: message,
retryAfter: Math.ceil(windowMs / 1000),
})
}
requests.set(key, [...hits, now])
res.setHeader('X-RateLimit-Limit', max)
res.setHeader('X-RateLimit-Remaining', max - hits.length - 1)
next()
}
},
module.exports = {
globalLimit: createRateLimit({ windowMs: 60000, max: 100 }),
authLimit: createRateLimit({ windowMs: 60000, max: 10, message: 'Too many login attempts. Try again in 1 minute.' }),
aiLimit: createRateLimit({ windowMs: 60000, max: 20, message: 'AI rate limit reached. Max 20 requests per minute.' }),
},
