Static Export

Pre-render your Bini.js app to static HTML with bini-ssg, ready for any static host.

bini-ssg pre-renders every route - static and dynamic - to static HTML as part of npm run build. It starts from your static routes, then crawls every rendered page for internal links and pre-renders those too. A blog post at /blog/my-first-post linked from /blog is fully pre-rendered - no extra config, no getStaticPaths.

There is no separate export command or export mode. The output is real server-rendered markup, not a client-only shell, ready for GitHub Pages, S3, Firebase, Surge, and any other static host.

Web target only. Static export applies to the Node.js/web target. Desktop and mobile builds (Windows, macOS, Linux, Android, iOS) do not use bini-ssg - they package the same routes into a native binary instead.

How It Works

bini-ssg is a Vite build plugin that runs during vite build. It:

src
app
page.tsx
blog
page.tsx
[slug]
page.tsx
/
/blog
/blog/:slug

Static routes (/, /blog) come straight from the route list. Dynamic routes like the highlighted /blog/:slug are discovered by crawling.

  • Reads your route list from bini-router's generateRouteManifest()
  • Calls your render() function for every static route
  • Crawls each rendered HTML for internal <a href> links and discovers dynamic routes (e.g. /blog/my-first-post linked from /blog)
  • Pre-renders every discovered dynamic route with your render() - full HTML, not a shell
  • Falls back to shell pages only for dynamic routes that were never linked (still valid - hydrated on client)
  • Writes one index.html per route into your output directory
Zero config crawling. If a page links to /docs/getting-started, /blog/hello-world, or /users/123, bini-ssg finds it and pre-renders it. You don't need getStaticPaths or a manifest.

Your render() Function

The render() function is exported from src/main.tsx and called by bini-ssg for every static route:

src/main.tsx
import { createRoot } from 'react-dom/client'
import App from './App'

// Client mount
createRoot(document.getElementById('root')!).render(<App />)

// SSG render (called by bini-ssg, Node-only)
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>
  )
}

This function uses React 19's renderToPipeableStream under the hood with StaticRouter from React Router, producing real server-rendered HTML.

Already scaffolded: The render() function is already in your project. You only need to modify it if you need custom server rendering logic.

Build Command

CommandWhen to use
npm run buildPre-renders every route to static HTML - GitHub Pages, S3, Firebase, Surge, and any static host
npm run startServes the production build with API routes - Node.js hosts (Railway, Render, Fly.io, VPS)

npm run build type-checks (TypeScript projects) and then runs vite build. The bini-ssg plugin drives pre-rendering as part of that same build.

>_Terminal
$ npm run build

Output Structure

Each route becomes its own folder with an index.html. Highlighted files were found by crawling and pre-rendered; the [slug] and [...slug] folders are shell fallbacks for dynamic routes that were never linked.

dist
index.html
about
index.html
blog
index.html
my-first-post
index.html
hello-world
index.html
[slug]
index.html
docs
getting-started
index.html
[...slug]
index.html
js
index-[hash].js
css
index-[hash].css
/
/about
/blog
/blog/my-first-post
/blog/hello-world
/blog/:slug
/docs/getting-started
/docs/*
Dynamic routes that are linked somewhere in your app are pre-rendered as real HTML. Only routes that were never discovered during crawling get the shell fallback.

Crawling & Shell Fallback

After pre-rendering static routes, bini-ssg parses each HTML file for internal links and crawls them:

dist/blog/index.html
<a href="/blog/my-first-post">
<a href="/blog/hello-world">
Discovered and pre-rendered
/blog/my-first-postdist/blog/my-first-post/index.html
/blog/hello-worlddist/blog/hello-world/index.html

This repeats recursively - if /blog/my-first-post links to /users/123, that page is also pre-rendered. Crawling respects your base path and skips external links, hashes, and /api/*.

Only dynamic routes that were never discovered during crawling get a shell page with a marker script:

<script>window.__BINI_SHELL__=true;</script>

Your client entry checks this flag to decide between createRoot and hydrateRoot:

src/main.tsx
const root = document.getElementById('root')!

if (window.__BINI_SHELL__) {
  createRoot(root).render(<App />)
} else {
  hydrateRoot(root, <App />)
}
Best of both worlds. Linked dynamic routes are fully pre-rendered for SEO and instant loads. Unlinked ones still work via the shell fallback - no 404, hydrated on client. To guarantee pre-rendering, just make sure a page links to it.

404 Handling

You can enable 404.html generation with the fallback option:

src
app
not-found.tsx
dist
404.html
index.html
vite.config.ts
// vite.config.ts
import { defineConfig } from 'vite'
import { biniSSG } from 'bini-ssg'

export default defineConfig({
  plugins: [
    // ...other plugins
    biniSSG({
      fallback: true,  // Render '/404' as 404.html
    }),
  ],
})
SituationWhat gets written to 404.html
src/app/not-found.tsxYour custom not-found page is pre-rendered to HTML
No custom not-found fileBuilt-in 404 page is used
Default: fallback is false. Enable it to generate 404.html for static hosts that support it.

Works on Any Fully Static Host

HostStatic routesDynamic routes
GitHub Pages✓ pre-rendered✓ crawled + pre-rendered, shell fallback
AWS S3 + CloudFront✓ pre-rendered✓ crawled + pre-rendered, shell fallback
Firebase Hosting✓ pre-rendered✓ crawled + pre-rendered, shell fallback
Surge.sh✓ pre-rendered✓ crawled + pre-rendered, shell fallback

Complete Example

A full setup for deploying to GitHub Pages with true SSG:

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({
  base: '/my-app/',  // GitHub Pages subpath
  plugins: [
    react(),
    biniEnv(),
    ...biniroute(),
    biniSSG({
      fallback: true,        // Generate 404.html
    }),
  ],
})

Run npm run build, then push the contents of dist/ to your GitHub Pages branch (or upload them through the GitHub Pages UI).

>_Terminal
$ npm run build
Was this helpful?