Introduction to Hapi
Hapi (pronounced "happy") is a rich, battle-tested Node.js framework for building web applications and services. Originally created by Eran Hammer at Walmart Labs to handle Black Friday traffic, Hapi was designed from the ground up with enterprise-grade reliability, security, and developer productivity in mind. Unlike Express or Koa, which take a minimalist middleware-centric approach, Hapi provides a configuration-driven architecture where the framework itself manages the request lifecycle, leaving you to focus on business logic.
At its core, Hapi is built around three fundamental concepts: configuration over code, proven enterprise reliability, and developer-centric design. Every aspect of the framework—from routing to validation, authentication to caching—is expressed through declarative configuration objects. This makes Hapi applications incredibly predictable, self-documenting, and easy to maintain at scale.
Why Hapi Matters
In today's landscape of microservices and high-traffic APIs, Hapi offers several compelling advantages:
- Built-in input validation — Hapi integrates with Joi validation schemas at the routing level, rejecting invalid requests before they ever touch your handler logic
- Plugin-based architecture — Everything in Hapi is a plugin, from routes to authentication strategies, enabling true modular composition
- Comprehensive request lifecycle — Extension points throughout the lifecycle give you fine-grained control without messy middleware ordering
- Enterprise battle-tested — Hapi powers Walmart's infrastructure, handling billions of requests during Black Friday without incident
- Predictable error handling — Boom error objects provide consistent HTTP error responses across your entire application
- Zero dependency culture — The Hapi ecosystem favors well-maintained, curated packages over sprawling dependency trees
Getting Started with Hapi
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Let's begin our learning path by setting up a basic Hapi server. First, create a new project directory and install the required packages:
mkdir hapi-learning-path
cd hapi-learning-path
npm init -y
npm install @hapi/hapi @hapi/joi
Now create an index.js file with your first Hapi server. Every Hapi server begins by creating a server instance using Hapi.server(), which accepts a comprehensive configuration object:
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({
port: 3000,
host: 'localhost',
routes: {
cors: {
origin: ['*'],
headers: ['Accept', 'Authorization', 'Content-Type'],
additionalExposedHeaders: ['X-Total-Count']
}
}
});
// Define a simple route
server.route({
method: 'GET',
path: '/',
handler: (request, h) => {
return { message: 'Welcome to Hapi!', timestamp: new Date().toISOString() };
}
});
await server.start();
console.log(`Server running on ${server.info.uri}`);
};
init().catch(err => {
console.error('Server startup failed:', err);
process.exit(1);
});
Notice the handler signature: (request, h). The h object is Hapi's response toolkit—it provides methods for crafting responses, setting headers, redirecting, and more. Run the server with node index.js and visit http://localhost:3000 to see your first Hapi response.
Understanding Routes and Handlers
Routes in Hapi are defined using the server.route() method, which accepts either a single route configuration or an array of configurations. Each route configuration is a rich object that specifies the HTTP method, path, handler, and optional validation, authentication, and caching settings.
Route Configuration Deep Dive
server.route([
{
method: 'GET',
path: '/users/{id}',
handler: (request, h) => {
const { id } = request.params;
// Fetch user logic here
return { id, name: 'Jane Doe', email: 'jane@example.com' };
}
},
{
method: 'POST',
path: '/users',
handler: async (request, h) => {
const payload = request.payload;
// Create user logic here
const newUser = { id: '123', ...payload };
return h.response(newUser).code(201);
}
},
{
method: ['PUT', 'PATCH'],
path: '/users/{id}',
handler: (request, h) => {
return { updated: true, id: request.params.id };
}
}
]);
Hapi supports multiple HTTP methods per route definition and provides automatic parameter parsing from the path. Path parameters are accessible via request.params, while query parameters come from request.query. The response toolkit h.response() allows you to chain methods like .code(), .header(), and .state() for fine-grained response control.
Route Options for Advanced Control
server.route({
method: 'GET',
path: '/api/data',
options: {
description: 'Fetch paginated data',
notes: 'Returns a paginated list of items',
tags: ['api', 'data', 'read'],
auth: 'simple-auth',
validate: {
query: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20)
})
},
response: {
schema: Joi.object({
items: Joi.array(),
total: Joi.number(),
page: Joi.number()
})
},
handler: (request, h) => {
const { page, limit } = request.query;
// Business logic here
return { items: [], total: 0, page };
}
}
});
The options object is where Hapi truly shines. You can document routes with description, notes, and tags, apply validation schemas to inputs and outputs, and specify authentication requirements—all in one declarative block.
The Request Lifecycle and Extension Points
Understanding Hapi's request lifecycle is essential for progressing beyond beginner level. Every incoming request flows through a predetermined sequence of steps, and Hapi exposes extension points where you can hook in custom logic.
The lifecycle proceeds in this order:
- onRequest — The raw incoming request arrives; authentication headers are parsed but not yet validated
- onPreAuth — Request is fully parsed; ideal for setting up authentication prerequisites
- onCredentials — Authentication credentials are validated (auth strategies run here)
- onPostAuth — Authentication is complete; authorization checks typically occur here
- onPreHandler — Right before the route handler executes; perfect for pre-handler logic like loading context
- onPostHandler — After the handler runs but before the response is sent; modify responses here
- onPreResponse — Final step before response transmission; catch-all for error transformation
Using Extension Points
Extensions are registered via server.ext() and can operate on the entire server or specific routes:
// Global extension - runs for every request
server.ext('onPreHandler', (request, h) => {
request.headers['x-request-start'] = Date.now();
return h.continue; // Must return h.continue or a takeover response
});
// Capture timing on every request
server.ext('onPostHandler', (request, h) => {
const startTime = request.headers['x-request-start'];
if (startTime && request.response.statusCode) {
const duration = Date.now() - parseInt(startTime);
request.response.header('X-Response-Time', `${duration}ms`);
}
return h.continue;
});
// Global error logging
server.ext('onPreResponse', (request, h) => {
const response = request.response;
if (response.isBoom) {
console.error(`Error ${response.output.statusCode}: ${response.message}`);
}
return h.continue;
});
Each extension point expects you to return either h.continue to pass control forward, or a different response to short-circuit the lifecycle (a "takeover response"). This gives you surgical control without the chaos of middleware ordering.
Route-Level Extensions
You can also attach extensions to specific routes via the options.ext property:
server.route({
method: 'GET',
path: '/admin/dashboard',
options: {
ext: {
onPreHandler: {
method: (request, h) => {
if (!request.auth.credentials.isAdmin) {
return h.response({ error: 'Admin access required' }).code(403).takeover();
}
return h.continue;
}
}
},
handler: (request, h) => {
return { dashboard: 'admin data' };
}
}
});
Plugins: The Heart of Hapi
Plugins are the fundamental building block of Hapi applications. Unlike Express middleware, which is a flat pipeline, Hapi plugins are self-contained modules with their own lifecycle, dependencies, and isolated scope. This modularity enables teams to build complex applications from composable, testable pieces.
Creating a Plugin
A plugin is defined by an object with a register function and a name property. The register function receives the server instance and options:
// plugins/database.js
const databasePlugin = {
name: 'database-plugin',
version: '1.0.0',
register: async (server, options) => {
const { connectionString, poolSize } = options;
// Simulate database connection
const db = {
connected: true,
query: async (sql) => {
console.log(`Executing: ${sql}`);
return [{ id: 1, data: 'result' }];
}
};
// Expose db instance to other plugins and handlers
server.decorate('request', 'db', db);
server.decorate('server', 'db', db);
// Cleanup on server stop
server.events.on('stop', async () => {
console.log('Closing database connections...');
});
console.log('Database plugin registered with pool size:', poolSize);
}
};
Registering Plugins
Plugins are registered using server.register(), which accepts a plugin, an array of plugins, or a configuration object with plugin and options:
const Hapi = require('@hapi/hapi');
const databasePlugin = require('./plugins/database');
const routesPlugin = require('./plugins/routes');
const authPlugin = require('./plugins/auth');
const init = async () => {
const server = Hapi.server({ port: 3000 });
// Sequential registration with dependencies
await server.register([
{
plugin: databasePlugin,
options: { connectionString: process.env.DB_URL, poolSize: 10 }
},
{
plugin: authPlugin,
options: { secret: process.env.JWT_SECRET }
},
{
plugin: routesPlugin
}
]);
await server.start();
console.log('All plugins registered and server started');
};
Plugin Dependencies
Plugins can declare dependencies using the dependencies property, ensuring proper loading order:
const userRoutesPlugin = {
name: 'user-routes',
version: '1.0.0',
dependencies: ['database-plugin', 'auth-plugin'],
register: async (server, options) => {
server.route({
method: 'GET',
path: '/users',
handler: async (request, h) => {
const users = await request.db.query('SELECT * FROM users');
return users;
}
});
}
};
Hapi will automatically verify that database-plugin and auth-plugin are registered before user-routes loads. If a dependency is missing, the server will refuse to start and provide a clear error message.
Decorating the Server and Request
The server.decorate() method is how plugins share functionality. You can decorate server, request, or toolkit (h) objects:
// Inside a plugin's register function
server.decorate('server', 'getConfig', function (key) {
return this.app.config[key];
});
server.decorate('request', 'getUserLocale', function () {
return this.headers['accept-language'] || 'en-US';
});
// Usage in a handler
handler: (request, h) => {
const locale = request.getUserLocale();
const config = request.server.getConfig('apiVersion');
return { locale, config };
}
Validation with Joi
Hapi's tight integration with Joi validation is one of its most powerful features. Joi is a schema description language and validator for JavaScript objects, and Hapi uses it to validate route inputs and outputs automatically. This means invalid data never reaches your handler, dramatically reducing defensive code.
Comprehensive Route Validation
const Joi = require('@hapi/joi');
server.route({
method: 'POST',
path: '/api/orders',
options: {
validate: {
// Headers validation
headers: Joi.object({
'content-type': Joi.string().valid('application/json').required(),
'x-client-id': Joi.string().alphanum().required()
}).options({ allowUnknown: true }),
// Path parameters
params: Joi.object({
userId: Joi.string().guid({ version: 'uuidv4' })
}),
// Query parameters
query: Joi.object({
discount: Joi.number().min(0).max(100).default(0),
priority: Joi.boolean().default(false)
}),
// Request payload
payload: Joi.object({
items: Joi.array().items(
Joi.object({
productId: Joi.string().required(),
quantity: Joi.number().integer().min(1).required(),
price: Joi.number().positive().precision(2).required()
})
).min(1).max(50).required(),
shippingAddress: Joi.object({
street: Joi.string().required(),
city: Joi.string().required(),
zip: Joi.string().pattern(/^\d{5}(-\d{4})?$/).required(),
country: Joi.string().length(2).uppercase().default('US')
}).required()
}).required()
},
// Response validation (optional but recommended)
response: {
schema: Joi.object({
orderId: Joi.string().guid({ version: 'uuidv4' }).required(),
status: Joi.string().valid('confirmed', 'pending', 'rejected').required(),
total: Joi.number().positive().required(),
estimatedDelivery: Joi.date().iso().required()
}),
failAction: 'log' // Log validation failures instead of throwing
},
handler: async (request, h) => {
const order = request.payload;
// Business logic - payload is already validated!
const orderId = generateOrderId();
return { orderId, status: 'confirmed', total: calculateTotal(order) };
}
}
});
Custom Validation Fail Actions
Hapi allows you to customize what happens when validation fails using failAction:
// Default: sends 400 with validation details
failAction: 'error' // (default)
// Silently logs and continues
failAction: 'log'
// Silently ignores validation failure
failAction: 'ignore'
// Custom handler
failAction: (request, h, error) => {
return h.response({
success: false,
message: 'Invalid input',
details: error.details.map(d => ({
field: d.path.join('.'),
message: d.message
}))
}).code(422).takeover();
}
Reusable Validation Schemas
// schemas/user.js
const Joi = require('@hapi/joi');
const userSchemas = {
create: Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required(),
name: Joi.string().min(2).max(100).required(),
role: Joi.string().valid('user', 'admin').default('user')
}),
update: Joi.object({
name: Joi.string().min(2).max(100),
email: Joi.string().email(),
role: Joi.string().valid('user', 'admin')
}).min(1), // At least one field required
response: Joi.object({
id: Joi.string().required(),
email: Joi.string().email().required(),
name: Joi.string().required(),
role: Joi.string().required(),
createdAt: Joi.date().iso().required()
})
};
module.exports = userSchemas;
Authentication and Authorization
Hapi's authentication system is built on schemes and strategies. A scheme is a general authentication method (like Basic auth or JWT), while a strategy is a configured instance of a scheme with specific options. This separation allows you to define multiple strategies for different routes using the same underlying scheme.
Implementing JWT Authentication
First, install the JWT plugin for Hapi:
npm install @hapi/jwt
Now configure a JWT authentication strategy:
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({ port: 3000 });
// Register JWT plugin
await server.register(require('@hapi/jwt'));
// Define a JWT strategy
server.auth.strategy('jwt', 'jwt', {
keys: [
{
// Use a strong secret in production, preferably from env vars
key: process.env.JWT_SECRET || 'supersecretkey-change-in-production',
algorithms: ['HS256']
}
],
verify: {
aud: 'urn:api-client',
iss: 'urn:api-server',
sub: false // Allow any subject
},
validate: (artifacts, request, h) => {
// artifacts.decoded contains the decoded JWT payload
const { userId, role, scope } = artifacts.decoded;
return {
isValid: true,
credentials: {
user: userId,
role: role,
scope: scope || []
}
};
}
});
// Set default auth for all routes
server.auth.default('jwt');
// Protected route
server.route({
method: 'GET',
path: '/api/protected',
handler: (request, h) => {
return {
message: `Hello, ${request.auth.credentials.user}!`,
role: request.auth.credentials.role
};
}
});
// Public route (override default auth)
server.route({
method: 'GET',
path: '/api/public',
options: { auth: false },
handler: (request, h) => {
return { message: 'This endpoint is public' };
}
});
};
Scope-Based Authorization
Hapi supports access control through scopes. Define required scopes in your route configuration:
// Admin-only route
server.route({
method: 'DELETE',
path: '/api/users/{id}',
options: {
auth: {
strategy: 'jwt',
access: {
scope: ['admin', 'superadmin']
}
},
handler: async (request, h) => {
// Only users with admin or superadmin scope can access
await deleteUser(request.params.id);
return { deleted: true };
}
}
});
// Route with multiple possible scopes
server.route({
method: 'GET',
path: '/api/reports',
options: {
auth: {
strategy: 'jwt',
access: {
scope: ['reports.read', 'admin']
}
},
handler: (request, h) => {
return { reports: [] };
}
}
});
Multiple Authentication Strategies
Hapi allows you to specify multiple strategies per route. The mode parameter controls whether all strategies must pass or just one:
server.auth.strategy('api-key', 'basic', {
validate: async (request, username, password, h) => {
// Validate API key logic
const isValid = await checkApiKey(username, password);
return { isValid, credentials: { apiKey: username } };
}
});
// Route that accepts either JWT or API key
server.route({
method: 'GET',
path: '/api/flexible-access',
options: {
auth: {
strategies: ['jwt', 'api-key'],
mode: 'optional' // 'required' (all must pass) or 'optional' (at least one)
},
handler: (request, h) => {
const authMethod = request.auth.strategy;
return { accessedVia: authMethod };
}
}
});
Caching in Hapi
Hapi provides a powerful server methods caching system that integrates seamlessly with your application. Server methods are functions whose results are cached automatically based on their arguments and a configurable expiration policy.
Defining Cached Server Methods
const init = async () => {
const server = Hapi.server({ port: 3000 });
// Define a server method with caching
server.method('fetchProduct', async (productId) => {
// Simulate expensive database operation
console.log(`Fetching product ${productId} from database...`);
await new Promise(resolve => setTimeout(resolve, 200));
return {
id: productId,
name: 'Widget Pro',
price: 29.99,
stock: 42
};
}, {
cache: {
expiresIn: 30 * 60 * 1000, // 30 minutes in milliseconds
generateTimeout: 5000, // Max time to generate a new value
getTimeout: 2000, // Max time to retrieve from cache
stale: 'update', // Serve stale while updating in background
},
generateKey: (productId) => `product:${productId}` // Custom cache key
});
// Use the cached method in a route
server.route({
method: 'GET',
path: '/products/{id}',
handler: async (request, h) => {
const product = await server.methods.fetchProduct(request.params.id);
// First call: hits database, subsequent calls: serves from cache
return product;
}
});
};
Configuring Cache Providers
By default, Hapi uses an in-memory cache. For production, you'll want a distributed cache like Redis:
npm install @hapi/catbox-redis
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({
port: 3000,
cache: [
{
name: 'redisCache',
provider: {
constructor: require('@hapi/catbox-redis'),
options: {
partition: 'hapi-cache',
host: process.env.REDIS_HOST || 'localhost',
port: 6379,
password: process.env.REDIS_PASSWORD
}
}
}
]
});
server.method('expensiveQuery', async (query) => {
// Complex query logic
return results;
}, {
cache: {
cache: 'redisCache', // Reference the named cache provider
expiresIn: 15 * 60 * 1000,
stale: 'update'
},
generateKey: (query) => `query:${hashQuery(query)}`
});
};
Cache Policy Tuning
server.method('userProfile', fetchUserProfile, {
cache: {
expiresIn: 60 * 60 * 1000, // 1 hour
stale: 'update', // Serve stale data while regenerating
staleTimeout: 100, // Max 100ms to serve stale before failing
generateTimeout: 10000, // 10 seconds to generate fresh data
dropOnError: true, // Don't cache errored responses
getTimeout: false // No timeout when reading from cache
},
generateKey: (userId, options) => {
return `userProfile:${userId}:${options.includeHistory ? 'full' : 'basic'}`;
}
});
The stale: 'update' option is particularly valuable—when a cached value expires, Hapi will immediately serve the stale value while asynchronously generating a fresh one, ensuring users never wait for cache regeneration.
Error Handling and Lifecycle
Hapi uses the Boom library for HTTP-friendly error objects. Boom errors are rich objects that carry HTTP status codes, messages, and optional payload data. Combined with the request lifecycle, Hapi's error handling is both powerful and predictable.
Throwing Boom Errors
const Boom = require('@hapi/boom');
server.route({
method: 'GET',
path: '/api/resource/{id}',
handler: async (request, h) => {
const resource = await findResource(request.params.id);
if (!resource) {
throw Boom.notFound('Resource not found', {
resourceId: request.params.id,
timestamp: new Date().toISOString()
});
}
if (resource.accessLevel > request.auth.credentials.clearance) {
throw Boom.forbidden('Insufficient clearance level');
}
return resource;
}
});
Global Error Transformation
Use the onPreResponse extension point to transform all errors before they reach the client:
server.ext('onPreResponse', (request, h) => {
const response = request.response;
if (response.isBoom) {
// Transform Boom errors into a consistent API format
const formattedError = {
success: false,
error: {
code: response.output.statusCode,
type: response.output.payload.error,
message: response.message || response.output.payload.message,
details: response.data || null
},
requestId: request.id
};
return h.response(formattedError).code(response.output.statusCode);
}
return h.continue;
});
Custom Boom Errors
// Create custom Boom errors with additional data
const createValidationError = (details) => {
return Boom.badRequest('Validation failed', {
validationErrors: details.map(d => ({
field: d.path.join('.'),
constraint: d.type,
message: d.message
}))
});
};
// Usage in a handler
handler: (request, h) => {
const { error } = schema.validate(request.payload);
if (error) {
throw createValidationError(error.details);
}
return processPayload(request.payload);
}
Async Handler Error Handling
Hapi automatically catches both synchronous exceptions and rejected promises in async handlers:
server.route({
method: 'GET',
path: '/api/external-data',
handler: async (request, h) => {
// If fetch() throws or rejects, Hapi wraps it in Boom.serverUnavailable
const response = await fetch('https://external-api.com/data');
if (!response.ok) {
throw Boom.badGateway('External API returned an error');
}
return await response.json();
}
});
Testing Hapi Applications
Hapi's architecture makes testing remarkably straightforward through server injection. The server.inject() method allows you to simulate HTTP requests without actually opening network connections, giving you fast, reliable integration tests.
Basic Route Testing
const Hapi = require('@hapi/hapi');
// Create a testable server factory
const createTestServer = async () => {
const server = Hapi.server({ port: 0 }); // Port 0 = random available port
server.route({
method: 'GET',
path: '/health',
handler: () => ({ status: 'ok' })
});
return server;
};
// Test file using a test runner like Jest or Mocha
describe('Health Check Route', () => {
let server;
beforeAll(async () => {
server = await createTestServer();
await server.initialize(); // Initialize but don't start listening
});
afterAll(async () => {
await server.stop();
});
test('GET /health returns 200', async () => {
const res = await server.inject({
method: 'GET',
url: '/health'
});
expect(res.statusCode).toBe(200);
expect(res.result).toEqual({ status: 'ok' });
});
});
Testing Authenticated Routes
test('GET /api/protected requires authentication', async () => {
// Without auth headers
const res = await server.inject({
method: 'GET',
url: '/api/protected'
});
expect(res.statusCode).toBe(401);
});
test('GET /api/protected with valid token succeeds', async () => {
const token = generateTestToken({ userId: 'test-user', role: 'user' });
const res = await server.inject({
method: 'GET',
url: '/api/protected',
headers: {
authorization: `Bearer ${token}`
}
});
expect(res.statusCode).toBe(200);
expect(res.result.message).toContain('test-user');
});
Testing Request Payloads and Validation
test('POST /api/orders validates payload', async () => {
const res = await server.inject({
method: 'POST',
url: '/api/orders',
payload: {
items: [], // Empty array - should fail validation
shippingAddress: { street: '123 Main' }
}
});
expect(res.statusCode).toBe(400);
expect(res.result.error).toBeDefined();
});
test('POST /api/orders with valid payload succeeds', async () => {
const res = await server.inject({
method: 'POST',
url: '/api/orders',
payload: {
items: [
{ productId: 'prod-123', quantity: 2, price: 19.99 }
],
shippingAddress: {
street: '123 Main St',
city: 'Anytown',
zip: '12345'
}
}
});
expect(res.statusCode).toBe(200);
expect(res.result.orderId).toBeDefined();
});
Testing Plugins in Isolation
const testPlugin = async (plugin, options = {}) => {
const server = Hapi.server({ port: 0 });
await server.register({ plugin, options });
await server.initialize();
return server;
};
describe('Database Plugin', () => {
test('decorates server and request with db', async () => {
const server = await testPlugin(databasePlugin, {
connectionString: 'test://localhost'
});
expect(server.db).toBeDefined();
expect(server.db.connected).toBe(true);
const res = await server.inject({
method: 'GET',
url: '/test-db-access',
handler: (