Building Full-Stack Apps with Hapi
What is Hapi?
Hapi (short for "HTTP API") is a rich framework for building applications and services in Node.js. Unlike Express, which provides minimal abstraction, Hapi comes with a powerful plugin system, built-in input validation, caching, authentication, and a configuration-driven design. It was originally developed by Walmart Labs to handle high-traffic e-commerce systems and has since become a trusted choice for enterprise-grade full-stack applications.
Why Hapi Matters for Full-Stack Development
When building a full-stack application, you need a backend that can handle complex routing, data validation, authentication, and seamless integration with front-end technologies. Hapi offers:
- Configuration over code β Many features (validation, caching, logging) are declarative, reducing boilerplate.
- Plugin ecosystem β Reusable modules for authentication (JWT, Basic), templating (Handlebars, Pug), and database connectivity.
- Built-in validation β Using Joi, you can define strict schemas for request payloads, query parameters, and headers.
- Error handling β Standardized error responses with
Boomobjects. - Full-stack ready β Serve static files, render server-side templates, or act as a pure API consumed by a separate SPA.
Setting Up a Hapi Project
Start with a new Node.js project and install Hapi:
mkdir hapi-fullstack
cd hapi-fullstack
npm init -y
npm install @hapi/hapi
Create a simple server file, server.js:
'use strict';
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({
port: 3000,
host: 'localhost'
});
server.route({
method: 'GET',
path: '/',
handler: (request, h) => {
return 'Hello, Full-Stack Hapi!';
}
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
process.on('unhandledRejection', (err) => {
console.log(err);
process.exit(1);
});
init();
Run with node server.js and visit http://localhost:3000.
Building the Backend API with Hapi
Hapi routes are objects with a method, path, handler, and optional options for validation and authentication. Use @hapi/joi for input validation:
npm install @hapi/joi
Example REST endpoints for a simple task manager:
const Joi = require('@hapi/joi');
let tasks = [];
// CREATE a task
server.route({
method: 'POST',
path: '/tasks',
options: {
validate: {
payload: Joi.object({
title: Joi.string().min(1).max(100).required(),
completed: Joi.boolean().default(false)
})
}
},
handler: (request, h) => {
const task = {
id: tasks.length + 1,
title: request.payload.title,
completed: request.payload.completed
};
tasks.push(task);
return h.response(task).code(201);
}
});
// READ all tasks
server.route({
method: 'GET',
path: '/tasks',
handler: (request, h) => {
return tasks;
}
});
// READ a single task
server.route({
method: 'GET',
path: '/tasks/{id}',
options: {
validate: {
params: Joi.object({
id: Joi.number().integer().min(1).required()
})
}
},
handler: (request, h) => {
const task = tasks.find(t => t.id === request.params.id);
if (!task) {
return h.response({ error: 'Task not found' }).code(404);
}
return task;
}
});
Integrating a Frontend (Static Files and Templates)
Hapi can serve static files (HTML, CSS, JS) and render server-side views. Install the inert plugin for static files and vision for template engines:
npm install @hapi/inert @hapi/vision handlebars
Register plugins and configure Handlebars views:
await server.register([
require('@hapi/inert'),
require('@hapi/vision')
]);
server.views({
engines: {
html: require('handlebars')
},
relativeTo: __dirname,
path: 'views',
layout: true,
layoutPath: 'views/layouts'
});
Create a views folder with index.html:
<!DOCTYPE html>
<html>
<head>
<title>Task Manager</title>
</head>
<body>
<h1>Tasks</h1>
<ul>
{{#each tasks}}
<li>{{title}} - {{#if completed}}Done{{else}}Pending{{/if}}</li>
{{/each}}
</ul>
<script src="/public/app.js"></script>
</body>
</html>
Serve static assets from a public folder:
server.route({
method: 'GET',
path: '/public/{param*}',
handler: {
directory: {
path: 'public'
}
}
});
Now the server can render a page that lists tasks:
server.route({
method: 'GET',
path: '/',
handler: (request, h) => {
return h.view('index', { tasks });
}
});
Connecting to a Database (MongoDB Example)
For persistence, integrate MongoDB via Mongoose:
npm install mongoose
Connect in server.js:
const mongoose = require('mongoose');
await mongoose.connect('mongodb://localhost:27017/hapi-tasks', {
useNewUrlParser: true,
useUnifiedTopology: true
});
Define a Task model:
const taskSchema = new mongoose.Schema({
title: { type: String, required: true, maxlength: 100 },
completed: { type: Boolean, default: false }
});
const Task = mongoose.model('Task', taskSchema);
Update the POST route to save to MongoDB:
handler: async (request, h) => {
const task = new Task({
title: request.payload.title,
completed: request.payload.completed
});
const saved = await task.save();
return h.response(saved).code(201);
}
Authentication and Authorization
Hapi makes authentication easy with plugins like hapi-auth-jwt2. Install it:
npm install hapi-auth-jwt2 jsonwebtoken
Register the authentication scheme and strategy:
const Jwt = require('@hapi/jwt'); // or hapi-auth-jwt2
const jwt = require('jsonwebtoken');
await server.register(require('hapi-auth-jwt2'));
server.auth.strategy('jwt', 'jwt', {
key: 'your-secret-key',
validate: async (decoded, request) => {
// Check if user exists in database
const user = await User.findById(decoded.id);
if (!user) {
return { isValid: false };
}
return { isValid: true, credentials: { id: user.id, scope: user.role } };
},
verifyOptions: { algorithms: ['HS256'] }
});
server.auth.default('jwt'); // protect all routes by default
Create a login route that issues tokens:
server.route({
method: 'POST',
path: '/login',
options: { auth: false }, // public
handler: async (request, h) => {
const { email, password } = request.payload;
// validate user credentials (simplified)
const user = await User.findOne({ email });
if (!user || user.password !== password) {
return h.response({ error: 'Invalid credentials' }).code(401);
}
const token = jwt.sign({ id: user.id }, 'your-secret-key', { expiresIn: '1h' });
return { token };
}
});
Best Practices
- Use environment variables β Store secrets, database URIs, and ports in
.envfiles withdotenv. - Structure your project β Separate routes, models, plugins, and config into distinct modules. Use a
libfolder. - Validate everything β Always validate input with Joi schemas to prevent injection and malformed data.
- Handle errors centrally β Use
server.ext('onPreResponse', ...)to catch and format errors consistently. - Use plugins for cross-cutting concerns β Create reusable plugins for logging (
good), caching, and database connections. - Enable CORS for frontend clients β Configure
server.connectionoptions or use@hapi/corsplugin when serving a separate SPA. - Keep routes thin β Move business logic to service modules for testability.
- Version your API β Prefix routes with
/api/v1to avoid breaking changes.
Conclusion
Hapi provides a robust, opinionated foundation for building full-stack applications with Node.js. Its plugin system, built-in validation, and support for both server-side rendering and REST APIs make it a versatile choice for projects of any scale. By following the patterns outlined in this tutorialβsetting up a server, defining validated routes, integrating a front-end, connecting a database, and securing endpoints with JWTβyou can build production-ready full-stack apps that are maintainable, secure, and performant. Whether you are creating a small prototype or a large enterprise application, Hapi's configuration-driven approach will help you keep your code clean and your logic well-organized.