bini-ssg

Official

Static 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 touches vite dev.
  • Automatic discovery. Static routes come from bini-router's route manifest.
  • Link crawling. Internal <a href> links in rendered HTML are followed up to crawlDepth, 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

>_Terminal
$ 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

vite.config.ts
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

See Implementing render().

3. Build

>_Terminal
$ npm run build
>_Terminal
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:

src/main.tsx
export function render(url: string): Promise<string> | string

url 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:

src/main.tsx
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>
  )
}
Your entry runs in two environments - browser and Node via 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 contentsClient should
Pre-renderedFull server-rendered HTMLhydrateRoot(...)
ShellEmptycreateRoot(...).render(...)

Shell pages get this marker injected into <head>:

index.html
<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.

blog
:slug
[slug]
index.html
docs
[...slug]
index.html

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.

src/app/blog/[slug]/route.tsx
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.

For dynamic routes, metadata is keyed by the route pattern, not by each resolved URL - every /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

vite.config.ts
biniSSG({
  appDir      : 'src/app',
  outputDir   : undefined,
  includeRoot : true,
  fallback    : false,
  crawlDepth  : 3,
  concurrency : 1,
  failOnError : true,
  quiet       : false,
  minify      : true,
})
OptionDefaultDescription
appDir'src/app'Routes directory passed to bini-router.
outputDirbuild.outDirWhere pre-rendered HTML is written.
includeRoottrueSeed / even if bini-router didn't report it.
fallbackfalseAlso render /404 and write <outDir>/404.html for static-404 hosts.
crawlDepth3Max link-following depth. 0 disables crawling.
concurrency1Routes rendered in parallel. Raise only if render() has no shared module state.
failOnErrortrueFail the build on discovery, module-load, or write errors.
quietfalseSuppress all output.
minifytrueMinify each written page with a hydration-safe html-minifier-terser config.
A render() call that throws is not a build failure - that route falls back to a shell and the build continues.

Output

dist
index.html
about
index.html
blog
index.html
hello-world
index.html
docs
[...slug]
index.html
assets
  • 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.html for /about automatically, so pre-rendered routes work with no config.
  • Shell pages live in literal [param] directories. Hosts won't map /blog/some-post onto that path - add a rewrite or SPA-fallback rule for those patterns.
  • Use fallback: true for hosts that look for a top-level 404.html (Netlify, GitHub Pages).

Requirements

DependencyVersionNotes
Node.js>= 1818.19+ / 20.6+ recommended
Vite^8.0.0Peer
bini-router>= 2.0.0Required
react, react-dom>= 18Peer
react-router-dom>= 6Peer
tsx^4.0.0Required - 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 appDir points at the wrong directory.
  • File … must export a render(url) function - src/main.* loaded but has no render export.
  • Failed to load src/main.tsx - an import failed under Node. Confirm tsx is installed, and check module-scope code doesn't rely on browser globals or import.meta.env.
  • Hydration error #418 - the page is a shell, but the client called hydrateRoot. Check window.__BINI_SHELL__ before choosing between createRoot and hydrateRoot.
  • A route rendered as a shell unexpectedly - render() threw for that URL. Call render('/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-ssg calls process.exit(0) after a successful run. Other plugins' closeBundle hooks after it won't run.
Was this helpful?