← Back to DevBytes

Server-Side Rendering with Hapi: SSR, SSG, ISR

Introduction to Server-Side Rendering with Hapi

Server-Side Rendering (SSR) is a technique where web pages are rendered on the server rather than in the browser. When a user requests a page, the server generates the full HTML and sends it to the client. This approach contrasts with Client-Side Rendering (CSR), where the browser downloads a minimal HTML document and JavaScript then builds the UI.

Hapi is a rich, configuration-driven Node.js framework originally created at Walmart Labs. While it is most commonly associated with building REST APIs and microservices, it is also a capable foundation for rendering web pages using SSR, Static Site Generation (SSG), and Incremental Static Regeneration (ISR) strategies. This tutorial walks through each of these rendering patterns and shows how to implement them with Hapi.

Why Rendering Strategy Matters

The rendering strategy you choose affects performance, SEO, time-to-first-byte (TTFB), and infrastructure cost. SSR delivers fully-formed HTML on every request, which is great for SEO and dynamic content but adds server load. SSG pre-builds pages at compile time, offering the fastest possible delivery but requiring rebuilds when content changes. ISR sits in the middle: it serves static pages but regenerates them in the background when data changes, combining the speed of SSG with the freshness of SSR.

Setting Up a Hapi Project

Begin by initializing a new Node.js project and installing Hapi along with a templating engine. Vision is the official Hapi plugin for rendering templates, and we will use Handlebars as the engine because it is simple, widely understood, and well-supported.

mkdir hapi-ssr-demo
cd hapi-ssr-demo
npm init -y
npm install @hapi/hapi @hapi/vision @hapi/inert handlebars

Create the following directory structure:

hapi-ssr-demo/
├── server.js
├── templates/
│   ├── layout.html
│   ├── home.html
│   └── post.html
└── static/
    └── cache/

The static/cache directory will hold generated static HTML files for SSG and ISR. Now let's configure the server with Vision and Handlebars.

// server.js
const Hapi = require('@hapi/hapi');
const Vision = require('@hapi/vision');
const Path = require('path');
const fs = require('fs').promises;

const init = async () => {
  const server = Hapi.server({
    port: 3000,
    host: 'localhost',
  });

  await server.register(Vision);

  server.views({
    engines: { html: require('handlebars') },
    relativeTo: __dirname,
    path: 'templates',
    layout: 'layout',
    layoutPath: 'templates',
  });

  // Routes will be added here

  await server.start();
  console.log('Server running on %s', server.info.uri);
};

init();

The server.views() call configures Vision to use Handlebars for any .html template found in the templates directory, with a default layout file. Let's create the layout and a basic home template.

<!-- templates/layout.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{title}}</title>
</head>
<body>
  <nav><a href="/">Home</a> | <a href="/blog">Blog</a></nav>
  <main>{{{content}}}</main>
</body>
</html>
<!-- templates/home.html -->
<h1>{{title}}</h1>
<p>{{message}}</p>

Implementing Server-Side Rendering (SSR)

SSR is the most straightforward pattern in Hapi. Each request triggers the server to fetch data, render the template, and return HTML. This is ideal for pages with personalized or frequently changing content, such as dashboards, user profiles, or search results.

Let's add an SSR route. We will simulate a data source with a small asynchronous function.

// Simulated data source
const fetchPosts = async () => {
  return [
    { id: 1, title: 'Getting Started with Hapi', body: 'Hapi is a powerful Node.js framework.' },
    { id: 2, title: 'Understanding SSR', body: 'Server-side rendering improves SEO and initial load.' },
    { id: 3, title: 'ISR Explained', body: 'ISR combines static and dynamic rendering.' },
  ];
};

const fetchPost = async (id) => {
  const posts = await fetchPosts();
  return posts.find((p) => p.id === parseInt(id, 10));
};

server.route({
  method: 'GET',
  path: '/',
  handler: async (request, h) => {
    const posts = await fetchPosts();
    return h.view('home', {
      title: 'Hapi SSR Demo',
      message: `Welcome! We have ${posts.length} posts.`,
    });
  },
});

server.route({
  method: 'GET',
  path: '/blog',
  handler: async (request, h) => {
    const posts = await fetchPosts();
    return h.view('post', {
      title: 'Blog',
      posts,
    });
  },
});

server.route({
  method: 'GET',
  path: '/blog/{id}',
  handler: async (request, h) => {
    const post = await fetchPost(request.params.id);
    if (!post) {
      return h.response('Not Found').code(404);
    }
    return h.view('post', {
      title: post.title,
      post,
    });
  },
});

And the post template that lists posts and shows individual ones:

<!-- templates/post.html -->
{{#if posts}}
  <h1>Blog Posts</h1>
  <ul>
    {{#each posts}}
      <li><a href="/blog/{{id}}">{{title}}</a></li>
    {{/each}}
  </ul>
{{else}}
  <h1>{{post.title}}</h1>
  <p>{{post.body}}</p>
  <a href="/blog">&larr; Back to blog</a>
{{/if}}

With this setup, every request to /, /blog, or /blog/{id} triggers a fresh render. The advantage is that the client always receives up-to-date content and crawlers see fully populated HTML. The trade-off is that the server does work on every request, which can become expensive under heavy traffic.

Implementing Static Site Generation (SSG)

SSG pre-renders pages at build time and serves the resulting static HTML files. This approach is perfect for content that rarely changes: marketing pages, documentation, blog posts, and landing pages. The server simply reads a file from disk and returns it, which is extremely fast.

To implement SSG in Hapi, we will write a build step that renders templates to HTML files, then add a route that serves those files. Let's create a build function.

const buildStaticPages = async (server) => {
  const cacheDir = Path.join(__dirname, 'static', 'cache');
  await fs.mkdir(cacheDir, { recursive: true });

  const posts = await fetchPosts();

  // Build the home page
  const homeHtml = await server.render('home', {
    title: 'Hapi SSR Demo',
    message: `Welcome! We have ${posts.length} posts.`,
  });
  await fs.writeFile(Path.join(cacheDir, 'index.html'), homeHtml);

  // Build the blog index
  const blogHtml = await server.render('post', { title: 'Blog', posts });
  await fs.writeFile(Path.join(cacheDir, 'blog.html'), blogHtml);

  // Build each individual post
  for (const post of posts) {
    const postHtml = await server.render('post', { title: post.title, post });
    await fs.writeFile(Path.join(cacheDir, `post-${post.id}.html`), postHtml);
  }

  console.log('Static pages generated successfully.');
};

Now add a route that serves these cached files. We use a helper that maps URLs to file names.

const staticFileFor = (request) => {
  if (request.path === '/') return 'index.html';
  if (request.path === '/blog') return 'blog.html';
  const match = request.path.match(/^\/blog\/(\d+)$/);
  if (match) return `post-${match[1]}.html`;
  return null;
};

server.route({
  method: 'GET',
  path: '/static/{path*}',
  handler: async (request, h) => {
    const fileName = staticFileFor(request);
    if (!fileName) return h.response('Not Found').code(404);
    const filePath = Path.join(__dirname, 'static', 'cache', fileName);
    try {
      const html = await fs.readFile(filePath, 'utf-8');
      return h.response(html).type('text/html');
    } catch (err) {
      return h.response('Not Found').code(404);
    }
  },
});

To run the build, call buildStaticPages(server) after the server starts. In a real application, you would typically run this as a separate script before deploying, so the production server only serves files.

init().then(async () => {
  // Uncomment to regenerate static pages
  // await buildStaticPages(server);
});

SSG gives you the best possible TTFB because the response is a static file read. The downside is that any content change requires a rebuild and redeploy, which can be slow for large sites.

Implementing Incremental Static Regeneration (ISR)

ISR is a hybrid approach popularized by Next.js. The server serves a static cached page, but after a configurable time-to-live (TTL), it regenerates the page in the background on the next request. The user who triggers regeneration still receives the stale page, but subsequent users get the fresh one. This provides the speed of SSG with the freshness of SSR.

Let's implement ISR for the individual blog post route. We will store a metadata file alongside each cached HTML file that records when it was last generated.

const ISR_TTL_MS = 60 * 1000; // 1 minute

const getCachedPost = async (id) => {
  const filePath = Path.join(__dirname, 'static', 'cache', `post-${id}.html`);
  const metaPath = Path.join(__dirname, 'static', 'cache', `post-${id}.json`);
  try {
    const [html, metaRaw] = await Promise.all([
      fs.readFile(filePath, 'utf-8'),
      fs.readFile(metaPath, 'utf-8'),
    ]);
    return { html, generatedAt: JSON.parse(metaRaw).generatedAt };
  } catch (err) {
    return null;
  }
};

const regeneratePost = async (server, id) => {
  const post = await fetchPost(id);
  if (!post) return null;
  const html = await server.render('post', { title: post.title, post });
  const filePath = Path.join(__dirname, 'static', 'cache', `post-${id}.html`);
  const metaPath = Path.join(__dirname, 'static', 'cache', `post-${id}.json`);
  await fs.writeFile(filePath, html);
  await fs.writeFile(metaPath, JSON.stringify({ generatedAt: Date.now() }));
  return html;
};

server.route({
  method: 'GET',
  path: '/blog/isr/{id}',
  handler: async (request, h) => {
    const id = request.params.id;
    const cached = await getCachedPost(id);

    if (!cached) {
      // First request: generate and cache
      const html = await regeneratePost(server, id);
      if (!html) return h.response('Not Found').code(404);
      return h.response(html).type('text/html');
    }

    const age = Date.now() - cached.generatedAt;
    const isStale = age > ISR_TTL_MS;

    if (isStale) {
      // Serve stale content immediately, regenerate in background
      setImmediate(async () => {
        try {
          await regeneratePost(server, id);
        } catch (err) {
          console.error('ISR regeneration failed:', err);
        }
      });
    }

    return h.response(cached.html).type('text/html')
      .header('X-Cache', isStale ? 'STALE' : 'HIT')
      .header('Cache-Age', `${Math.floor(age / 1000)}s`);
  },
});

This implementation follows the classic ISR pattern:

The X-Cache and Cache-Age headers are useful for debugging and monitoring cache behavior. In production, you might also add an on-demand revalidation endpoint that triggers regeneration when content changes via a webhook from your CMS.

Comparing the Three Strategies

Each rendering strategy has distinct trade-offs. The following summary helps you decide which to use for each route in your application:

You can mix strategies within a single Hapi application. A marketing site might use SSG for the homepage, ISR for blog posts, and SSR for a logged-in dashboard. Hapi's flexible routing makes this straightforward.

Best Practices

When building SSR, SSG, or ISR systems with Hapi, keep the following practices in mind:

Conclusion

Hapi is a versatile framework that supports all three major server-side rendering strategies: SSR for dynamic, personalized content; SSG for blazing-fast static pages; and ISR for a balanced approach that keeps content fresh without sacrificing performance. By understanding the trade-offs of each pattern and applying the best practices outlined above, you can build web applications that are fast, SEO-friendly, and maintainable. Start with SSR for simplicity, introduce SSG for stable pages, and adopt ISR where content updates are occasional but should propagate without a full rebuild. With Hapi's plugin ecosystem and flexible routing, mixing these strategies in a single codebase is both practical and clean.

— Ad —

Google AdSense will appear here after approval

← Back to all articles