A Static Site Generator, Minus the Magic

Modern web development offers an overwhelming array of tools. Static site generators (SSGs) like Gatsby, 11ty, and Jekyll are powerful, but that power comes with complexity: plugin ecosystems, build pipelines, and multiple template engines. For a small project, this can feel like using a sledgehammer to crack a nut. The core premise of any SSG is simple: combine data with templates to output HTML. We can build a minimal version of that ourselves, using just Node, Handlebars, and an API-first CMS — no frameworks, and no client-side JavaScript.

This walkthrough uses Sanity.io for content management, Handlebars for templating, and Netlify for hosting. The result is a fully editable site built from a plain HTML file. The complete code is available in the accompanying GitHub repository.

Starting with Plain HTML

The starting point is a straightforward HTML page. Create a src directory in your project and place an index.html file inside it.




<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Title of the page!</title>
</head>
<body>
    <h1>The personal homepage of Bryan Robinson</h1>

    <p>Some pagraph and rich text content next</p>

    <h2>Bryan is on the internet</h2>
    <ul>
        <li><a href="linkURL">List of links</a></li>
    </ul>
</body>
</html>

This is a simple about page: an h1 heading, a few biographical paragraphs, and a list of links. At this stage, it is static markup with no dynamic parts.

Introducing a Template Engine

To make the HTML dynamic, we add Handlebars. This engine takes an HTML string, finds defined variables and logic, and replaces them with the data we provide.

First, initialize the project and install Handlebars:

npm install handlebars

The build process is a Node script that performs two key actions: reading the source template and writing the final HTML file. The script will be used both locally and by the hosting provider to generate the live site.

const fs = require('fs');
const Handlebars = require('handlebars');

The heart of the script lies in two functions. The buildHTML function reads the template file, compiles it with Handlebars, and renders it with the provided data:

function buildHTML(filename, data) {
  const source = fs.readFileSync(filename,'utf8').toString();
  const template = Handlebars.compile(source);
  const output = template(data);

  return output
}

async function main(src, dist) {
  const html = buildHTML(src, { "variableData": "This is variable data"});
 
  fs.writeFile(destination, html, function (err) {
    if (err) return console.log(err);
      console.log('index.html created');
  });
}

main('./src/index.html', './dist/index.html');

A main function orchestrates the process. It calls buildHTML with the path to the template and a data object, and then uses Node's file system module to write the resulting string to a dist directory. To avoid errors, create a dist directory in your project, even if it only contains a .gitkeep file.

To confirm the pipeline works, modify the HTML to reference a variable from the data object using Handlebars's double-curly syntax:

<h1>{{ variableData }}</h1>

Run the Node script:

node index.js

After execution, a file will exist at /dist/index.html. Opening it in a browser will show the template's markup with the variable data inserted correctly.

Making the CMS the Source of Truth

The templating system works, but it relies on a hardcoded data object. To make content editable, we connect to a headless CMS. This approach separates content from presentation, creating a structured data layer that any frontend or service can consume. Sanity is an API-first data source with an open-source CMS tool, and their free tier covers everything needed for a project of this scope.

Setting Up the Data Layer

Install the Sanity CLI globally to bootstrap the project:

npm install -g @sanity/cli

Run sanity init and answer the questionnaire. This creates a new project and dataset in your Sanity account and generates a local studio directory where the CMS lives. While larger projects might use a separate repository for this, keeping it in the same project works for this setup. Start the local Studio with sanity start, which serves it at localhost:3333.

Defining Content Structure

The data model is defined as schemas in the schemas/schema.js file. Structuring content fields thoughtfully provides flexibility — for instance, a separate "Full Name" field is more reusable than embedding the name within the "Title" field. This enables the same data to power a resume page or API in the future, making the CMS a single source of truth.

For the about page, the schema requires the following fields:

  • - Title (string)
  • - Full Name (string)
  • - Biography (array of rich-text blocks)
  • - Website list (array of objects with name and URL fields)
types: schemaTypes.concat([
    /* Your types here! */

    {
        title: "About Details",
        name: "about",
        type: "document",
        fields: [
            {
                name: 'title',
                type: 'string'
            },
            {
                name: 'fullName',
                title: 'Full Name',
                type: 'string'
            },
            {
                name: 'bio',
                title: 'Biography',
                name: 'content',
                type: 'array',
                of: [
                    {
                        type: 'block'
                    }
                ]
            },
            {
                name: 'externalLinks',
                title: 'Social media and external links',
                type: 'array',
                of: [
                    {
                        type: 'object',
                        fields: [
                            { name: 'text', title: 'Link text', type: 'string' },
                            { name: 'href', title: 'Link url', type: 'string' }
                        ]
                    }
                ]
            }
        ]
    }
])

Add the schema types, and the Studio will present a form for creating the first document. With content in place, the template can pull live data from the CMS instead of relying on a static object. The build script needs to fetch that data via the API and replace the hardcoded values. The site remains statically served, but now its content is managed by editors in a remote CMS rather than buried in source code.

Connecting the CMS to the Build Script

With the Sanity dataset configured as an API, the next step is pulling that content into the Node.js build script. The official @sanity/client package handles the connection.

npm install @sanity/client

Configuration lives in a utility file at /utils/SanityClient.js. The client needs only your project ID and dataset name to start querying.

const sanityClient = require('@sanity/client');
const client = sanityClient({
    projectId: '4fs6x5jg',
    dataset: 'production',
    useCdn: true 
  })

module.exports = client;

Querying Content With GROQ

Back in index.js, a new function fetches the data using GROQ, Sanity's open-source query language. The query targets a specific document by its _id, which Sanity auto-generates when the document is created. You can find the _id in the Studio by copying it from the URL or entering Inspect mode via the "kebab" menu or Ctrl + Alt + I.

The query is passed to the client's fetch method. Sanity returns an array of document objects, so the script takes the 0th entry. For larger projects, GROQ supports projections to limit the returned fields, though the demo simply pulls the full document.

const client = require('./utils/SanityClient') // at the top of the file

// ...

async function getSanityData() {
    const query = `{
        "about": *[_id == 'YOUR-ID-HERE'][0]
    }`
    let data = await client.fetch(query);
}

Transforming Portable Text to HTML

Sanity does not return rich text as HTML. It uses Portable Text, an open specification that represents rich text as an array of structured objects. This format makes content portable across platforms, including voice assistants and native apps, but the build script needs HTML to inject into the Handlebars template.

The block-content-to-html package converts Portable Text into HTML. It handles all default rich text markup out of the box, with options to override individual styles if custom markup is required.

npm install @sanity/block-content-to-html
const blocksToHtml = require('@sanity/block-content-to-html'); // Added to the top

async function getSanityData() {
    const query = `{
        "about": *[_type == 'about'][0]
    }`
    let data = await client.fetch(query);
    data.about.content = blocksToHtml({
        blocks: data.about.content
    })
    return await data
}

Rendering CMS Data in Handlebars

The transformed data is passed into the buildHTML function as its data argument. The Handlebars template now references these new variables to populate the page.

async function main(src, dist) {
    const data = await getSanityData();
    const html = buildHTML(src, data)

    fs.writeFile(dist, html, function (err) {
        if (err) return console.log(err);
        console.log('index.html created');
    });
}

Rendering the rich text content variable requires triple braces in the template; this tells Handlebars to output HTML rather than escape it as a string. For the externalLinks array, Handlebars' built-in loop helper iterates over each link stored in the Studio.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ about.title }}</title>
</head>
<body>
    <h1>The personal homepage of {{ about.fullName }}</h1>

    {{{ about.content }}}

    <h2>Bryan is on the internet</h2>
    <ul>
        {{#each about.externalLinks }}
            <li><a href="{{ this.href }}">{{ this.text }}</a></li>
        {{/each}}
    </ul>
</body>
</html>

Automating Builds on Publish

To go live, two pieces are required: a static host that executes the build script, and a mechanism to trigger a rebuild whenever content changes in Sanity.

Hosting on Netlify

Netlify serves static assets, runs Node scripts during deployments, and propagates output through a global CDN. Watching a GitHub repository is straightforward: push the code to GitHub, then connect the repo to a new Netlify site via the dashboard.

Under Settings > Build & Deploy > Build Settings, set the build command to node index.js and the publish directory to ./dist. Each deployment runs the script and serves whatever lands in that folder.

Webhooks for Content Updates

A webhook ties content publishing to a fresh deployment. A build hook created in the Netlify dashboard (Settings > Build & Deploy > Build hooks) provides a URL that triggers a build when requested.

To connect Sanity to that URL, use the Sanity CLI from the /studio directory:

sanity hook create

The command prompts for a name, a dataset (set to production), and the URL provided by Netlify. After that, every publish in the Studio automatically triggers a new build—no framework involved.

Where to Take It From Here

This minimal setup demonstrates how much control a purpose-built generator affords. The same pattern can scale in several directions:

  • The build script can output more than one page, potentially generating a blog from the same CMS.
  • Developer experience improves by adding automatic rebuilds on file save with a tool like Nodemon, or live reloading via BrowserSync.
  • Sanity's API can drive multiple consumers; the same content could generate a print-ready PDF or feed a native application in addition to the website.
  • Styling is entirely up to you—the output is plain static HTML, ready for any CSS approach.

Building a custom SSG is also instructive. Understanding how content flows from a CMS through a template into static files makes the internals of more feature-heavy generators easier to grasp when those are the better fit for a project.