Key Takeaways
- Express 5 async support: Route handlers can be
asyncfunctions — unhandled rejections automatically become 500 errors. No more.catch(next)boilerplate on every route. - Zod for validation:
z.object({ email: z.string().email(), age: z.number().min(18) }).parse(req.body)throws aZodErrorwith field-level messages on invalid input — clean, type-safe validation in one line. - Parameterised queries always:
pool.query('SELECT * FROM users WHERE id = $1', [userId])prevents SQL injection. Never concatenate user input into SQL strings. - JWT lifecycle: Access tokens expire in 15 minutes, refresh tokens in 7 days. Refresh token rotation (issue new refresh token on each use) prevents replay attacks without requiring a token blacklist.
Introduction
Direct Answer: How do I build a REST API with Node.js and Express on Ubuntu 24.04 in 2026?
Install Node.js 22 LTS with curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - && sudo apt-get install -y nodejs. Create a project with npm init -y && npm install express zod pg jsonwebtoken bcrypt helmet cors. Create server.js with import express from 'express'; const app = express(); app.use(express.json()); app.get('/health', (req, res) => res.json({ status: 'ok' })); app.listen(3000). Run with node server.js. For a production-ready API add: helmet() for security headers, cors() for cross-origin requests, a PostgreSQL pool with the pg package, Zod schemas for request validation, JWT authentication middleware, and a global error handler. Structure routes in separate files under /routes/, middleware under /middleware/, and database queries under /db/. The complete stack covers: CRUD endpoints, authentication with JWT access + refresh tokens, input validation, error handling, and a Dockerfile for containerised deployment.
Setup
# Install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version && npm --version
Expected output:
v22.11.0
10.9.0
# Create project
mkdir -p ~/node-api && cd ~/node-api
npm init -y
# Install dependencies
npm install express@5 zod pg jsonwebtoken bcrypt helmet cors dotenv
npm install --save-dev nodemon jest supertest
# Use ES modules (modern Node.js style)
npm pkg set type=module
Part 1: Project Structure
node-api/
├── server.js ← Entry point
├── app.js ← Express app (exported for testing)
├── .env ← Environment variables
├── routes/
│ ├── auth.js ← POST /auth/register, /auth/login
│ └── users.js ← GET/PUT /users/:id
├── middleware/
│ ├── auth.js ← JWT verification middleware
│ └── validate.js ← Zod request validation middleware
├── db/
│ └── pool.js ← PostgreSQL connection pool
└── Dockerfile
mkdir -p routes middleware db
Part 2: Database Setup
# Create the database schema
sudo -u postgres psql << 'SQL'
CREATE DATABASE nodeapi;
CREATE USER apiuser WITH PASSWORD 'secure_password_here';
GRANT ALL PRIVILEGES ON DATABASE nodeapi TO apiuser;
\c nodeapi
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
GRANT ALL ON ALL TABLES IN SCHEMA public TO apiuser;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO apiuser;
SQL
echo "Database ready"
Expected output:
CREATE TABLE
CREATE TABLE
GRANT
Database ready
# .env file
cat > .env << 'EOF'
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://apiuser:secure_password_here@localhost:5432/nodeapi
JWT_SECRET=change_this_to_a_random_64_char_secret_in_production
JWT_REFRESH_SECRET=change_this_to_another_random_64_char_secret
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
EOF
// db/pool.js
import pg from 'pg';
import 'dotenv/config';
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Max pool size
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 2000,
});
// Test connection on startup
pool.on('error', (err) => {
console.error('Unexpected database error:', err);
process.exit(1);
});
export async function query(text, params) {
const start = Date.now();
const res = await pool.query(text, params);
const duration = Date.now() - start;
if (process.env.NODE_ENV === 'development') {
console.debug(`[DB] ${duration}ms — ${text.substring(0, 60)}`);
}
return res;
}
Part 3: Validation Middleware
// middleware/validate.js
import { ZodError } from 'zod';
/**
* Zod validation middleware factory.
* Usage: router.post('/users', validate(createUserSchema), handler)
*/
export function validate(schema) {
return (req, res, next) => {
try {
req.body = schema.parse(req.body); // Throws ZodError on invalid input
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
})),
});
}
next(err);
}
};
}
Part 4: Auth Routes (Register + Login + Refresh)
// routes/auth.js
import { Router } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { query } from '../db/pool.js';
import { validate } from '../middleware/validate.js';
const router = Router();
// Validation schemas
const registerSchema = 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(2, 'Name must be at least 2 characters').max(100),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
function generateTokens(userId) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
);
return { accessToken, refreshToken };
}
// POST /auth/register
router.post('/register', validate(registerSchema), async (req, res) => {
const { email, password, name } = req.body;
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' });
}
const hashedPassword = await bcrypt.hash(password, 12);
const { rows } = await query(
'INSERT INTO users (email, password, name) VALUES ($1, $2, $3) RETURNING id, email, name, created_at',
[email, hashedPassword, name]
);
const user = rows[0];
const { accessToken, refreshToken } = generateTokens(user.id);
// Store refresh token
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await query(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)',
[user.id, refreshToken, expiresAt]
);
res.status(201).json({
user: { id: user.id, email: user.email, name: user.name },
accessToken,
refreshToken,
});
});
// POST /auth/login
router.post('/login', validate(loginSchema), async (req, res) => {
const { email, password } = req.body;
const { rows } = await query('SELECT * FROM users WHERE email = $1', [email]);
const user = rows[0];
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const { accessToken, refreshToken } = generateTokens(user.id);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await query(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)',
[user.id, refreshToken, expiresAt]
);
res.json({
user: { id: user.id, email: user.email, name: user.name },
accessToken,
refreshToken,
});
});
// POST /auth/refresh — refresh token rotation
router.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ error: 'Refresh token required' });
let payload;
try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
} catch {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Check token exists and hasn't expired
const { rows } = await query(
'DELETE FROM refresh_tokens WHERE token = $1 AND expires_at > NOW() RETURNING user_id',
[refreshToken]
);
if (rows.length === 0) return res.status(401).json({ error: 'Refresh token expired or already used' });
// Issue new token pair (rotation)
const tokens = generateTokens(payload.sub);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await query(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)',
[rows[0].user_id, tokens.refreshToken, expiresAt]
);
res.json(tokens);
});
export default router;
Part 5: Auth Middleware + User Routes
// middleware/auth.js
import jwt from 'jsonwebtoken';
export function requireAuth(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const payload = jwt.verify(auth.slice(7), process.env.JWT_SECRET);
req.userId = payload.sub;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired access token' });
}
}
// routes/users.js
import { Router } from 'express';
import { z } from 'zod';
import { query } from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
const updateSchema = z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional(),
}).refine(obj => Object.keys(obj).length > 0, { message: 'No fields to update' });
// GET /users/me — get current user profile
router.get('/me', requireAuth, async (req, res) => {
const { rows } = await query(
'SELECT id, email, name, created_at FROM users WHERE id = $1',
[req.userId]
);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
res.json(rows[0]);
});
// PUT /users/me — update current user
router.put('/me', requireAuth, validate(updateSchema), async (req, res) => {
const updates = Object.entries(req.body);
const setClauses = updates.map(([key], i) => `${key} = $${i + 1}`).join(', ');
const values = [...updates.map(([, val]) => val), req.userId];
const { rows } = await query(
`UPDATE users SET ${setClauses}, updated_at = NOW()
WHERE id = $${values.length}
RETURNING id, email, name, updated_at`,
values
);
res.json(rows[0]);
});
export default router;
Part 6: App and Server Entry Points
// app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import 'dotenv/config';
import authRouter from './routes/auth.js';
import usersRouter from './routes/users.js';
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json({ limit: '10kb' }));
// Routes
app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
app.use('/auth', authRouter);
app.use('/users', usersRouter);
// Global error handler (Express 5: catches async errors automatically)
app.use((err, req, res, _next) => {
console.error(err);
const status = err.status || 500;
res.status(status).json({
error: status === 500 ? 'Internal server error' : err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
});
export default app;
// server.js
import app from './app.js';
import { pool } from './db/pool.js';
const PORT = process.env.PORT || 3000;
// Verify DB connection on startup
await pool.query('SELECT 1');
console.log('Database connected');
const server = app.listen(PORT, () => {
console.log(`API running on http://localhost:${PORT}`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
server.close(async () => {
await pool.end();
process.exit(0);
});
});
node server.js
Expected output:
Database connected
API running on http://localhost:3000
Part 7: Testing the API
# Health check
curl -s http://localhost:3000/health | python3 -m json.tool
Expected output:
{
"status": "ok",
"timestamp": "2026-04-22T10:00:00.000Z"
}
# Register a user
curl -s -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"securepass123","name":"Alice"}' \
| python3 -m json.tool
Expected output:
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "alice@example.com",
"name": "Alice"
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
# Get own profile (use access token from register response)
ACCESS_TOKEN="paste_token_here"
curl -s http://localhost:3000/users/me \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| python3 -m json.tool
Expected output:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "alice@example.com",
"name": "Alice",
"created_at": "2026-04-22T10:00:00.000Z"
}
# Test validation — missing fields
curl -s -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"not-an-email","password":"short"}' \
| python3 -m json.tool
Expected output:
{
"error": "Validation failed",
"details": [
{"field": "email", "message": "Invalid email format"},
{"field": "password", "message": "Password must be at least 8 characters"},
{"field": "name", "message": "Required"}
]
}
Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Troubleshooting
Cannot use import statement in a module error
Fix: Ensure "type": "module" in package.json, or rename files to .mjs.
JWT JsonWebTokenError: invalid signature
Cause: Different secret used to sign vs verify.
Fix: Confirm JWT_SECRET is consistent in .env and not accidentally empty.
pg connection timeout
Fix: Check DATABASE_URL format — it must be postgresql://user:pass@host:port/db. Verify PostgreSQL is running: sudo systemctl is-active postgresql.
Conclusion
A production Node.js 22 + Express 5 API with: type-safe Zod validation, JWT auth with refresh token rotation, parameterised PostgreSQL queries via pg, security headers via helmet, and a global async error handler. The architecture — separate route files, middleware, and a database module — scales to large codebases without restructuring.
See Docker Compose Tutorial to containerise this API alongside PostgreSQL and Redis, and GitHub Actions CI/CD to automate testing and deployment on every push.
People Also Ask
Should I use Express or Fastify for a new Node.js API in 2026?
Express 5 is the safe default — massive ecosystem, familiar to every Node.js developer, excellent documentation. Fastify is 2–3× faster due to its schema-based serialisation, better TypeScript support, and built-in schema validation. Choose Fastify if raw throughput matters (high-traffic APIs, latency-sensitive services). Choose Express if team familiarity, ecosystem breadth, and a lower learning curve are priorities. For most CRUD APIs serving under 1,000 req/s, the performance difference is irrelevant — Express 5 is the right choice.
Should I use an ORM (Sequelize, Prisma) or raw SQL?
Prisma is the recommended ORM in 2026 for TypeScript projects — its generated client provides type-safe queries, migrations via prisma migrate, and a schema-first workflow. Raw pg queries (as used in this guide) are faster and give full SQL control — better for performance-critical paths or complex queries that ORMs generate poorly. A pragmatic approach: use Prisma for standard CRUD, drop to raw SQL for reporting queries and anything involving CTEs, window functions, or query optimisation.
Further Reading
- Build a REST API with FastAPI — the Python equivalent of this guide
- Docker Compose Tutorial — containerise this Node.js API
- How to Install PostgreSQL 17 on Ubuntu 24.04 — the database this API uses
- GitHub Actions CI/CD — automate test and deploy
Tested on: Ubuntu 24.04 LTS (Hetzner CX22). Node.js 22.11.0, Express 5.0.1, pg 8.13.0. Last verified: April 22, 2026.