Static Site Generation

Production engineering guide for static site generation covering patterns, implementation strategies, and operational best practices.

Static site generation (SSG) is the process of building a complete website at build time, producing a set of static HTML, CSS, and JavaScript files that can be served directly from a CDN or web server. It matters when content is known ahead of time—such as blogs, documentation, marketing sites, and e-commerce product pages—where performance, scalability, and developer experience are critical. SSG reduces the need for dynamic server logic, enabling fast, resilient, and cost-effective delivery of content.

Building a Site

To generate a static site, a build tool processes source files—Markdown, JSON, YAML, templates, and assets—into static output. The process typically involves:

A typical build command is:

npm run build

This runs a script defined in package.json:

{
  "scripts": {
    "build": "vite build && node scripts/generate-routes.js && cp -r public/* dist/"
  }
}

The vite build command compiles the frontend app into dist/ and outputs a manifest.json with asset mappings. The generate-routes.js script reads content from content/, generates a routes.json file listing all routes, and creates dist/posts/slug/index.html for each blog post.

Configuration and Content Structure

Content is organized in a structured directory tree. A standard layout includes:

/content
  /posts
    2023-10-01-how-ssg-works.md
    2023-10-15-performance-best-practices.md
  /pages
    about.md
    contact.md
  /data
    site.json
    nav.json

Each Markdown file starts with a front matter block:

---
title: "How Static Site Generation Works"
date: 2023-10-01
tags: ["ssg", "performance"]
draft: true
layout: "post"
---

This is a blog post about SSG.

The build system reads this metadata and uses it to populate templates, generate navigation, and create structured output.

The build tool’s configuration file is a standard JSON or JavaScript file. For example, vite.config.js:

import { defineConfig } from 'vite';
import { resolve } from 'path';
import { ssgPlugin } from 'vite-plugin-ssg';

export default defineConfig({
  root: 'src',
  build: {
    outDir: 'dist',
    emptyOutDir: true,
    rollupOptions: {
      output: {
        manualChunks: undefined
      }
    }
  },
  plugins: [
    ssgPlugin({
      routes: ['/', '/about', '/contact'],
      output: 'dist',
      entry: 'src/main.tsx'
    })
  ]
});

The ssgPlugin directive specifies which routes to generate, the output directory, and the entry point for the client-side app.

Handling Dynamic Content

Even though the site is built ahead of time, dynamic content can be injected via data files or API calls. A common pattern is to fetch data during the build process.

A data directory holds JSON or YAML files used by the build script:

// content/data/site.json
{
  "title": "My Static Site",
  "description": "A high-performance site built with SSG",
  "author": "Jane Doe"
}

In scripts/generate-routes.js, the build script reads all Markdown files and generates a routes.json:

import fs from 'fs';
import path from 'path';
import { parse } from 'gray-matter';

const contentDir = path.join(process.cwd(), 'content');
const outputDir = path.join(process.cwd(), 'dist');

const routes = [];

fs.readdirSync(contentDir).forEach((dir) => {
  const dirPath = path.join(contentDir, dir);
  if (!fs.statSync(dirPath).isDirectory()) return;

  fs.readdirSync(dirPath).forEach((file) => {
    const filePath = path.join(dirPath, file);
    const content = fs.readFileSync(filePath, 'utf-8');
    const { data } = parse(content);

    const slug = path.basename(file, path.extname(file));
    const route = `/${dir}/${slug}`;

    routes.push({
      path: route,
      data,
      template: `${dir}.html`
    });

    const output = path.join(outputDir, route);
    fs.mkdirSync(output, { recursive: true });
    fs.writeFileSync(`${output}/index.html`, renderTemplate(route, data));
  });
});

fs.writeFileSync(path.join(outputDir, 'routes.json'), JSON.stringify(routes));

This script reads each file, extracts metadata, and writes a route.html file for each page.

Incremental Builds and Caching

For large sites, full rebuilds are slow. Incremental builds detect changes and rebuild only affected pages.

A build system uses a cache directory to store intermediate results. The vite build command supports incremental caching via the --cache-dir flag:

vite build --cache-dir .vite-cache

When a file changes, the build tool checks the cache/ directory for previous outputs. If content/posts/2023-10-01-how-ssg-works.md was last built on October 1st, and the file is modified, the build only reprocesses that file and updates the corresponding HTML.

The cache is stored as a JSON object in cache/:

{
  "2023-10-01-how-ssg-works.md": {
    "hash": "a1b2c3d4e5f6",
    "lastBuilt": "2023-10-01T12:00:00Z",
    "output": "dist/posts/how-ssg-works/index.html"
  }
}

If the hash of the file has changed (computed via crypto.createHash('sha256')), the build triggers a rebuild.

The build script can also use mtime (modification time) as a fallback:

const fileStat = fs.statSync(filePath);
const cached = cache.get(filePath);
if (fileStat.mtimeMs > cached.mtimeMs) {
  rebuild(filePath);
}

Deploying a Static Site

The final output is a directory of static files. Deployment involves copying this directory to a CDN or web server.

A typical deployment script uses rsync or aws s3 cp:

#!/bin/bash


echo "Deploying to CDN..."

# Build the site
npm run build

# Sync to S3
aws s3 sync dist/ s3://my-site/ \
  --delete \
  --cache-control 'max-age=31536000,public' \
  --content-type 'text/html' \
  --exclude "*.map" \
  --include "*.html" \
  --include "*.css" \
  --include "*.js" \
  --include "*.jpg" \
  --include "*.png"

# Invalidate CloudFront
aws cloudfront create-invalidation \
  --distribution-id E1234567890 \
  --paths "/*"

The --delete flag ensures that deleted files are removed from the CDN. --cache-control sets HTTP headers for long-term caching. --include and --exclude filter file types for different caching strategies.

CloudFront invalidation is critical: after a deployment, the CDN still serves old versions of assets until the cache expires. An invalidation request forces all edge locations to fetch fresh versions.

Common Failure Modes

A silent but common failure occurs when the dist/ directory is not properly cleaned before a new build. The build tool outputs to dist/ but does not delete old files. As a result, a new version of main.js is added, but old main.js.map files persist, causing stale bundles.

This is solved by setting emptyOutDir: true in the build configuration. Without it, the site may load a new main.js but still reference old vendor.js or styles.css from a previous build.

Another failure is incorrect routing in generated pages. The build script writes dist/posts/2023-10-01-how-ssg-works/index.html, but the base path in index.html is incorrect:

<!DOCTYPE html>
<html>
  <head>
    <base href="/" />
    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>
    <script src="/main.js"></script>
  </body>
</html>

If the site is deployed to https://example.com/blog/, but the base is /, then CSS and JS files are loaded from https://example.com/styles.css, not https://example.com/blog/styles.css.

To fix this, set the base path in the build configuration:

export default defineConfig({
  base: '/blog/',
  build: {
    outDir: 'dist',
    emptyOutDir: true
  }
});

The base path must be set consistently across the build, the template engine, and the asset pipeline.

Server-Side Rendering and Client Hydration

For interactive sites, SSG combines static generation with client-side hydration. The build process generates static HTML, but JavaScript bundles hydrate the page on the client.

The entry point (src/main.tsx) includes hydrate:

import { hydrate } from 'react-dom';
import App from './App';

const container = document.getElementById('app');
if (container) {
  hydrate(<App />, container);
}

The ssgPlugin generates a index.html file that includes a <script> tag referencing main.js:

<script type="module" src="/main.js" async></script>

The async attribute ensures that the JavaScript loads without blocking the initial render. The type="module" enables native ES modules, allowing the use of import and export in the client bundle.

If the main.js file is not correctly generated or missing, the page renders static content but remains unresponsive. The error appears in the browser console:

Uncaught TypeError: hydrate is not a function
    at main.js:1

This indicates that react-dom was not included in the bundle or that the main.js file was not built correctly.

Optimizing Assets

Images are a common bottleneck. The build process should optimize and generate multiple versions.

A typical image optimization step uses sharp in a Node.js script:

import sharp from 'sharp';
import fs from 'fs';

const input = 'src/assets/images/banner.jpg';
const outputDir = 'dist/images/banner';

fs.mkdirSync(outputDir, { recursive: true });

const image = sharp(input);

// Generate multiple sizes
await image
  .resize(800)
  .toFile(`${outputDir}/800.jpg`);

await image
  .resize(1200)
  .toFile(`${outputDir}/1200.jpg`);

await image
  .resize(1600)
  .toFile(`${outputDir}/1600.jpg`);

// Generate webp
await image
  .toFormat('webp')
  .toFile(`${outputDir}/banner.webp`);

// Generate avif
await image
  .toFormat('avif')
  .toFile(`${outputDir}/banner.avif`);

The final index.html includes srcset and picture elements:

<picture>
  <source srcset="/images/banner/banner.avif 1x, /images/banner/banner.avif 2x" type="image/avif">
  <source srcset="/images/banner/banner.webp 1x, /images/banner/banner.webp 2x" type="image/webp">
  <img src="/images/banner/800.jpg" srcset="/images/banner/800.jpg 1x, /images/banner/1200.jpg 2x" alt="Banner" width="800" height="600">
</picture>

Without srcset, the browser downloads the largest image for every viewport, increasing load time and bandwidth.

Build-Time vs Runtime Data Fetching

SSG allows two data-fetching strategies:

A common mistake is confusing the two. For example, a build script fetches user data from an API but forgets to embed it in the page.

const response = await fetch('https://api.example.com/users');
const users = await response.json();

// This data is used to generate the HTML but not serialized to the page
// The client must fetch it again

To fix this, the build script serializes the data and embeds it in the page:

const html = `
  <html>
    <body>
      <script>
        window.__INITIAL_DATA__ = ${JSON.stringify(users)};
      </script>
      <div id="app"></div>
      <script src="/main.js" async></script>
    </body>
  </html>
`;

The client-side code then reads window.__INITIAL_DATA__ during hydration:

const initialData = window.__INITIAL_DATA__;
hydrate(<App data={initialData} />, document.getElementById('app'));

This reduces the number of network requests and improves perceived performance.

Final Checklist

These practices ensure that a static site is not just generated, but truly optimized and resilient.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.