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.
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:
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'sgenerateRouteManifest() - 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-postlinked 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.htmlper route into your output directory
/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:
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.
render() function is already in your project. You only need to modify it if you need custom server rendering logic.Build Command
| Command | When to use |
|---|---|
npm run build | Pre-renders every route to static HTML - GitHub Pages, S3, Firebase, Surge, and any static host |
npm run start | Serves 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.
$ 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.
Crawling & Shell Fallback
After pre-rendering static routes, bini-ssg parses each HTML file for internal links and crawls them:
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:
const root = document.getElementById('root')!
if (window.__BINI_SHELL__) {
createRoot(root).render(<App />)
} else {
hydrateRoot(root, <App />)
}404 Handling
You can enable 404.html generation with the fallback option:
// 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
}),
],
})| Situation | What gets written to 404.html |
|---|---|
src/app/not-found.tsx | Your custom not-found page is pre-rendered to HTML |
| No custom not-found file | Built-in 404 page is used |
fallback is false. Enable it to generate 404.html for static hosts that support it.Works on Any Fully Static Host
| Host | Static routes | Dynamic 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:
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).
$ npm run build