
Introduction
Building reliable REST APIs requires more than routing requests and returning JSON. You need validation, clear configuration, strong defaults, and predictable behavior across all endpoints. Hapi.js is a powerful Node.js framework designed with these goals in mind. Unlike minimal frameworks that leave decisions to you, Hapi emphasizes configuration-driven design, built-in validation, and explicit control over every aspect of the request lifecycle.
In this comprehensive guide, you will learn how to build REST APIs with Hapi.js from the ground up. You will understand its core concepts including the request lifecycle, plugin architecture, and validation system. By the end, you will be able to create clean, secure, and maintainable backend services that scale with your application’s needs.
Why Choose Hapi.js for REST APIs
Although Express dominates many Node.js projects, Hapi.js offers a fundamentally different philosophy. It focuses on safety, clarity, and strong defaults, which makes it attractive for long-term backend systems where maintainability matters as much as initial development speed.
- Configuration-first approach: Define behavior through objects instead of middleware chains
- Built-in request validation: Joi integration validates input before your handler runs
- Strong plugin system: Organize code into modular, reusable components
- Explicit lifecycle hooks: Control authentication, parsing, and response at precise points
- Enterprise-ready defaults: Security headers, input sanitization, and error handling built in
Hapi was created by Walmart Labs to handle Black Friday traffic. Its design prioritizes reliability and predictability over flexibility.
Project Setup
Start by creating a new Node.js project with Hapi and its core dependencies.
# Create project
mkdir hapi-api && cd hapi-api
npm init -y
# Install dependencies
npm install @hapi/hapi @hapi/joi @hapi/boom @hapi/jwt
# Development dependencies
npm install -D typescript @types/node ts-node nodemon
// src/server.ts
import Hapi from '@hapi/hapi';
const init = async () => {
const server = Hapi.server({
port: process.env.PORT || 3000,
host: 'localhost',
routes: {
cors: {
origin: ['*'],
headers: ['Accept', 'Content-Type', 'Authorization'],
},
validate: {
failAction: async (request, h, err) => {
// In production, log error and return generic message
if (process.env.NODE_ENV === 'production') {
throw Boom.badRequest('Invalid request payload');
}
// In development, return detailed error
throw err;
},
},
},
});
// Health check route
server.route({
method: 'GET',
path: '/health',
handler: () => ({ status: 'healthy', timestamp: new Date().toISOString() }),
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
init().catch((err) => {
console.error(err);
process.exit(1);
});
Understanding the Request Lifecycle
Hapi processes every request through a well-defined lifecycle with extension points at each stage. Understanding this lifecycle is essential for building robust APIs.
// Request lifecycle stages (in order):
// 1. onRequest - earliest point, before routing
// 2. onPreAuth - before authentication
// 3. onCredentials - after credentials identified
// 4. onPostAuth - after authentication complete
// 5. onPreHandler - before route handler
// 6. handler - your route handler executes
// 7. onPostHandler - after handler, before response
// 8. onPreResponse - last chance before response sent
server.ext('onPreHandler', async (request, h) => {
// Log all requests before handling
console.log(`${request.method.toUpperCase()} ${request.path}`);
return h.continue;
});
server.ext('onPreResponse', async (request, h) => {
const response = request.response;
// Transform Boom errors into consistent format
if (response.isBoom) {
const error = response.output;
return h.response({
success: false,
error: {
statusCode: error.statusCode,
message: error.payload.message,
},
}).code(error.statusCode);
}
return h.continue;
});
Building RESTful Routes
Hapi routes are defined declaratively with configuration objects. This makes routes self-documenting and easy to understand.
// src/routes/users.ts
import Hapi from '@hapi/hapi';
import Joi from '@hapi/joi';
import Boom from '@hapi/boom';
// In-memory store for example (use database in production)
let users: User[] = [];
let nextId = 1;
interface User {
id: number;
username: string;
email: string;
createdAt: Date;
}
const userRoutes: Hapi.ServerRoute[] = [
// GET /users - List all users
{
method: 'GET',
path: '/users',
options: {
description: 'Get all users',
tags: ['api', 'users'],
validate: {
query: Joi.object({
limit: Joi.number().integer().min(1).max(100).default(10),
offset: Joi.number().integer().min(0).default(0),
}),
},
},
handler: async (request) => {
const { limit, offset } = request.query;
const paginatedUsers = users.slice(offset, offset + limit);
return {
success: true,
data: paginatedUsers,
pagination: {
total: users.length,
limit,
offset,
},
};
},
},
// GET /users/{id} - Get single user
{
method: 'GET',
path: '/users/{id}',
options: {
description: 'Get user by ID',
tags: ['api', 'users'],
validate: {
params: Joi.object({
id: Joi.number().integer().required(),
}),
},
},
handler: async (request) => {
const user = users.find((u) => u.id === request.params.id);
if (!user) {
throw Boom.notFound('User not found');
}
return { success: true, data: user };
},
},
// POST /users - Create user
{
method: 'POST',
path: '/users',
options: {
description: 'Create a new user',
tags: ['api', 'users'],
validate: {
payload: Joi.object({
username: Joi.string().min(3).max(30).required(),
email: Joi.string().email().required(),
}),
},
},
handler: async (request, h) => {
const { username, email } = request.payload as { username: string; email: string };
// Check for duplicate email
if (users.some((u) => u.email === email)) {
throw Boom.conflict('Email already registered');
}
const user: User = {
id: nextId++,
username,
email,
createdAt: new Date(),
};
users.push(user);
return h.response({ success: true, data: user }).code(201);
},
},
// PUT /users/{id} - Update user
{
method: 'PUT',
path: '/users/{id}',
options: {
description: 'Update user',
tags: ['api', 'users'],
validate: {
params: Joi.object({
id: Joi.number().integer().required(),
}),
payload: Joi.object({
username: Joi.string().min(3).max(30),
email: Joi.string().email(),
}).min(1), // At least one field required
},
},
handler: async (request) => {
const index = users.findIndex((u) => u.id === request.params.id);
if (index === -1) {
throw Boom.notFound('User not found');
}
const payload = request.payload as Partial;
users[index] = { ...users[index], ...payload };
return { success: true, data: users[index] };
},
},
// DELETE /users/{id} - Delete user
{
method: 'DELETE',
path: '/users/{id}',
options: {
description: 'Delete user',
tags: ['api', 'users'],
validate: {
params: Joi.object({
id: Joi.number().integer().required(),
}),
},
},
handler: async (request, h) => {
const index = users.findIndex((u) => u.id === request.params.id);
if (index === -1) {
throw Boom.notFound('User not found');
}
users.splice(index, 1);
return h.response().code(204);
},
},
];
export default userRoutes;
Validation with Joi
Joi is Hapi’s companion library for schema validation. It validates request payloads, query parameters, headers, and route parameters before your handler executes.
// src/validation/schemas.ts
import Joi from '@hapi/joi';
// Reusable validation schemas
export const schemas = {
// User schemas
createUser: Joi.object({
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required()
.messages({
'string.alphanum': 'Username must only contain letters and numbers',
'string.min': 'Username must be at least 3 characters',
'string.max': 'Username cannot exceed 30 characters',
}),
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
password: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.required()
.messages({
'string.pattern.base': 'Password must contain uppercase, lowercase, and number',
}),
}),
// Pagination schema (reusable)
pagination: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sortBy: Joi.string().valid('createdAt', 'username', 'email').default('createdAt'),
sortOrder: Joi.string().valid('asc', 'desc').default('desc'),
}),
// ID parameter
idParam: Joi.object({
id: Joi.number().integer().positive().required(),
}),
// UUID parameter
uuidParam: Joi.object({
id: Joi.string().uuid().required(),
}),
};
Authentication with JWT
Hapi provides first-class support for authentication through strategies. The JWT plugin makes token-based auth straightforward.
// src/plugins/auth.ts
import Hapi from '@hapi/hapi';
import Jwt from '@hapi/jwt';
import Boom from '@hapi/boom';
export const authPlugin: Hapi.Plugin<{}> = {
name: 'auth',
register: async (server) => {
await server.register(Jwt);
server.auth.strategy('jwt', 'jwt', {
keys: process.env.JWT_SECRET || 'your-secret-key',
verify: {
aud: false,
iss: false,
sub: false,
maxAgeSec: 86400, // 24 hours
},
validate: async (artifacts, request, h) => {
// artifacts.decoded contains the decoded token
const { payload } = artifacts.decoded;
// Verify user still exists and is active
const user = await findUserById(payload.userId);
if (!user || !user.isActive) {
return { isValid: false };
}
return {
isValid: true,
credentials: {
userId: payload.userId,
role: payload.role,
scope: payload.permissions || [],
},
};
},
});
// Set as default strategy
server.auth.default('jwt');
},
};
// Route with authentication
server.route({
method: 'GET',
path: '/profile',
options: {
auth: 'jwt', // Requires authentication
},
handler: async (request) => {
const { userId } = request.auth.credentials;
const user = await findUserById(userId);
return { success: true, data: user };
},
});
// Route without authentication
server.route({
method: 'GET',
path: '/public',
options: {
auth: false, // No authentication required
},
handler: () => ({ message: 'Public endpoint' }),
});
// Route with optional authentication
server.route({
method: 'GET',
path: '/posts',
options: {
auth: {
mode: 'optional', // Auth not required but credentials available if provided
},
},
handler: async (request) => {
const userId = request.auth.credentials?.userId;
// Show personalized content if authenticated
return getPosts(userId);
},
});
Building Plugins for Modular Architecture
Plugins are a core part of Hapi’s design. They let you organize routes, services, and configuration into reusable modules.
// src/plugins/users/index.ts
import Hapi from '@hapi/hapi';
import userRoutes from './routes';
import { UserService } from './service';
export const usersPlugin: Hapi.Plugin<{ prefix?: string }> = {
name: 'users',
version: '1.0.0',
dependencies: ['auth'], // Requires auth plugin
register: async (server, options) => {
// Initialize service
const userService = new UserService(server.app.db);
// Decorate server with service
server.decorate('server', 'userService', userService);
// Register routes with optional prefix
const prefix = options.prefix || '/api';
server.route(
userRoutes.map((route) => ({
...route,
path: `${prefix}${route.path}`,
}))
);
server.log(['users', 'info'], 'Users plugin registered');
},
};
// Register in main server
await server.register([
{ plugin: authPlugin },
{ plugin: usersPlugin, options: { prefix: '/api/v1' } },
]);
Error Handling with Boom
Boom provides HTTP-friendly error objects that Hapi understands. Use Boom throughout your handlers for consistent error responses.
import Boom from '@hapi/boom';
// Common error patterns
throw Boom.notFound('User not found');
throw Boom.badRequest('Invalid input');
throw Boom.unauthorized('Invalid credentials');
throw Boom.forbidden('Insufficient permissions');
throw Boom.conflict('Resource already exists');
throw Boom.internal('Something went wrong');
// Custom error data
throw Boom.badRequest('Validation failed', {
fields: {
email: 'Invalid email format',
username: 'Username already taken',
},
});
Testing Hapi APIs
Hapi includes server.inject() for testing routes without starting a real HTTP server. This makes tests fast and reliable.
// tests/users.test.ts
import { createServer } from '../src/server';
describe('Users API', () => {
let server;
beforeAll(async () => {
server = await createServer();
});
afterAll(async () => {
await server.stop();
});
describe('POST /users', () => {
it('creates a user with valid data', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/users',
payload: {
username: 'testuser',
email: 'test@example.com',
},
});
expect(response.statusCode).toBe(201);
const data = JSON.parse(response.payload);
expect(data.success).toBe(true);
expect(data.data.username).toBe('testuser');
});
it('returns 400 for invalid email', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/users',
payload: {
username: 'testuser',
email: 'invalid-email',
},
});
expect(response.statusCode).toBe(400);
});
});
describe('GET /users/{id}', () => {
it('returns 404 for non-existent user', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/users/99999',
});
expect(response.statusCode).toBe(404);
});
});
});
When to Use Hapi.js
Hapi.js excels in specific scenarios. Understanding when it fits helps you make the right framework choice.
Hapi Is Ideal For
- Enterprise applications: Where predictability and maintainability matter more than rapid prototyping
- Teams with strict standards: Configuration-driven design enforces consistency
- APIs requiring strong validation: Joi integration catches errors before they reach handlers
- Long-lived projects: Explicit code ages better than middleware magic
Consider Alternatives When
- Building quick prototypes: Express or Fastify offer faster initial development
- Minimal overhead needed: Hapi’s structure adds weight to simple projects
- Team prefers middleware patterns: Hapi’s configuration approach requires adjustment
Conclusion
Hapi.js offers a robust and structured approach to building REST APIs in Node.js. Its configuration-driven design, built-in validation with Joi, strong plugin system, and explicit lifecycle control make it ideal for long-term backend projects that value clarity and safety. While it requires more upfront structure than minimal frameworks, this investment pays off in maintainability, testability, and reliability as your application grows.
To compare different backend frameworks and their trade-offs, read Framework Showdown: Flask vs FastAPI vs Django in 2025. For authentication patterns that work across Node.js frameworks, see OAuth2, JWT, and Session Tokens Explained. To understand testing strategies for your APIs, explore Unit Testing in JavaScript with Jest and Vitest. Reference the official Hapi.js documentation and the Joi documentation for the latest APIs and best practices.