npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bini-router

v1.0.40

Published

File-based routing, nested layouts, auto-imports, and Hono-powered API routes for Vite + React

Readme

bini-router

npm version license vite react hono typescript PRs Welcome

File-based routing, nested layouts, per-route metadata, and Hono-powered API routes for Vite.
Like Next.js — but pure SPA, zero server required.


Features

  • 🗂️ File-based routingpage.tsx / page.jsx files map directly to URLs
  • 🪆 Nested layouts — layouts wrap their segment and all children automatically
  • 🏷️ Per-route metadataexport const metadata in any layout sets document.title at runtime; root layout metadata is injected into index.html at build time
  • 🔀 Dynamic segments[id]/page.tsx/:id, [...slug] → catch-all
  • 🌐 API routes — Hono-powered, pure Request → Response handlers in src/app/api/
  • Auto-importsuseState, useEffect, Link, useNavigate, getEnv and more available in every page without importing
  • 🌿 Auto env loading.env loaded automatically for API routes via bini-env
  • 🎨 Custom loading screen — create src/app/loading.tsx to 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

Install

npm install bini-router hono bini-env

hono and bini-env are required peer dependencies.


Setup

vite.config.ts / vite.config.js

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()],
})

index.html

<!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 your metadata export and injects everything at build time.


Auto-imports

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  useDeferredValue

From react-router-dom:

Link  NavLink  useNavigate  useParams  useLocation  useSearchParams  Outlet

From bini-env:

getEnv  requireEnv

So 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 in src/app/api/, and not the auto-generated App.tsx / App.jsx file itself.


Environment Variables

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() or requireEnv() — no dotenv import needed
  • Dev server.env is 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_*
[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 missing

JavaScript & TypeScript

bini-router supports both JavaScript and TypeScript projects out of the box — no extra configuration needed.

Auto-detection order:

  1. Checks for src/main.tsx or src/main.ts / src/main.jsx or src/main.js
  2. Falls back to checking for a tsconfig.json at the project root
  3. Falls back to scanning src/app/ recursively (up to 5 levels deep) for any .ts / .tsx files

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


File Structure

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.
The api/ directory is excluded from page route scanning.
Directory traversal is capped at 100 levels deep.


Pages

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

Dynamic routes

// src/app/blog/[slug]/page.tsx — useParams auto-imported
export default function Post() {
  const { slug } = useParams()
  return <h1>Post: {slug}</h1>
}

Catch-all routes

// 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

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 a CircularLayoutError.


Custom Loading Screen

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.


Custom 404

// 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="*".


Metadata

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 metadata is automatically stripped from the browser bundle by the transform hook — 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.


API Routes

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.

Local testing

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

Hono app (recommended)

// 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 app

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

Plain function handlers

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

Dynamic API routes

// 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 app

CORS

CORS 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 })

Deployment

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 |


🟩 Netlify

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 like nodemailer, fs, path, or anything that depends on Node built-ins will not work. Use Deno-compatible or Web API alternatives instead (e.g. fetch for 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 = 200

▲ Vercel

biniroute({ 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 reads api/ 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

🟠 Cloudflare Workers

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 (Railway, Render, Fly.io, VPS)

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 start

🦕 Deno

biniroute({ 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 like nodemailer, fs, path, or anything that depends on Node built-ins will not work. Use Deno-compatible or Web API alternatives instead (e.g. fetch for HTTP, Deno CDN imports for utilities).

⚠️ Deno Deploy reads server/ 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

Base Path

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/app
  • src/app/dashboard/page.tsx/app/dashboard
  • BrowserRouter basename is set to "/app" at build time

basePath affects page routes and the production API entry (e.g. /app/api/users). Dev and preview always serve API routes at /api/* regardless of basePath — the middleware is mounted directly at /api.

Without basePath set, basename falls back to import.meta.env.BASE_URL and 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
})

Error Boundaries

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.


HMR & File Watcher

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:event key (TTL: 2s) to prevent redundant reloads
  • Code generation is guarded by an isGenerating flag — concurrent regenerations are dropped, not queued

You never need to restart the dev server when adding or removing routes.


Route Naming Rules

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

License

MIT © Binidu Ranasinghe