Build a Production-Ready REST API With Node.js, Express, and PostgreSQL
Build16 min read·April 13, 2026·--

Build a Production-Ready REST API With Node.js, Express, and PostgreSQL

Authentication, validation, pagination, error handling, rate limiting — every piece a real API needs. Copy this structure and you'll never build a sloppy backend again.

@
@kivorablog
April 13, 2026
Share

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:


FeatureWhy It Matters
Input validationPrevents bad data from corrupting your database
AuthenticationOnly authorised users can access protected data
Rate limitingPrevents abuse and keeps your server alive
Error handlingGraceful failures instead of server crashes
PaginationPrevents database overload from large result sets
LoggingYou can diagnose problems after they happen
CORSFrontend apps can actually call your API
Environment configSecrets don't live in your codebase

This guide covers every one of these.




Free Stack


ToolPurposeFreePaid Upgrade
Node.js + ExpressServer frameworkFreeN/A
PostgreSQLDatabaseFree (self-hosted)Supabase $25/mo, Neon free tier
SupabaseManaged Postgres + Auth500MB free$25/month
RailwayHosting$5 credit/month$5–$20/month
RenderAlternative hostingFree (sleeps on inactivity)$7/month
ZodInput validationFree (npm)N/A
JoseJWT authenticationFree (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.' }),
},
Read more on Kivora Blog

Read more on Kivora Blog

Get started →