File-based routing, nested layouts, per-route metadata, and Hono-powered API routes for Vite.
Like Next.js — but pure SPA, zero server required.
- 🗂️ File-based routing —
page.tsx/page.jsxfiles map directly to URLs - 🪆 Nested layouts — layouts wrap their segment and all children automatically
- 🏷️ Per-route metadata —
export const metadatain any layout setsdocument.titleat runtime; root layout metadata is injected intoindex.htmlat build time - 🔀 Dynamic segments —
[id]/page.tsx→/:id,[...slug]→ catch-all - 🌐 API routes — Hono-powered, pure
Request → Responsehandlers insrc/app/api/ - ✨ Auto-imports —
useState,useEffect,Link,useNavigate,getEnvand more available in every page without importing - 🌿 Auto env loading —
.envloaded automatically for API routes via bini-env - 🎨 Custom loading screen — create
src/app/loading.tsxto replace the built-in spinner - 🛡️ Built-in error boundaries — per-layout crash isolation with a dev-friendly overlay
- ⏳ Lazy loading — every route is code-split automatically via
React.lazy - 🔄 HMR — file watcher with smart debounce (60ms), event deduplication, and live new-folder detection
- 🔒 Security — route segment validation, param name validation, path traversal guards, 10MB file size limits
- 📦 Zero config — works out of the box
- 💛 JavaScript & TypeScript — full support for both, auto-detected from your project
- 🚀 Deploy anywhere — Netlify Edge Functions, Vercel Edge, Cloudflare Workers, Node.js, Deno
npm install bini-router hono bini-env
honoandbini-envare required peer dependencies.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { biniroute } from 'bini-router'
import { biniEnv } from 'bini-env'
export default defineConfig({
plugins: [react(), biniEnv(), biniroute()],
})<!DOCTYPE html>
<html lang="en">
<head>
<!-- bini-router injects all meta tags here automatically -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>You do not need to manually add
<title>,<meta>, favicons, or Open Graph tags.
bini-router reads yourmetadataexport and injects everything at build time.
bini-router automatically injects imports into every page and layout file under src/app/ (excluding src/app/api/). You never need to write import statements for these:
From react:
useState useEffect useRef useMemo useCallback
useContext createContext useReducer useId useTransition useDeferredValueFrom react-router-dom:
Link NavLink useNavigate useParams useLocation useSearchParams OutletFrom bini-env:
getEnv requireEnvSo your pages look like this — no imports needed:
// src/app/profile/page.tsx
export default function Profile() {
const { id } = useParams()
const navigate = useNavigate()
const [user, setUser] = useState(null)
return (
<div>
<Link to="/">← Home</Link>
<h1>Profile {id}</h1>
</div>
)
}If you already import from one of these packages manually, bini-router detects it and skips injection — no duplicates ever.
Auto-imports are only injected into files inside
src/app/that are not insrc/app/api/, and not the auto-generatedApp.tsx/App.jsxfile itself.
bini-router uses bini-env to handle environment variables automatically:
- Client code — use
import.meta.env.BINI_*(prefix set automatically by bini-env) - API routes — use
getEnv()orrequireEnv()— no dotenv import needed - Dev server —
.envis loaded automatically when the server starts - Production — env vars are read from the host's environment (Netlify dashboard, Vercel settings, etc.)
# .env
BINI_FIREBASE_API_KEY=your_key # client-side — accessible via import.meta.env.BINI_*
SMTP_USER=[email protected] # server-side — accessible via getEnv() in API routes
SMTP_PASS=your_password
FROM_EMAIL=App <[email protected]>// src/app/api/email.ts — getEnv/requireEnv are auto-imported
const SMTP_USER = requireEnv('SMTP_USER') // throws if missing
const DEBUG = getEnv('DEBUG_MODE') // returns undefined if missingbini-router supports both JavaScript and TypeScript projects out of the box — no extra configuration needed.
Auto-detection order:
- Checks for
src/main.tsxorsrc/main.ts/src/main.jsxorsrc/main.js - Falls back to checking for a
tsconfig.jsonat the project root - Falls back to scanning
src/app/recursively (up to 5 levels deep) for any.ts/.tsxfiles
| TypeScript project | JavaScript project | |
|---|---|---|
| Auto-generated app entry | src/App.tsx |
src/App.jsx |
ErrorBoundary |
Full generic types | Plain JS class |
TitleSetter |
Typed props | Plain JS function |
| Your pages / layouts | .tsx |
.jsx |
| API routes | .ts |
.js |
src/
main.tsx ← mounts <App /> as usual
App.tsx ← auto-generated by bini-router — do not edit
app/
layout.tsx ← root layout + global metadata
page.tsx ← /
loading.tsx ← custom loading screen (optional)
not-found.tsx ← custom 404 page (optional)
dashboard/
layout.tsx ← nested layout for /dashboard/*
page.tsx ← /dashboard
[id]/
page.tsx ← /dashboard/:id
blog/
[slug]/
page.tsx ← /blog/:slug
api/
users.ts ← /api/users
posts/
index.ts ← /api/posts
[id].ts ← /api/posts/:id
[...catch].ts ← /api/* catch-all
Files and directories prefixed with
_or.are ignored by the router.
Theapi/directory is excluded from page route scanning.
Directory traversal is capped at 100 levels deep.
// src/app/dashboard/page.tsx — no imports needed
export default function Dashboard() {
const [count, setCount] = useState(0)
return <h1>Dashboard</h1>
}Pages are scanned from flat files in a directory (e.g. about.tsx → /about) and from page.* files inside named subdirectories. Both forms are supported simultaneously.
// src/app/blog/[slug]/page.tsx — useParams auto-imported
export default function Post() {
const { slug } = useParams()
return <h1>Post: {slug}</h1>
}// src/app/docs/[...path]/page.tsx
export default function Docs() {
// matches /docs/anything/nested/here
return <h1>Docs</h1>
}Route priority: static routes are matched before dynamic ones; dynamic routes before catch-alls. Routes are sorted by this priority and then by path length (shortest first).
Layouts wrap all pages in their directory and subdirectories. bini-router walks up the directory tree from each page to collect the full layout chain, stopping at the appDir root.
All layouts — including the root layout — are rendered as React Router <Route element> wrappers using <Outlet />. The root layout receives child routes via <Outlet /> exactly like nested layouts do.
// src/app/layout.tsx — root layout
export const metadata = {
title : 'My App',
description: 'Built with bini-router',
}
export default function RootLayout() {
return <Outlet />
}// src/app/dashboard/layout.tsx — nested layout
export const metadata = {
title: 'Dashboard',
}
export default function DashboardLayout() {
return (
<div className="dashboard">
<aside>Sidebar</aside>
<main><Outlet /></main>
</div>
)
}Layouts that contain an
<html>tag are automatically excluded from the chain (treated as HTML shell files, not route layouts).
Layouts without a default export are also excluded from the chain.
Circular layout dependencies are detected and throw aCircularLayoutError.
Create src/app/loading.tsx with a default export to replace the built-in spinner. bini-router automatically detects and uses it as the Suspense fallback for every lazy-loaded route and layout.
// src/app/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-10 w-10 border-t-2 border-blue-500" />
</div>
)
}If the file exists but has no default export, the built-in spinner is used automatically. The built-in spinner is dark-mode aware — it reads document.documentElement.classList for a dark class and falls back to prefers-color-scheme, with a MutationObserver for live theme switching.
// src/app/not-found.tsx
export default function NotFound() {
return (
<div>
<h1>404 — Page not found</h1>
<Link to="/">Go home</Link>
</div>
)
}A built-in 404 page is rendered automatically if not-found.tsx is absent or has no default export. If a custom not-found.tsx exists, it is wrapped with the root layout chain (same layouts that wrap /) before being rendered at path="*".
Export metadata from any layout.tsx. Root layout metadata is injected into index.html at build time via transformIndexHtml. Nested layout titles update document.title at runtime via a TitleSetter component rendered inside the layout's Suspense boundary.
export const metadatais automatically stripped from the browser bundle by thetransformhook — it never ships to the client.
export const metadata = {
title : 'Dashboard',
description : 'Your personal dashboard',
viewport : 'width=device-width, initial-scale=1.0',
themeColor : '#00CFFF',
charset : 'UTF-8',
robots : 'index, follow',
manifest : '/site.webmanifest',
keywords : ['react', 'vite', 'dashboard'], // array or string
authors : [{ name: 'Your Name', url: 'https://example.com' }],
canonical : 'https://myapp.com/dashboard',
openGraph: {
title : 'Dashboard',
description: 'Your personal dashboard',
url : 'https://myapp.com/dashboard',
type : 'website',
images : [{ url: '/og.png', width: 1200, height: 630 }],
},
twitter: {
card : 'summary_large_image',
title : 'Dashboard',
description: 'Your personal dashboard',
creator : '@yourhandle',
images : ['/og.png'],
},
icons: {
icon : [{ url: '/favicon.svg', type: 'image/svg+xml' }],
shortcut: [{ url: '/favicon.png' }],
apple : [{ url: '/apple-touch-icon.png', sizes: '180x180' }],
},
}All fields are optional. Only the root layout.tsx metadata is used for index.html injection. All metadata values are HTML-escaped before injection.
Write your API files in src/app/api/. The same handler code runs unchanged across all environments — vite dev, vite preview, and every production platform.
API handlers are loaded on-demand and cached by mtime — touching a file in dev busts the cache immediately without a server restart.
Both vite dev and vite preview serve API routes identically. The dev server mounts a middleware at /api that strips the prefix before passing the request to your handler, so there is no difference in behavior between the two. No extra setup is needed — your handlers work the same way locally as they do in production.
vite dev # API routes live at http://localhost:3000/api/*
vite preview # same behaviour, served from the dist build// src/app/api/hello.ts
import { Hono } from 'hono'
const app = new Hono()
app.all('/hello', (c) => {
return c.json({
message : 'Hello from Bini.js!',
timestamp: new Date().toISOString(),
method : c.req.method,
})
})
export default appThis handler is reachable at /api/hello in every environment — vite dev, vite preview, and all five production platforms — without any changes. Write routes without the /api prefix. bini-router strips it before your handler sees the request in dev/preview, and mounts the app under /api in the production entry automatically.
// src/app/api/hello.ts
export default function handler(req: Request) {
return Response.json({ message: 'hello', method: req.method })
}Route params are passed via the x-bini-params request header as a JSON string when using plain function handlers.
// src/app/api/posts/[id].ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/posts/:id', (c) => c.json({ id: c.req.param('id') }))
export default appCORS is enabled by default for all /api/* routes in dev, preview, and production. The following methods are allowed: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD. Preflight OPTIONS requests are handled automatically with a 204 response and a 24-hour Access-Control-Max-Age.
Set cors: false to disable.
biniroute({ cors: false })bini-router uses one codebase across all five platforms — the same src/app/api/ handlers run in vite dev, vite preview, and every production target without any changes. Set platform once in vite.config.ts and bini-router generates the production entry file automatically during vite build.
| Platform | Entry file generated | Runtime |
|---|---|---|
netlify |
netlify/edge-functions/api.ts |
Deno (Edge) |
vercel |
api/index.ts |
Edge |
cloudflare |
worker.ts |
Workers |
node |
(none — handled by bini-server) | Node.js |
deno |
server/index.ts |
Deno |
biniroute({ platform: 'netlify' })Generates netlify/edge-functions/api.ts using Deno CDN URL imports ([email protected]) — no npm deps needed in the edge function.
⚠️ Netlify Edge Functions run on the Deno runtime, not Node.js. Node-specific packages likenodemailer,fs,path, or anything that depends on Node built-ins will not work. Use Deno-compatible or Web API alternatives instead (e.g.fetchfor HTTP, Deno CDN imports for utilities).
Add netlify.toml:
[build]
command = "vite build"
publish = "dist"
[[edge_functions]]
path = "/api/*"
function = "api"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200biniroute({ platform: 'vercel' })Generates api/index.ts (or api/index.js) as a Vercel Edge Function with export const config = { runtime: 'edge' }.
Add vercel.json:
{
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api/index.ts" },
{ "source": "/(.*)", "destination": "/index.html" }
]
}
⚠️ Vercel readsapi/before the build step runs. You must commit the generated file:git add api/index.ts git commit -m "chore: update vercel api entry" git push
biniroute({ platform: 'cloudflare' })Generates worker.ts (or worker.js) with a built-in SPA fallback — the ASSETS binding serves static files first, and all unmatched paths fall through to index.html for React Router. Requires a wrangler.toml with the ASSETS binding.
Add wrangler.toml:
name = "my-app"
main = "worker.ts"
compatibility_date = "2025-04-09"
[assets]
directory = "./dist"
binding = "ASSETS"Run vite build — the worker file is generated automatically and picked up by the Cloudflare dashboard on deploy.
Node.js serving is handled by bini-server — no entry file is generated by bini-router. Setting platform: 'node' is accepted but produces no output.
vite build && npm startbiniroute({ platform: 'deno' })Generates server/index.ts (or server/index.js) using Deno CDN imports ([email protected]) and Deno.serve. Port defaults to 3000 or reads from the PORT environment variable.
⚠️ Deno Deploy does not run Node.js. Node-specific packages likenodemailer,fs,path, or anything that depends on Node built-ins will not work. Use Deno-compatible or Web API alternatives instead (e.g.fetchfor HTTP, Deno CDN imports for utilities).
⚠️ Deno Deploy readsserver/before the build step runs. You must commit the generated file:git add server/index.ts git commit -m "chore: update deno server entry" git push
In Deno Console, set:
- Entrypoint:
server/index.ts - Build Command:
vite build - Runtime: Dynamic App
Use basePath when your app is deployed under a subpath (e.g. /app, /v2). bini-router prepends it to every page route and the BrowserRouter basename automatically.
// vite.config.ts
biniroute({ basePath: '/app' })With basePath: '/app':
src/app/page.tsx→/appsrc/app/dashboard/page.tsx→/app/dashboardBrowserRouter basenameis set to"/app"at build time
basePathaffects page routes and the production API entry (e.g./app/api/users). Dev and preview always serve API routes at/api/*regardless ofbasePath— the middleware is mounted directly at/api.
Without
basePathset,basenamefalls back toimport.meta.env.BASE_URLand then"/".
biniroute({
appDir : 'src/app', // Default: src/app
apiDir : 'src/app/api', // Default: src/app/api
cors : true, // Enable CORS on dev/preview API. Default: true
platform : 'netlify', // 'netlify' | 'vercel' | 'cloudflare' | 'deno' | 'node'
// generates production entry on build (except 'node')
strictMode: true, // Throw on route conflicts. Default: true
basePath : '', // Subpath prefix for all routes. Default: ''
// e.g. '/app' → all routes prefixed with /app
})Every layout is wrapped in a built-in ErrorBoundary. In development, runtime errors are dispatched as a __bini_error__ CustomEvent on window (consumed by bini-overlay) so dev overlays can display them — the boundary itself renders null in dev so the overlay takes over the screen. In production, a fallback UI is rendered with a "Try again" button that resets the boundary state.
bini-router watches src/app/ during development and regenerates App.tsx automatically.
- New file → regenerates after 300ms debounce
- New folder → watched instantly; regenerates after 300ms if a
page.*file appears within 300ms - Changed file → regenerates after 60ms debounce
- Deleted file or folder → removed from routes and triggers reload
- Root layout change → full module graph invalidation + full reload
- API file change → clears module cache entry and route cache, triggers full reload
- Events are deduplicated within a 500ms window per
file:eventkey (TTL: 2s) to prevent redundant reloads - Code generation is guarded by an
isGeneratingflag — concurrent regenerations are dropped, not queued
You never need to restart the dev server when adding or removing routes.
bini-router validates all route segment names and dynamic parameter names at scan time:
- Segment names must match
/^[a-zA-Z0-9_-]+$/and be under 100 characters - Parameter names (inside
[brackets]) must match/^[a-zA-Z_][a-zA-Z0-9_]*$/ - Paths containing
..or//are rejected (path traversal guard) - Invalid names are skipped with a warning — they never cause a crash
- Decoded URL parameter values are also checked for
..and//at request time
MIT © Binidu Ranasinghe