bini-router
OfficialFile-based routing, nested layouts, templates, route groups, parallel routes @slot, intercepting routes (.), folder-scoped loading/error/404/default boundaries, MDX pages, and Web-standard Request → Response API routes for Vite.
Overview
bini-router is the core of Bini.js. Similar to Next.js App Router, but pure SPA with no server. Scans src/app/ on every file change and regenerates React Router tree instantly with HMR. Now with parallel routes @sidebar, intercepting routes (.) (..) (...), and typed document export with no HTML injection surface.
bini-deploy. This package focuses on routing, layouts, and local API serving.Features
File-based Routing
page.tsx in folders + flat files like about.tsx → URLs. index.* → parent.
Parallel Routes
@sidebar, @modal slots resolve independently with default.tsx fallback.
Intercepting Routes
(.)name, (..)name, (...)name for modal flows like photo-in-feed.
Dynamic & Catch-all
[id], [...slug], [[...slug]] for folders and flat files. Required vs optional tracked separately.
Security & Bounded
Segment validation, traversal guards, host-header validation, 10MB source limit, 500-entry capped preview cache.
Document Export
Typed tree, not raw HTML strings - no injection surface. html, body, head merged safely.
Install
$ npm install bini-router bini-env
| Dependency | Version |
|---|---|
| Vite | 8 or later |
| React | 18 or later |
| react-router-dom | Required - BrowserRouter, Routes, Route, Outlet, useLocation, useParams |
Setup
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()],
})import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(<App />)File Structure
Routing
export default function Dashboard() {
const [count, setCount] = useState(0)
return <h1>Dashboard</h1>
}export default function Post() {
const { slug } = useParams()
return <h1>Post: {slug}</h1>
}export default function Docs() {
// Matches /docs/anything/nested/here
return <h1>Docs</h1>
}:param → required * → optional ** last. Extension: .tsx > .jsx > .ts > .js > .mdx > .mdRoute Groups
Group names /^[a-zA-Z0-9_-]+$/. Folders using interception syntax (.) (..) (...) are NOT treated as groups.
Parallel Routes
Folder prefixed with @ defines slot: named subtree resolved independently and does not add URL segment. Slot name /^[a-zA-Z][a-zA-Z0-9_-]*$/.
| Concept | Behavior |
|---|---|
| Slot scanning | Dynamic, catch-alls, nested layouts, templates all work - tagged with slotName |
| Fallback | Nearest default.tsx (nearest-wins). No default → built-in No Content |
| Rendering | Own SlotBoundary block, not injected as named prop into layouts (Next.js difference) |
| Status | Experimental - verify against your layout, composition evolving |
export default function SidebarDefault() {
return <p>Nothing to show here for this page.</p>
}Intercepting Routes
Folder prefixed with (.) (..) (...) intercepts navigation to nearby route - same as Next.js for photo-in-modal.
| Prefix | Intercepts |
|---|---|
| (.)name | Sibling of current segment (same level) |
| (..)name | One level up |
| (...)name | Root of app |
Layouts
export const metadata = {
title: 'My App',
description: 'Built with bini-router',
}
export default function RootLayout() {
return <Outlet />
}export const metadata = {
title: 'Dashboard',
}
export default function DashboardLayout({ params }) {
return (
<div className="dashboard">
<aside>Sidebar</aside>
<main><Outlet /></main>
</div>
)
}<html> treated as shell and excluded. Circular chains detected. Eagerly bundled except root slot/boundary dependents.Templates
export default function DashboardTemplate({ children }) {
return <section className="page-transition">{children}</section>
}Templates render inside layout chain, directly around page. Receive children not params. Nearest-wins.
Loading, Not Found, Error, Default Boundaries
Nearest-wins. Subfolder shadows ancestor. default.tsx only inside @slot.
export default function DashboardLoading() {
return <p>Loading dashboard...</p>
}export default function NotFound() {
return (
<div>
<h1>Post not found</h1>
<Link to="/blog">Back to blog</Link>
</div>
)
}export default function DashboardError({ error, reset }) {
return (
<div>
<h2>Something broke</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}export default function SidebarDefault() {
return <p>Nothing to show here.</p>
}MDX and Markdown
# About us
This is **markdown**, rendered as JSX.
<button className="rounded bg-cyan-500 px-4 py-2 text-white">
Click me
</button>biniroute({
mdx: {
remarkPlugins: [],
rehypePlugins: [],
},
})Metadata
export const metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'Built with bini-router',
openGraph: {
title: 'Dashboard',
images: [{ url: '/og.png' }],
},
}index.html at build. Others update document.title via TitleSetter. Stripped from client bundle. Title template: Dashboard → Dashboard | My App.Document Export
Root layout can export document object to customize HTML shell. Enabled by default, disable with document: false. Head fragment parsed into typed tree (element/text/raw nodes) - no HTML injection surface even for handwritten JSX.
export const document = {
html: { lang: 'en', class: 'dark' },
body: { class: 'antialiased' },
head: (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<script async src="https://example.com/analytics.js"></script>
</>
),
}
export default function RootLayout() {
return <Outlet />
}| Key | Behavior |
|---|---|
| html | Attributes merged onto <html> |
| body | Attributes merged onto <body> |
| head | JSX → static typed structure, appended before </head> |
Auto-imports
| From | Symbols |
|---|---|
| react | useState, useEffect, useRef, useMemo, useCallback, useContext, createContext, useReducer, useId, useTransition, useDeferredValue |
| react-router-dom | Link, NavLink, useNavigate, useParams, useLocation, useSearchParams, Outlet |
| bini-env | getEnv, requireEnv |
export default function Profile() {
const { id } = useParams()
const [user, setUser] = useState(null)
return <div><Link to="/">Home</Link><h1>Profile {id}</h1></div>
}Environment Variables
BINI_FIREBASE_API_KEY=your_key
SMTP_USER=user@smtp.example.com
SMTP_PASS=your_passwordconst SMTP_USER = requireEnv('SMTP_USER') // throws if missing
const DEBUG = getEnv('DEBUG_MODE') // undefined if missingAPI Routes
| File | Route |
|---|---|
| api/users.ts | /api/users |
| api/posts/index.ts | /api/posts |
| api/posts/[id].ts | /api/posts/:id |
| api/[...catch].ts | /api/* |
| api/(internal)/health.ts | /api/health |
export default function handler(req) {
return Response.json({ message: 'hello', method: req.method })
}export default function handler(req) {
const params = JSON.parse(req.headers.get('x-bini-params') ?? '{}')
return Response.json({ id: params.id })
}import { Hono } from 'hono'
const app = new Hono()
app.all('/hello', (c) => c.json({ message: 'Hello!', method: c.req.method }))
export default app/api prefix - stripped before handler. Body capped 1MB (413), bodySizeLimit to adjust. CORS disabled by default - cors: true. Host header validated, capped cache 500 entries.Configuration Reference
biniroute({
appDir: 'src/app',
apiDir: 'src/app/api',
autoImportDir: 'src',
cors: false,
strictMode: true,
bodySizeLimit: 1024 * 1024,
document: true,
mdx: {},
})| Option | Type | Default | Description |
|---|---|---|---|
| appDir | string | src/app | Dir containing file-based routes |
| apiDir | string | src/app/api | Dir containing API routes |
| autoImportDir | string | src | Dir where auto-imports injected |
| cors | boolean | object | false | CORS handling for dev/preview API |
| strictMode | boolean | true | Fail on route conflicts |
| bodySizeLimit | number | 1048576 | Max API body size bytes |
| document | boolean | true | Process document export - typed tree, no HTML injection |
| base | string | / | Vite base option - router respects vite base, no separate basePath option |
| mdx | object | {} | Options passed to @mdx-js/rollup |