Static Site Generation Without the Framework
File-based route entries, server routes for RSS and sitemaps, and module preloading.
Part 3 of the "Building a Modern Portfolio Without a Meta-Framework" series
The first time I saw someone explain SSG, they made it sound trivial: "just render your React app to HTML at build time." Three weeks later, I understood why Next.js exists.
Static Site Generation is conceptually simple. The implementation details are where it gets interesting.
What SSG Actually Means
At its core, SSG is three steps:
- Build time: Render each page to HTML
- Serve time: Send static HTML files (fast, cacheable, cheap)
- Client time: React "hydrates" the HTML, attaching event handlers
The user gets instant content (the HTML is already there), then the page becomes interactive once JavaScript loads. Best of both worlds—if you can make it work.
The Build Pipeline
Before diving into the SSG script itself, here's how Vite builds everything:
// vite.config.ts
environments: {
client: {
build: { outDir: 'dist/client', manifest: true }
},
ssr: {
build: { outDir: 'dist/server', ssr: true }
},
ssg: {
build: {
outDir: 'dist/ssg',
ssr: true,
rollupOptions: {
input: resolve(__dirname, 'src/entry-server.tsx')
}
}
}
},
builder: {
async buildApp(builder) {
await Promise.all([
builder.build(builder.environments.client),
builder.build(builder.environments.ssr),
builder.build(builder.environments.ssg),
]);
await ssg(); // Our custom SSG script
}
}
Three parallel builds:
- client: The browser bundle with code splitting
- ssr: The Cloudflare Worker for API routes
- ssg: A Node-compatible build of the React app for pre-rendering
The manifest: true on the client build is important—it generates a JSON file mapping source files to output chunks. We'll use that for module preloading.
Vite is doing a lot of the heavy lifting here: bundling, code splitting, tree shaking, and compiling JSX/TypeScript. The SSG script just needs to render routes and write HTML files. Thanks to Vite's new environment api, we can orchestrate everything cleanly.
The Server Entry
The SSG script needs a way to render React to HTML. That's the server entry:
// src/entry-server.tsx
import { renderToReadableStream, renderToStaticMarkup } from 'react-dom/server';
import { Head } from './lib/head';
export async function render(
path: string,
options?: { manifest?: Manifest },
): Promise<RenderResult> {
const stream = await renderToReadableStream(
<StrictMode>
<RouterProvider initialPath={path} routes={routes}>
<Router />
</RouterProvider>
</StrictMode>,
);
// Wait for all Suspense boundaries to resolve
await stream.allReady;
const html = await streamToString(stream);
// Generate head with preloads from manifest
const meta = getMetaForPath(path, routes);
const manifestPreloads = options?.manifest
? getManifestAssets(options.manifest, path)
: { preloads: [], stylesheets: [] };
const head = renderToStaticMarkup(
<Head {...meta} {...manifestPreloads} url={path} server={true} />,
);
return { html, head };
}
A few things happening here:
renderToReadableStream: React 18+ streaming API. Returns a stream we can convert to a string.stream.allReady: Waits for all Suspense boundaries to resolve. Since we're pre-rendering, we want the complete HTML.initialPath: Tells the router which route to render (see Part 2).manifestoption: The Vite build manifest, used to calculate module preloads for each page.renderToStaticMarkup: Faster than streaming for the head since we don't need hydration there.
Route Discovery via Entries
Rather than auto-crawling links at build time, each route declares its own entry points. The file-based routing system (see Part 2) uses +Page.options.ts files where dynamic routes export an entries() function:
// src/pages/blog/[slug]/+Page.options.ts
export const entries = () => blogPosts.map((post) => `/blog/${post.slug}`);
The server entry collects all entries from all routes:
// src/entry-server.tsx
export function entries() {
return routes
.flatMap((route) => {
if (route.entries) {
// Dynamic route - expand to all concrete paths
return route.entries().map((path) => ({
path,
mode: 'page',
}));
}
// Static route - use the path directly
return [{ path: route.path, mode: 'page' }];
})
.concat(
// Add server routes (feed.xml, sitemap.xml, etc.)
serverRoutes.map(({ path }) => ({
path,
mode: 'server',
})),
);
}
The SSG script then iterates over these entries:
// scripts/ssg.ts
const allEntries = entries();
for (const entry of allEntries) {
if (entry.mode === 'page') {
console.log(` -> Rendering page: ${entry.path}`);
const { html, head } = await render(entry.path, { manifest });
const pageHtml = template
.replace('<!--app-html-->', html)
.replace('<!--app-head-->', head);
writeHtmlFile(entry.path, pageHtml);
} else if (entry.mode === 'server') {
console.log(` -> Rendering server route: ${entry.path}`);
const result = await renderServerRoute(entry.path);
if (result) writeFile(entry.path, result);
}
}
This approach has a nice property: routes are explicit about what they generate. No crawling means no orphan pages slipping through, and dynamic routes declare exactly which slugs should be pre-rendered.
Module Preloading
This is where SSG pays dividends. Remember how every route is lazy-loaded (Part 2)? Without preloading, navigating to /blog/some-post would:
- Load the page
- Discover it needs the blog post chunk
- Fetch the chunk
- Render
With preloading, we tell the browser "you're going to need these files" before it asks:
<link rel="modulepreload" href="/assets/+Page-ghi789.js" />
<link rel="modulepreload" href="/assets/some-post-xyz.js" />
The SSG script calculates preloads by walking the manifest's import graph:
function getModulePreloads(
path: string,
manifest: Manifest,
routeMappings: RouteMapping[],
): string[] {
const preloads = new Set<string>();
const route = findRouteSource(path, routeMappings);
if (!route) return [];
function collectImports(key: string, visited = new Set<string>()) {
if (visited.has(key)) return;
visited.add(key);
const entry = manifest[key];
if (!entry) return;
// Add the file itself (except main entry)
if (!entry.isEntry) {
preloads.add(entry.file);
}
// Recursively collect imports
if (entry.imports) {
for (const imp of entry.imports) {
collectImports(imp, visited);
}
}
}
collectImports(route.source);
// Also preload content (MDX files)
if (route.contentSource) {
collectImports(route.contentSource);
}
return [...preloads];
}
Each page gets exactly the preloads it needs—no more, no less. The home page preloads its chunk. The blog post page preloads the post layout chunk plus the specific MDX content chunk.
Server Routes
Not everything is HTML. RSS feeds, sitemaps, and other non-React content need to be generated at build time too. The file-based routing supports +server.ts files for this:
// src/pages/feed.xml/+server.ts
import { blogPosts } from '@/content/blog';
export function get() {
const baseUrl = import.meta.env.VITE_BASE_URL;
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Adam Grady's Blog</title>
<link>${baseUrl}/blog</link>
${blogPosts.map(
(post) => `
<item>
<title>${escapeXml(post.title)}</title>
<link>${baseUrl}/blog/${post.slug}</link>
<pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate>
</item>`,
)}
</channel>
</rss>`;
}
The server entry discovers these routes the same way it discovers pages:
// src/entry-server.tsx
const serverImports = import.meta.glob<{ get: () => string }>(
'./pages/**/+server.ts',
);
const serverRoutes = Object.entries(serverImports).map(([path, mod]) => ({
path: path.replace('./pages', '').replace('/+server.ts', ''),
mod,
}));
export async function renderServerRoute(path: string): Promise<string | null> {
for (const { path: routePath, mod } of serverRoutes) {
if (matchPath(routePath, path).matched) {
const module = await mod();
return module.get();
}
}
return null;
}
The folder name becomes the output file. src/pages/feed.xml/+server.ts generates dist/static/feed.xml. Same for sitemap.xml or any other static file type.
The HTML Template
The source index.html is a simple template that Vite uses by default:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!--app-head-->
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
The SSG script replaces the placeholders:
const pageHtml = template
.replace('<!--app-html-->', html)
.replace('<!--app-head-->', head);
The head includes everything: meta tags, OpenGraph, stylesheets, and module preloads—all generated by the Head component with the manifest data.
Output Structure
After SSG runs, the output looks like:
Each page route gets its own index.html. Server routes produce files matching their folder names. Cloudflare (and most hosts) serve index.html automatically for directory requests, so /blog serves /blog/index.html.
Hydration
When the browser loads a pre-rendered page, React needs to "hydrate" it—attach event handlers to the existing HTML instead of re-rendering from scratch.
The client entry does this:
// src/entry-client.tsx
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(
document.getElementById('root')!,
<StrictMode>
<RouterProvider routes={routes}>
<Router />
</RouterProvider>
</StrictMode>,
);
hydrateRoot instead of createRoot. React walks the existing DOM, verifies it matches what it would render, and attaches handlers. If there's a mismatch, you get a hydration error—usually caused by the server and client rendering different content.
Common causes:
- Using
Date.now()orMath.random()during render - Reading browser-only APIs during SSR
- Router path mismatch between SSG and client
The router handles this by accepting initialPath during SSG and reading window.location during hydration—they should always match.
Head Generation
Each page needs proper meta tags for SEO and social sharing. The typical approach is to have a string-based generateHead() function for SSG and a separate React component for client-side updates. But that means maintaining two implementations that need to stay in sync.
Instead, I use a single Head component for both:
// src/lib/head/Head.tsx
export function Head({
title,
description,
url,
ogImage,
keywords,
type = 'website',
server = false,
}: HeadProps) {
const [isClient, setIsClient] = useState(server);
useEffect(() => {
clearExistingMetaTags();
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
const fullUrl = `${BASE_URL}${url}`;
// ...
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
{/* ... */}
</>
);
}
React 19 automatically hoists <title>, <meta>, and <link> tags to the document <head>. No portal required, no Helmet library. You render them inline and React handles the rest.
Server vs Client Rendering
During SSG, the component renders with server={true}:
// entry-server.tsx
const headStream = await renderToReadableStream(
<Head {...meta} url={path} server={true} />,
);
This produces HTML that gets injected into each page's <head>. Standard SSG behavior.
On the client, the component needs to handle hydration carefully. If it rendered the same tags immediately, React would try to hydrate against the SSG-generated HTML—but those tags are in the <head>, not inside the React root. Hydration would fail or produce duplicates.
The solution is two-pass rendering:
- First pass: Return
null. React hydrates successfully because there's nothing to match. - Second pass: After
useEffectruns, render the actual tags. React 19 hoists them to<head>.
The useState(server) initialization handles this. On the server, server={true} so it renders immediately. On the client, server defaults to false, so isClient starts as false and only becomes true after the effect runs.
Cleaning Up SSG Tags
There's a catch. When navigating to a new page, the new route's Head component renders fresh meta tags. But the old page's SSG-generated tags are still sitting in the <head>. Now you have duplicates—two titles, two descriptions, two OG images.
Search engines might pick up the wrong one. Page weight increases. The canonical URL could be wrong.
The fix is manual cleanup before React adds new tags:
function clearExistingMetaTags() {
document.querySelector('title')?.remove();
const metaNames = [
'description',
'author',
'keywords',
'twitter:card' /* ... */,
];
metaNames.forEach((name) => {
document.querySelector(`meta[name="${name}"]`)?.remove();
});
const metaProperties = ['og:type', 'og:url', 'og:title' /* ... */];
metaProperties.forEach((property) => {
document.querySelector(`meta[property="${property}"]`)?.remove();
});
document.querySelector('link[rel="canonical"]')?.remove();
document.querySelector('script[type="application/ld+json"]')?.remove();
}
This runs in the useEffect before setIsClient(true). The SSG tags get removed, then React adds the new ones. Clean slate on every navigation.
Is this elegant? Not particularly. It's the kind of thing frameworks hide from you. But it's also about 30 lines of code, it works reliably, and I understand exactly what it does. That trade-off works for me.
The route's meta property (defined in routes.ts) provides the data. For dynamic routes like /blog/:slug, meta is a function that computes values from params.
The Build Output
Running pnpm build produces:
-> Rendering page: /
-> Rendering page: /blog
-> Rendering page: /blog/building-portfolio-part-1-why
-> Rendering page: /blog/building-portfolio-part-2-router
-> Rendering page: /projects/portfolio
-> Rendering page: /terminal
-> Rendering page: *
-> Rendering server route: /feed.xml
-> Rendering server route: /sitemap.xml
Copying static assets...
SSG build complete!
Output: dist/static
Each route declares what it generates. Dynamic routes expand via their entries() function. Server routes produce non-HTML files.
What I'd Change for a Larger Site
This SSG implementation is tailored for a portfolio. For a larger site, I'd consider:
- Incremental builds: Only re-render pages whose content changed
- Parallel rendering: Render multiple pages concurrently (currently sequential)
- Build caching: Cache rendered HTML between builds
None of these are hard—they're just not worth the complexity for a handful of pages. Sitemap generation is already handled by a server route that reads from the same entries() data.
Alternatives I Considered
Next.js: The obvious choice. getStaticProps and getStaticPaths are well-designed. But I wanted to understand SSG at a lower level, and I didn't want to accept Next.js's opinions about everything else.
Astro: Great for content-heavy sites. The island architecture is clever. But I wanted a fully interactive React app, not islands of interactivity in a sea of static HTML.
vite-plugin-ssr (now Vike): Closest to what I built. If I were starting over, I might use it. But rolling my own taught me more about how SSG actually works.
The Full Picture
The complete SSG system is about 300 lines across a few files:
scripts/ssg.ts- Entry iteration, manifest parsing, HTML generationsrc/entry-server.tsx- React rendering, route entries, server route discoverysrc/lib/head/Head.tsx- Unified head component for server and clientsrc/pages/**/+Page.options.ts- Route metadata, loaders, and entry pointssrc/pages/**/+server.ts- Non-HTML static files (RSS, sitemap)
No special webpack loaders, no framework-specific data fetching conventions, no magic. Routes declare what they render via entries(). Server routes export a get() function that returns a string. The SSG script just iterates and writes files. The head management is the messiest part—cleaning up SSG tags on navigation isn't pretty—but it's all explicit and debuggable.