My Blog

Implementing Static Site Generation (SSG) with Vite from Scratch

2025-04-14 Hanlun Wang

1. Introduction

Static Site Generation (SSG) has become increasingly popular for building high-performance websites with excellent SEO capabilities. As a developer working extensively with Vite and React, I recently implemented a complete SSG solution for a React application. In this article, I'll share the technical details of transforming a standard client-side rendered (CSR) Vite React application into a fully static generated site.

Unlike frameworks like Next.js or Nuxt that offer SSG out of the box, implementing SSG in a vanilla Vite project requires understanding the underlying mechanisms and making strategic modifications to the build process. This approach offers greater flexibility and control while achieving similar performance benefits.

2. Understanding SSG Principles

At its core, SSG pre-renders pages at build time rather than at request time (SSR) or in the client's browser (CSR). The primary benefit is combining SEO advantages of server rendering with the simplicity of static hosting.

The fundamental concept involves:

  1. Creating a dual-entry architecture that separates client and server rendering
  2. Using server-side React rendering to generate HTML strings
  3. Injecting these pre-rendered strings into HTML templates
  4. Implementing client-side hydration to make the static content interactive

3. Implementation Steps Overview

The SSG implementation can be broken down into five key phases:

  1. Dual Entry Architecture: Creating separate entry points for client and server rendering
  2. Routes Definition: Defining all routes to be pre-rendered
  3. Build Script Configuration: Setting up distinct build processes for client and server
  4. Static Generation Logic: Developing the script to convert React components to static HTML
  5. Core Configuration Adjustments: Modifying Vite configuration to support the SSG flow

Let's explore each component in detail.

4. Core Implementation Components

4.1. Client Entry File (entry-client.tsx)

The client entry file is responsible for "hydrating" the static HTML with interactive React components:

// src/entry-client.tsx
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
 
const rootElement = document.getElementById("root");
 
if (!rootElement) {
  console.error("Root element not found! Unable to hydrate React application.");
} else {
  // Use hydrateRoot for client-side hydration of server-rendered content
  hydrateRoot(
    rootElement,
    <React.StrictMode>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </React.StrictMode>
  );
}

The critical difference from standard React initialization is using hydrateRoot instead of createRoot. This tells React to connect to the existing DOM nodes rather than rebuilding them, preserving the pre-rendered HTML while making it interactive.

4.2. Server Entry File (entry-server.tsx)

This file contains the logic for rendering components to HTML strings during the build process:

// src/entry-server.tsx
import React, { useEffect, useLayoutEffect } from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/cn/tooltip";
 
// Patch React's useLayoutEffect during SSR to behave like useEffect
// This prevents warnings from libraries like Radix UI that use useLayoutEffect internally
if (typeof window === "undefined") {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  (React as any).useLayoutEffect = React.useEffect;
}
 
// Server-side rendering function
export function render(url: string): string {
  // Create a new QueryClient for each render
  const queryClient = new QueryClient();
 
  try {
    // Render the app to string
    const appHtml = renderToString(
      <QueryClientProvider client={queryClient}>
        <TooltipProvider>
          <StaticRouter location={url}>
            <App />
          </StaticRouter>
        </TooltipProvider>
      </QueryClientProvider>
    );
 
    return appHtml;
  } catch (error) {
    console.error("Error rendering application:", error);
    return `<div id="root">Error rendering application</div>`;
  }
}

Key aspects of this implementation:

  1. Using StaticRouter instead of BrowserRouter since we don't have browser history during the build
  2. Patching useLayoutEffect to prevent server rendering warnings
  3. Maintaining the same provider structure as the client for rendering consistency
  4. Exporting a render function that accepts a URL path and returns the corresponding HTML

4.3. Routes Definition (routes.js)

This simple file defines all routes that should be pre-rendered:

// src/routes.js
export const routes = [
  "/",
  "/pricing",
  "/services",
  "/about",
  "/contact",
  "/signup",
  "*", // 404 page
];

The special '*' route is used to generate a 404 page. This centralized approach makes it easy to maintain and extend the list of routes to be pre-rendered.

4.4. Static Generation Script (generate-static.js)

This is the heart of the SSG process:

// scripts/generate-static.js
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
 
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, ".."); // Project root directory
 
// Helper functions
const toAbsolute = (p) => path.resolve(rootDir, p);
const outDir = toAbsolute("dist");
 
// Pre-render all routes
(async function () {
  try {
    console.log("Starting static site generation...");
 
    // Dynamically import routes relative to project root
    const { routes } = await import("../src/routes.js");
 
    // Check if server build exists
    const serverEntryPath = toAbsolute("dist/server/entry-server.js");
    if (!fs.existsSync(serverEntryPath)) {
      console.error(`Server entry file not found: ${serverEntryPath}`);
      console.error('Please run "npm run build:server" first');
      process.exit(1);
    }
 
    // Import server-side render function using file path
    const serverEntryUrl = `file://${serverEntryPath}`;
    const { render } = await import(serverEntryUrl);
 
    // Read the client-side build HTML template
    const templatePath = toAbsolute("dist/index.html");
    if (!fs.existsSync(templatePath)) {
      console.error(`Client template file not found: ${templatePath}`);
      console.error('Please run "npm run build:client" first');
      process.exit(1);
    }
    const template = fs.readFileSync(templatePath, "utf-8");
 
    // Find the root element where our app should be rendered
    const appRootTag = '<div id="root">';
    const closingRootTag = "</div>";
    const ssrOutlet = "<!--ssr-outlet-->";
 
    // Pre-render each route
    for (const url of routes) {
      // Special handling for 404 page
      if (url === "*") {
        try {
          const renderedApp = render("/404");
 
          // Replace the SSR outlet or root div content with server-rendered HTML
          let notFoundHtml;
          if (template.includes(ssrOutlet)) {
            notFoundHtml = template.replace(ssrOutlet, renderedApp);
          } else {
            // Fallback: Find the position after the opening root tag
            const startPos = template.indexOf(appRootTag);
            if (startPos === -1) {
              console.error(
                `Could not find '${appRootTag}' in template: ${templatePath}`
              );
              continue;
            }
            const startInsertPos = startPos + appRootTag.length;
            const endPos = template.indexOf(closingRootTag, startInsertPos);
            if (endPos === -1) {
              console.error(
                `Could not find closing '${closingRootTag}' after '${appRootTag}' in template: ${templatePath}`
              );
              continue;
            }
 
            // Insert the rendered app content between the root tags
            notFoundHtml =
              template.substring(0, startInsertPos) +
              renderedApp +
              template.substring(endPos);
          }
 
          fs.writeFileSync(toAbsolute("dist/404.html"), notFoundHtml);
          console.log("Pre-rendered: /404 -> dist/404.html");
        } catch (err) {
          console.error(`Error rendering 404 page: ${err.message || err}`);
        }
        continue;
      }
 
      try {
        // Determine output path relative to dist
        const pathWithoutLeadingSlash = url.substring(1); // remove leading '/'
        const filename =
          pathWithoutLeadingSlash === ""
            ? "index.html"
            : `${pathWithoutLeadingSlash}/index.html`;
        const filePath = path.join(outDir, filename);
 
        // Create directory if needed
        const dirname = path.dirname(filePath);
        if (!fs.existsSync(dirname)) {
          fs.mkdirSync(dirname, { recursive: true });
        }
 
        // Render the page content
        const renderedApp = render(url);
 
        // Replace the SSR outlet or root div content with server-rendered HTML
        let html;
        if (template.includes(ssrOutlet)) {
          html = template.replace(ssrOutlet, renderedApp);
        } else {
          // Fallback: Find the position after the opening root tag
          const startPos = template.indexOf(appRootTag);
          if (startPos === -1) {
            console.error(
              `Could not find '${appRootTag}' in template: ${templatePath}`
            );
            continue;
          }
          const startInsertPos = startPos + appRootTag.length;
          const endPos = template.indexOf(closingRootTag, startInsertPos);
          if (endPos === -1) {
            console.error(
              `Could not find closing '${closingRootTag}' after '${appRootTag}' in template: ${templatePath}`
            );
            continue;
          }
 
          // Insert the rendered app content between the root tags
          html =
            template.substring(0, startInsertPos) +
            renderedApp +
            template.substring(endPos);
        }
 
        fs.writeFileSync(filePath, html);
        console.log(`Pre-rendered: ${url} -> dist/${filename}`);
      } catch (error) {
        console.error(`Error pre-rendering ${url}: ${error.message || error}`);
      }
    }
 
    console.log("Static site generation complete!");
  } catch (error) {
    console.error("Static site generation failed:", error);
    process.exit(1);
  }
})();

This script performs several crucial operations:

  1. Imports the routes list and server render function
  2. Reads the HTML template from the client build
  3. For each route:
    • Determines the output file path (creating the correct directory structure)
    • Calls the server render function to generate HTML
    • Injects the HTML into the template at the designated insertion point
    • Writes the complete HTML file to the appropriate location
  4. Special handling for the 404 page

The script uses a primary insertion strategy (looking for <!--ssr-outlet-->) with a fallback mechanism (inserting between root div tags) to ensure compatibility across different build configurations.

4.5. Package.json Script Configuration

The build process is split into distinct phases:

"scripts": {
  "dev": "vite",
  "build": "npm run build:client && npm run build:server && npm run generate",
  "build:client": "vite build --outDir dist",
  "build:server": "vite build --outDir dist/server --ssr src/entry-server.tsx",
  "generate": "node --experimental-json-modules scripts/generate-static.js",
  "build:dev": "npm run build:client:dev && npm run build:server:dev && npm run generate",
  "build:client:dev": "vite build --outDir dist --mode development",
  "build:server:dev": "vite build --outDir dist/server --ssr src/entry-server.tsx --mode development",
  "lint": "eslint .",
  "preview": "vite preview"
}

This configuration enables:

  1. A single build command that orchestrates the entire process
  2. Separate client and server builds with appropriate options
  3. Development-specific build commands for easier debugging
  4. The final static generation step that produces the pre-rendered site

4.6. Vite Configuration (vite.config.ts)

The modified Vite configuration supports the SSG process:

import { defineConfig, loadEnv, ConfigEnv, UserConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { resolve } from "path";
 
export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
  const env = loadEnv(mode, process.cwd(), "");
  const isProd = mode === "production";
 
  return {
    // Server configurations...
    plugins: [
      react(),
      {
        name: "generate-static-html",
        apply: "build",
        enforce: "post",
        closeBundle: async () => {
          if (mode === "production") {
            console.log("Running static site generation...");
          }
        },
      },
    ],
    resolve: {
      alias: {
        "@": path.resolve(__dirname, "./src"),
      },
    },
    build: {
      outDir: "dist",
      // Keep comments for replacement
      minify: isProd ? "esbuild" : false,
      rollupOptions: {
        input: {
          main: resolve(__dirname, "index.html"),
        },
      },
    },
    preview: {
      port: 8080,
      open: true,
    },
  };
});

Key adjustments include:

  1. A custom plugin that logs when static generation begins
  2. Controlled minification that preserves HTML comments in development mode
  3. Explicit specification of the HTML entry point
  4. TypeScript type enhancements for better developer experience

4.7. HTML Template Modifications (index.html)

The HTML template is simplified to include a placeholder for server-rendered content:

<div id="root"><!--ssr-outlet--></div>

The <!--ssr-outlet--> comment serves as the injection point for pre-rendered HTML. This placeholder will be replaced with the actual component HTML during the static generation process.

5. Workflow Analysis

5.1. Development Phase

During development, the application runs as a standard Vite application:

  1. Running npm run dev starts the Vite development server
  2. The application functions in CSR mode with hot module replacement
  3. Developers can work with the familiar React development experience

5.2. Build Phase

When building for production, the npm run build command triggers a three-step process:

  1. Client Build (build:client)

    • Standard Vite build process
    • Generates optimized client JavaScript, CSS, and HTML template
    • Outputs to the dist directory
  2. Server Build (build:server)

    • Uses Vite's --ssr flag for server-optimized build
    • Builds entry-server.tsx and its dependencies
    • Outputs to the dist/server directory
    • Creates a Node.js executable module
  3. Static Generation (generate)

    • Runs the generate-static.js script
    • Imports the routes list and server render function
    • Renders each route and generates corresponding HTML files
    • Creates the final static site structure

5.3. Runtime Flow

When a user visits the site:

  1. Initial Load:

    • The server returns the pre-rendered static HTML
    • Users immediately see the complete page content (beneficial for SEO and initial load performance)
    • No waiting for JavaScript to load and execute
  2. Hydration Phase:

    • Once client JavaScript loads
    • React connects to the existing DOM through hydrateRoot
    • The page becomes interactive without re-rendering
  3. Subsequent Navigation:

    • Client-side routing handles page transitions
    • No additional server requests for full HTML pages
    • Provides a smooth SPA-like experience

6. Technical Benefits

6.1. SEO Enhancement

Static pre-rendering offers significant SEO advantages:

  1. Search engines can directly crawl complete HTML content
  2. Content remains visible even if JavaScript is disabled
  3. Improved indexing quality and completeness

6.2. Performance Improvements

The SSG approach yields measurable performance gains:

  1. Faster First Contentful Paint (FCP) and Largest Contentful Paint (LCP)
  2. Reduced Cumulative Layout Shift (CLS)
  3. Better Core Web Vitals scores overall

6.3. Developer Experience

Despite the more complex build process, developer experience remains strong:

  1. Vite's rapid development server and HMR features are preserved
  2. Separation of concerns: client hydration vs. server rendering
  3. Clear build process, easier to debug and extend

6.4. Deployment Flexibility

The static output provides deployment advantages:

  1. Pure static HTML files can be hosted on any static hosting service
  2. No need for a Node.js server in production
  3. Reduced server load and operating costs

7. Implementation Challenges

While implementing SSG with Vite, I encountered several technical challenges:

  1. Libraries Using Browser APIs: Many React libraries assume browser APIs are available, requiring careful handling of server-side rendering contexts.

  2. Hydration Mismatch: Ensuring that server-rendered HTML exactly matches what React expects during hydration was critical. Even small differences can cause hydration errors.

  3. useLayoutEffect Warnings: React's useLayoutEffect hook isn't meant for server environments, requiring patching for libraries that use it internally.

  4. Path Resolution: Ensuring correct file paths between development and production builds, particularly for dynamic imports.

  5. CSS Handling: Managing CSS extraction and application during the SSR process required special attention to prevent styling inconsistencies.

8. Advanced Techniques

8.1. Dynamic Routes Support

For applications with dynamic routes (like blog posts), the routes.js file can be extended:

// Fetch data for dynamic routes
async function generateDynamicRoutes() {
  // Example: fetch blog posts from an API
  const posts = await fetchBlogPosts();
  return posts.map((post) => `/blog/${post.slug}`);
}
 
export async function getRoutes() {
  const staticRoutes = ["/", "/about"];
  const dynamicRoutes = await generateDynamicRoutes();
  return [...staticRoutes, ...dynamicRoutes];
}

8.2. Route-Specific Data Prefetching

For pages requiring specific data:

// Pass route-specific props to the render function
const appHtml = await render(route.path, route.props);

8.3. Incremental Static Regeneration

Similar to Next.js ISG, a rebuild endpoint can be implemented:

app.get("/api/rebuild-page", async (req, res) => {
  const { path, secret } = req.query;
 
  // Validate security token
  if (secret !== process.env.REBUILD_SECRET) {
    return res.status(401).send("Unauthorized");
  }
 
  try {
    // Rebuild HTML for specific path
    await rebuildPath(path);
    res.status(200).send(`Rebuilt: ${path}`);
  } catch (error) {
    res.status(500).send(error.message);
  }
});

9. Conclusion

Implementing SSG with Vite from scratch provides a powerful balance between performance, SEO, and developer experience. While frameworks like Next.js offer these capabilities out of the box, building a custom SSG solution offers greater flexibility and understanding of the underlying processes.

The approach described in this article follows a "best of both worlds" strategy: using pre-rendered static HTML for initial loading to optimize SEO and performance, while leveraging React's client-side hydration for smooth subsequent interactions.

By understanding the core principles and implementation flow of SSG, development teams can adapt and extend this solution according to their specific project requirements. The end result is a high-performance, SEO-friendly site that combines the speed of static sites with the interactivity of modern SPAs.