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.
To generate a static site, a build tool processes source files—Markdown, JSON, YAML, templates, and assets—into static output. The process typically involves:
content/posts/*.md)dist/ directory with index.html, styles.css, main.js, and static asset folders.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.
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.
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.
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);
}
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.
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.
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.
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.
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.
emptyOutDir: true in build configurationbase path set correctlycache directory used for incremental buildssrcset and picture elements for responsive imageswindow.__INITIAL_DATA__ for runtime data--delete and --cache-control in deployment scriptasync and type="module" in <script> tagsThese 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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy