bini-router

Official

File-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.

Zero config. Production deployment handled by companion 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

>_Terminal
$ npm install bini-router bini-env
DependencyVersion
Vite8 or later
React18 or later
react-router-domRequired - BrowserRouter, Routes, Route, Outlet, useLocation, useParams

Setup

vite.config.ts
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()],
})
src/main.tsx
import { createRoot } from 'react-dom/client'
import App from './App'

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

File Structure

src
main.tsx
App.tsx
app
ƒlayout.tsx
ƒtemplate.tsx
ƒpage.tsx
loading.tsx
not-found.tsx
error.tsx
global-error.tsx
about.mdx
(marketing)
ƒlayout.tsx
pricing
ƒpage.tsx
@sidebar
ƒdefault.tsx
ƒpage.tsx
dashboard
ƒpage.tsx
photo
[id]
ƒpage.tsx
(.)view
ƒpage.tsx
blog
ƒindex.tsx
ƒ[slug].tsx
api
ƒusers.ts
posts
ƒ[id].ts
ƒ[...catch].ts

Routing

src/app/dashboard/page.tsx
export default function Dashboard() {
  const [count, setCount] = useState(0)
  return <h1>Dashboard</h1>
}
src/app/blog/[slug]/page.tsx
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>
}
Priority: static → dynamic :param → required * → optional ** last. Extension: .tsx > .jsx > .ts > .js > .mdx > .md

Route Groups

app
(marketing)
ƒlayout.tsx
about
ƒpage.tsx
(app)
ƒlayout.tsx
settings
ƒpage.tsx

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_-]*$/.

app
ƒlayout.tsx
ƒpage.tsx
@sidebar
ƒdefault.tsx
ƒpage.tsx
settings
ƒpage.tsx
ConceptBehavior
Slot scanningDynamic, catch-alls, nested layouts, templates all work - tagged with slotName
FallbackNearest default.tsx (nearest-wins). No default → built-in No Content
RenderingOwn SlotBoundary block, not injected as named prop into layouts (Next.js difference)
StatusExperimental - verify against your layout, composition evolving
src/app/@sidebar/default.tsx
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.

PrefixIntercepts
(.)nameSibling of current segment (same level)
(..)nameOne level up
(...)nameRoot of app
app
feed
ƒpage.tsx
photo
[id]
ƒpage.tsx
photo
[id]
ƒpage.tsx
(.)view
ƒpage.tsx
Intercepting folder itself does not add URL segment; segment after it does. Only default export inside treated as route. Conflicts compared only at same intercept level.

Layouts

src/app/layout.tsx
export const metadata = {
  title: 'My App',
  description: 'Built with bini-router',
}

export default function RootLayout() {
  return <Outlet />
}
src/app/dashboard/layout.tsx
export const metadata = {
  title: 'Dashboard',
}

export default function DashboardLayout({ params }) {
  return (
    <div className="dashboard">
      <aside>Sidebar</aside>
      <main><Outlet /></main>
    </div>
  )
}
Layouts containing <html> treated as shell and excluded. Circular chains detected. Eagerly bundled except root slot/boundary dependents.

Templates

src/app/dashboard/template.tsx
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.

src/app/dashboard/loading.tsx
export default function DashboardLoading() {
  return <p>Loading dashboard...</p>
}
src/app/blog/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h1>Post not found</h1>
      <Link to="/blog">Back to blog</Link>
    </div>
  )
}
src/app/dashboard/error.tsx
export default function DashboardError({ error, reset }) {
  return (
    <div>
      <h2>Something broke</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}
src/app/@sidebar/default.tsx
export default function SidebarDefault() {
  return <p>Nothing to show here.</p>
}

MDX and Markdown

about.mdx
# About us

This is **markdown**, rendered as JSX.

<button className="rounded bg-cyan-500 px-4 py-2 text-white">
  Click me
</button>
vite.config.ts
biniroute({
  mdx: {
    remarkPlugins: [],
    rehypePlugins: [],
  },
})

Metadata

src/app/layout.tsx
export const metadata = {
  title: {
    default: 'My App',
    template: '%s | My App',
  },
  description: 'Built with bini-router',
  openGraph: {
    title: 'Dashboard',
    images: [{ url: '/og.png' }],
  },
}
Root metadata injected into 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.

src/app/layout.tsx
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 />
}
KeyBehavior
htmlAttributes merged onto <html>
bodyAttributes merged onto <body>
headJSX → static typed structure, appended before </head>

Auto-imports

FromSymbols
reactuseState, useEffect, useRef, useMemo, useCallback, useContext, createContext, useReducer, useId, useTransition, useDeferredValue
react-router-domLink, NavLink, useNavigate, useParams, useLocation, useSearchParams, Outlet
bini-envgetEnv, requireEnv
src/app/profile/page.tsx
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

.env
BINI_FIREBASE_API_KEY=your_key
SMTP_USER=user@smtp.example.com
SMTP_PASS=your_password
src/app/api/email.ts
const SMTP_USER = requireEnv('SMTP_USER')  // throws if missing
const DEBUG = getEnv('DEBUG_MODE')         // undefined if missing

API Routes

FileRoute
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
src/app/api/hello.ts
export default function handler(req) {
  return Response.json({ message: 'hello', method: req.method })
}
src/app/api/posts/[id].ts (params)
export default function handler(req) {
  const params = JSON.parse(req.headers.get('x-bini-params') ?? '{}')
  return Response.json({ id: params.id })
}
src/app/api/hello.ts (Hono)
import { Hono } from 'hono'
const app = new Hono()
app.all('/hello', (c) => c.json({ message: 'Hello!', method: c.req.method }))
export default app
Write routes without /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

vite.config.ts
biniroute({
  appDir: 'src/app',
  apiDir: 'src/app/api',
  autoImportDir: 'src',
  cors: false,
  strictMode: true,
  bodySizeLimit: 1024 * 1024,
  document: true,
  mdx: {},
})
OptionTypeDefaultDescription
appDirstringsrc/appDir containing file-based routes
apiDirstringsrc/app/apiDir containing API routes
autoImportDirstringsrcDir where auto-imports injected
corsboolean | objectfalseCORS handling for dev/preview API
strictModebooleantrueFail on route conflicts
bodySizeLimitnumber1048576Max API body size bytes
documentbooleantrueProcess document export - typed tree, no HTML injection
basestring/Vite base option - router respects vite base, no separate basePath option
mdxobject{}Options passed to @mdx-js/rollup
Was this helpful?