bini-ssg
OfficialStatic site generation for Bini.js - pre-renders your routes to HTML during vite build.
Overview
bini-ssg pre-renders your routes to HTML during vite build. Route discovery, link crawling, metadata injection, and shell fallbacks ship in a single Vite build plugin - no dev-server changes, no separate CLI.
- Build-only. Runs at
apply: 'build'; never touchesvite dev. - Automatic discovery. Static routes come from bini-router's route manifest.
- Link crawling. Internal
<a href>links in rendered HTML are followed up tocrawlDepth, so dynamic URLs get fully pre-rendered. - Shell fallback. Unmatched dynamic patterns still get a client-rendered shell page.
- Per-route metadata + CSS. Title, description, Open Graph, Twitter, icons, and route-scoped stylesheets are injected into each page.
- Resilient. If
render()throws, that route falls back to a shell and the build continues.
bini-ssg does not supply a render() implementation. You export one from src/main.* - see Implementing render().Installation
$ npm install --save-dev bini-ssg tsx
bini-router must already be installed and configured - bini-ssg imports it at build time to discover routes and read per-route metadata/CSS.Quick Start
1. Register the plugin
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { biniroute } from 'bini-router'
import { biniEnv } from 'bini-env'
import { biniSSG } from 'bini-ssg'
export default defineConfig({
plugins: [react(), biniEnv(), ...biniroute(), biniSSG()],
})2. Export render() from your entry
3. Build
$ npm run build
STEP Pre-rendering routes ok / -> dist/index.html ok /about -> dist/about/index.html ok /blog -> dist/blog/index.html ok /blog/hello -> dist/blog/hello/index.html SUCCESS Pre-rendered 4 routes
Implementing render()
bini-ssg imports src/main.{tsx,jsx,ts,js} in Node and calls its render export once per route:
export function render(url: string): Promise<string> | stringurl is the route being pre-rendered. The return value must be an HTML string - it gets inserted into <div id="root">…</div>.
A typical implementation with React Router's StaticRouter:
import { createRoot, hydrateRoot } from 'react-dom/client'
import App from './App'
declare global {
interface Window { __BINI_SHELL__?: boolean }
}
// Client mount (browser only)
if (typeof document !== 'undefined') {
const container = document.getElementById('root')!
if (window.__BINI_SHELL__ || !container.hasChildNodes()) {
createRoot(container).render(<App />) // shell or empty → client render
} else {
hydrateRoot(container, <App />) // pre-rendered → hydrate
}
}
// SSG render (Node only, called by bini-ssg)
export async function render(url: string): Promise<string> {
const { renderToString } = await import('react-dom/server')
const { StaticRouter } = await import('react-router-dom/server')
const { AppRoutes } = await import('./App')
return renderToString(
<StaticRouter location={url}>
<AppRoutes />
</StaticRouter>
)
}tsx. Guard anything that touches window/document at module scope.Client entry and hydration
bini-ssg writes two kinds of pages, and your client entry must treat them differently:
| Page type | #root contents | Client should |
|---|---|---|
| Pre-rendered | Full server-rendered HTML | hydrateRoot(...) |
| Shell | Empty | createRoot(...).render(...) |
Shell pages get this marker injected into <head>:
<script>window.__BINI_SHELL__=true;</script>Without it, React would try to hydrate an empty #root and throw hydration error #418.
Keep render() pure
Routes render sequentially in the same Node process by default. Module-scope state persists between routes, so the output of render() should be a pure function of url.
Crawling and Shells
Every page's rendered HTML is scanned for internal <a href> links. New URLs are queued and rendered, up to crawlDepth levels from the seed routes. So a /blog page that links to /blog/hello-world gets that URL pre-rendered as a full page.
External links, #anchors, mailto:, and file references by extension are ignored. Set crawlDepth: 0 to disable crawling.
Shell fallback
Any dynamic pattern that no crawled link matched still gets a shell page - your built index.html with the __BINI_SHELL__ marker. render() is not called for shells; the client app takes over on load.
If at least one crawled URL matched a pattern (e.g. /blog/hello-world for /blog/:slug), no shell is written for that pattern.
What crawling can't see
- Links that only appear after client-side data fetching.
- Dynamic URLs that no rendered page links to. Link to them from a statically rendered page to get them pre-rendered.
Metadata and CSS
Once a page is rendered, bini-ssg asks bini-router for that route's metadata and CSS, and applies both before writing the file. Runs for crawled pages and shells alike. Best-effort - a failure here never fails the build.
bini-ssg only consumes metadata; you author it in your route/layout files against bini-router's metadata API.
export const metadata = {
title: 'How bini-ssg pre-renders routes',
meta: {
description: 'A look at link crawling, shells, and metadata injection.',
robots: 'index, follow',
canonical: 'https://example.com/blog/how-bini-ssg-works',
openGraph: {
title: 'How bini-ssg pre-renders routes',
type: 'article',
image: 'https://example.com/og.png',
},
twitter: {
card: 'summary_large_image',
title: 'How bini-ssg pre-renders routes',
},
},
}Injected tags include <title>, description, robots, canonical, manifest, icons, Open Graph, and Twitter card. Existing tags with the same name/property/rel are updated in place.
/blog/:slug URL gets the same metadata. For per-post titles, resolve them inside render() and write them into the HTML you return.Route-scoped CSS
CSS modules imported by a specific route are resolved to their hashed build output and injected as <link rel="stylesheet"> tags on that route's page, deduplicated across shared imports.
Options
biniSSG({
appDir : 'src/app',
outputDir : undefined,
includeRoot : true,
fallback : false,
crawlDepth : 3,
concurrency : 1,
failOnError : true,
quiet : false,
minify : true,
})| Option | Default | Description |
|---|---|---|
| appDir | 'src/app' | Routes directory passed to bini-router. |
| outputDir | build.outDir | Where pre-rendered HTML is written. |
| includeRoot | true | Seed / even if bini-router didn't report it. |
| fallback | false | Also render /404 and write <outDir>/404.html for static-404 hosts. |
| crawlDepth | 3 | Max link-following depth. 0 disables crawling. |
| concurrency | 1 | Routes rendered in parallel. Raise only if render() has no shared module state. |
| failOnError | true | Fail the build on discovery, module-load, or write errors. |
| quiet | false | Suppress all output. |
| minify | true | Minify each written page with a hydration-safe html-minifier-terser config. |
render() call that throws is not a build failure - that route falls back to a shell and the build continues.Output
index.html- pre-rendered/.about/index.html,blog/index.html- static routes.blog/hello-world/index.html- crawled dynamic URL, fully pre-rendered.docs/[...slug]/index.html- shell, only when no crawled URL matched/docs/*.assets/- normal Vite output, unchanged.
Add 404.html at the root when fallback: true. Routes are deduplicated before rendering.
Hosting
- Static hosts serve
about/index.htmlfor/aboutautomatically, so pre-rendered routes work with no config. - Shell pages live in literal
[param]directories. Hosts won't map/blog/some-postonto that path - add a rewrite or SPA-fallback rule for those patterns. - Use
fallback: truefor hosts that look for a top-level404.html(Netlify, GitHub Pages).
Requirements
| Dependency | Version | Notes |
|---|---|---|
| Node.js | >= 18 | 18.19+ / 20.6+ recommended |
| Vite | ^8.0.0 | Peer |
| bini-router | >= 2.0.0 | Required |
| react, react-dom | >= 18 | Peer |
| react-router-dom | >= 6 | Peer |
| tsx | ^4.0.0 | Required - loads TS/JSX in Node |
node-html-parser, p-limit, and html-minifier-terser are installed automatically.
Troubleshooting
- Failed to load bini-router manifest - bini-router isn't installed, or
appDirpoints at the wrong directory. - File … must export a render(url) function -
src/main.*loaded but has norenderexport. - Failed to load src/main.tsx - an import failed under Node. Confirm
tsxis installed, and check module-scope code doesn't rely on browser globals orimport.meta.env. - Hydration error #418 - the page is a shell, but the client called
hydrateRoot. Checkwindow.__BINI_SHELL__before choosing betweencreateRootandhydrateRoot. - A route rendered as a shell unexpectedly -
render()threw for that URL. Callrender('/that-route')directly to see the error. - A dynamic URL wasn't pre-rendered - no rendered page links to it. Link to it from a static page, or accept the shell.
- Metadata or route-scoped CSS missing - injection is best-effort; bini-router may have thrown while resolving it for that route.
- Build stops early -
bini-ssgcallsprocess.exit(0)after a successful run. Other plugins'closeBundlehooks after it won't run.