File-Based Routing

Learn how special files like page.tsx, layout.tsx, loading.tsx, error.tsx, and MDX pages define route behavior in Bini.js.

Overview

Bini.js uses a file-based routing system where files in the src/app/ directory automatically become routes in your application. Each file has a specific purpose and is automatically recognized by the router.

Zero Configuration

Routes are automatically generated from your file structure

TypeScript & JavaScript

Full support for both .tsx and .jsx files

MDX & Markdown

Content pages work out of the box with .mdx and .md

Nested Layouts

Create shared UI that persists across navigation

Special Files

Bini.js recognizes these special files in the src/app/ directory:

FilePurposeRequired
page.tsx / page.jsxDefines a public route — required to make a route accessible✅ Yes
page.mdx / page.mdMDX/Markdown content route — full JSX/import/export support❌ No
layout.tsx / layout.jsxShared UI that wraps pages and nested layouts✅ Yes (root)
loading.tsx / loading.jsxLoading UI shown while page content streams❌ No
error.tsx / error.jsxError UI when something breaks in a route or its children❌ No
not-found.tsx / not-found.jsxCustom 404 page for unmatched routes❌ No
Note: Files and directories prefixed with _ or . are ignored by the router. The api/ directory is excluded from page route scanning.

page.tsx / page.jsx

The page.tsx file defines a public route. Without it, the folder is not accessible via URL. Each page.tsx must have a default export of a React component.

Basic Pages

// src/app/page.tsx
export default function HomePage() {
  return <h1>Welcome to Bini.js!</h1>
}

// src/app/about/page.tsx
export default function AboutPage() {
  return <h1>About Us</h1>
}

// src/app/blog/page.tsx
export default function BlogPage() {
  return <h1>Blog</h1>
}

Flat File Pages

Pages can also be defined as flat files without a folder:

src/app/ ├── page.tsx → / ├── about.tsx → /about ├── contact.tsx → /contact └── blog/ └── [slug].tsx → /blog/:slug

This creates routes at /about and /contact without needing separate folders.

Auto-Imports

Bini.js 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:

useState
useEffect
useRef
useMemo
useCallback
useContext
createContext
useReducer
useId
useTransition
useDeferredValue
Link
NavLink
useNavigate
useParams
useLocation
useSearchParams
Outlet
getEnv
requireEnv
Auto-imports: If you already import from one of these packages manually, Bini.js detects it and skips injection — no duplicates ever.

MDX & Markdown Pages

Bini.js supports .mdx and .md files as content routes out of the box — no setup required. @mdx-js/rollup is bundled internally.

MDX Page Example

app/about.mdx
---
export const metadata = {
  title: 'About',
  description: 'Learn about us',
}
---

# About us

This is regular **markdown**, rendered as JSX under the hood. You can also
drop in real components:

<button className="rounded bg-cyan-500 px-4 py-2 text-white">
  Click me
</button>

## Features

- File-based routing
- MDX & Markdown support
- Nested layouts

MDX with Imports

app/blog/[slug].mdx
import { Button } from '@/components/Button'

export const metadata = {
  title: 'Blog Post',
}

# My Blog Post

<Button>Click me</Button>

Flat File MDX Routes

src/app/ ├── about.mdx → /about ├── blog/ │ ├── page.tsx → /blog │ └── [slug].mdx → /blog/:slug └── contact.md → /contact

Both .mdx and .md are compiled through the same MDX pipeline (full JSX/import/export support in both). Auto-imports apply to MDX files the same as any other page.

Note: layout.tsx, not-found.tsx, loading.tsx, and error.tsx must stay .tsx/.jsx — they define app structure rather than content.

Extension Priority

When multiple files share the same base name (e.g., both page.tsx and page.mdx exist in the same folder):

.tsx > .jsx > .ts > .js > .mdx > .md

The higher-priority file wins; the lower-priority one is simply ignored for that route.

layout.tsx / layout.jsx

Layouts wrap pages and other layouts, providing shared UI that persists across navigation. All layouts are rendered as React Router <Route element> wrappers using <Outlet />.

Root Layout

The root layout at src/app/layout.tsx is required. It wraps all pages in your application and can export metadata for the entire app.

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

export default function RootLayout() {
  return <Outlet />
}

Nested Layout

Create layouts for specific sections by adding layout.tsx in subdirectories.

app/dashboard/layout.tsx
// src/app/dashboard/layout.tsx
export const metadata = {
  title: 'Dashboard',
}

export default function DashboardLayout() {
  return (
    <div className="dashboard">
      <aside>Sidebar</aside>
      <main><Outlet /></main>
    </div>
  )
}

Layout Nesting

src/app/ ├── layout.tsx ← Wraps everything ├── page.tsx → / └── dashboard/ ├── layout.tsx ← Wraps /dashboard/* ├── page.tsx → /dashboard └── settings/ └── page.tsx → /dashboard/settings

The root layout wraps the dashboard layout, which wraps the settings page.

Layout Metadata

Export metadata from any layout. Root layout metadata is injected into index.html at build time. Nested layout titles update document.title at runtime.

app/dashboard/layout.tsx
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'],
  authors: [{ name: 'Your Name' }],
  canonical: 'https://myapp.com/dashboard',
  openGraph: {
    title: 'Dashboard',
    description: 'Your personal dashboard',
    url: 'https://myapp.com/dashboard',
    type: 'website',
    images: [{ url: '/og.png' }],
  },
  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' }],
  },
}

loading.tsx / loading.jsx

The loading.tsx file provides a loading UI while page content is being loaded. It wraps the page in a Suspense boundary.

Global Loading

app/loading.tsx
// 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-cyan-500" />
    </div>
  )
}

Route-Specific Loading

app/dashboard/loading.tsx
// src/app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="p-6">
      <div className="animate-pulse space-y-4">
        <div className="h-8 bg-slate-700 rounded w-1/4" />
        <div className="h-32 bg-slate-700 rounded" />
        <div className="h-32 bg-slate-700 rounded" />
      </div>
    </div>
  )
}

The loading UI is shown immediately on navigation while the page content streams in. If no loading.tsx exists, a built-in dark-mode-aware spinner is used automatically.

error.tsx / error.jsx

The error.tsx file catches errors thrown anywhere in a route or its children. It wraps the route and its children in an Error Boundary.

Error Component

app/dashboard/error.tsx
// src/app/dashboard/error.tsx
export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="p-6">
      <h2 className="text-xl font-bold text-white mb-2">Something went wrong!</h2>
      <p className="text-red-400 mb-4">{error.message}</p>
      <button 
        onClick={reset}
        className="px-4 py-2 bg-cyan-500 text-white rounded hover:bg-cyan-600 transition-colors"
      >
        Try again
      </button>
    </div>
  )
}

Error Props

Your error.tsx component receives two props:

  • error — The thrown Error object with message and stack trace
  • reset — A function that clears the error state and re-renders children

Folder-Scoped Errors

Place error.tsx in any folder to catch errors only for that route and its children:

src/app/ ├── layout.tsx ├── page.tsx ├── dashboard/ │ ├── layout.tsx │ ├── page.tsx │ ├── error.tsx ← Only catches errors in /dashboard/* │ └── settings/ │ └── page.tsx ← Also wrapped by dashboard/error.tsx └── blog/ ├── page.tsx └── error.tsx ← Only catches errors in /blog/*
Dev vs Production: In development, errors are also dispatched as a __bini_error__ CustomEvent on window. In production, generic "Something went wrong" UI is shown if no error.tsx exists.

not-found.tsx / not-found.jsx

The not-found.tsx file defines a custom 404 page for unmatched routes.

Custom 404 Page

app/not-found.tsx
// src/app/not-found.tsx
export default function NotFound() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h1 className="text-6xl font-bold text-white mb-4">404</h1>
      <p className="text-slate-400 mb-8">Page not found</p>
      <Link to="/" className="px-6 py-3 bg-cyan-500 text-white rounded hover:bg-cyan-600 transition-colors">
        Return Home
      </Link>
    </div>
  )
}

Programmatic 404

You can also trigger the 404 page programmatically:

// src/app/blog/[slug]/page.tsx
export default function BlogPost() {
  const { slug } = useParams()
  const post = getPost(slug)
  
  if (!post) {
    return <NotFound />
  }
  
  return <article>{post.content}</article>
}

Scoped Not Found

app/blog/not-found.tsx
// src/app/blog/not-found.tsx
export default function BlogNotFound() {
  return (
    <div>
      <h1 className="text-2xl font-bold text-white">Post not found</h1>
      <p className="text-slate-400">The blog post you're looking for doesn't exist.</p>
      <Link to="/blog" className="text-cyan-400 hover:underline">
        ← Back to blog
      </Link>
    </div>
  )
}

Nearest Wins Resolution

loading.tsx, not-found.tsx, and error.tsx all use "nearest wins" resolution — a file in a subfolder only affects that subfolder and shadows (without deleting) the same file in any ancestor folder.

How It Works

  • A file in a subfolder only affects routes inside that subfolder
  • It shadows (but doesn't delete) the same file in ancestor folders
  • Routes without a closer match fall through to the nearest ancestor
  • Built-in defaults apply if nothing exists anywhere

Example Structure

src/app/ ├── layout.tsx ├── page.tsx ├── loading.tsx ← Default loading for all routes ├── not-found.tsx ← Default 404 for all routes ├── error.tsx ← Default error for all routes ├── dashboard/ │ ├── layout.tsx │ ├── page.tsx │ ├── loading.tsx ← Only affects /dashboard/* │ ├── error.tsx ← Only affects /dashboard/* │ └── settings/ │ └── page.tsx ← Uses dashboard/loading.tsx and dashboard/error.tsx └── blog/ ├── page.tsx ├── loading.tsx ← Only affects /blog/* └── [slug]/ └── page.tsx ← Uses blog/loading.tsx

Resolution Flow

When a route needs a boundary file (loading, error, or not-found):

  1. Check the route's own folder first
  2. If not found, check each parent folder (going up)
  3. If still not found, use the built-in default

Built-in Defaults

  • Loading: Built-in dark-mode-aware spinner
  • Error: null in dev (Vite overlay takes over), generic "Something went wrong" in production
  • Not Found: Built-in 404 page

File Combinations

Special files can be combined in the same folder to create rich route behavior:

src/app/dashboard/ ├── layout.tsx ← Shared layout for all dashboard pages ├── loading.tsx ← Loading UI for dashboard ├── error.tsx ← Error UI for dashboard ├── page.tsx ← Dashboard home ├── settings/ │ ├── page.tsx ← Settings page (inherits layout, loading, error) │ └── loading.tsx ← Override loading UI just for settings └── profile/ ├── layout.tsx ← Additional nested layout for profile └── page.tsx ← Profile page
RouteFiles Used
/dashboardlayout.tsx + loading.tsx + error.tsx + page.tsx
/dashboard/settingslayout.tsx + loading.tsx (from settings) + error.tsx (from dashboard) + page.tsx
/dashboard/profilelayout.tsx + profile/layout.tsx + loading.tsx + error.tsx + page.tsx

File Priority

When multiple files could apply to a route, they are resolved in this order (from outermost to innermost):

  1. Root layout.tsx
  2. Nested layout.tsx files (from root to leaf)
  3. loading.tsx (closest to the page)
  4. error.tsx (closest to the page)
  5. not-found.tsx (if triggered)
  6. page.tsx or page.mdx

Dynamic Routes

Create dynamic routes using [param] syntax in folder or file names.

Dynamic Segment

app/blog/[slug]/page.tsx
// src/app/blog/[slug]/page.tsx
export default function BlogPost() {
  const { slug } = useParams()
  
  return (
    <article>
      <h1 className="text-3xl font-bold text-white">Post: {slug}</h1>
    </article>
  )
}

Flat File Dynamic Routes

src/app/ ├── blog/ │ └── [slug].tsx → /blog/:slug ├── user/ │ └── [id].tsx → /user/:id └── product/ └── [sku].tsx → /product/:sku

Using Params

app/user/[id]/page.tsx
// src/app/user/[id]/page.tsx
export default function UserProfile() {
  const { id } = useParams()
  const navigate = useNavigate()
  const [user, setUser] = useState(null)
  
  useEffect(() => {
    fetchUser(id).then(setUser)
  }, [id])
  
  if (!user) return <Loading />
  
  return (
    <div>
      <h1 className="text-2xl font-bold text-white">{user.name}</h1>
      <p className="text-slate-400">{user.email}</p>
    </div>
  )
}

Catch-All Routes

Use [...param] syntax to match multiple path segments.

Catch-All Example

app/docs/[...path]/page.tsx
// src/app/docs/[...path]/page.tsx
export default function DocsPage() {
  const { path } = useParams()
  
  return (
    <div>
      <h1 className="text-2xl font-bold text-white">Documentation</h1>
      <p className="text-slate-400">Path: {path}</p>
      <ul>
        <li>Matches /docs/guide</li>
        <li>Matches /docs/guide/setup</li>
        <li>Matches /docs/guide/setup/advanced</li>
      </ul>
    </div>
  )
}

Route Priority

Routes are matched in this order:

  1. Static routes (e.g., /about)
  2. Dynamic routes (e.g., /blog/:slug)
  3. Catch-all routes (e.g., /docs/*)

Routes are sorted by priority and then by path length (shortest first).

API Routes

Write your API files in src/app/api/. Handlers can be either a .fetch(request)-style app or a plain function handler.

Hono App (Recommended)

app/api/hello.ts
// src/app/api/hello.ts
import { Hono } from 'hono'

const app = new Hono()

app.get('/hello', (c) => {
  return c.json({
    message: 'Hello from Bini.js!',
    timestamp: new Date().toISOString(),
    method: c.req.method,
  })
})

export default app

Plain Function Handler

app/api/users.ts
// src/app/api/users.ts
export default function handler(req: Request) {
  return Response.json({
    users: [
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' },
    ],
    method: req.method,
  })
}

Dynamic API Routes

app/api/users/[id].ts
// src/app/api/users/[id].ts
import { Hono } from 'hono'

const app = new Hono()

app.get('/users/:id', (c) => {
  const { id } = c.req.param()
  return c.json({ id, name: `User ${id}` })
})

app.put('/users/:id', async (c) => {
  const { id } = c.req.param()
  const body = await c.req.json()
  return c.json({ id, updated: body })
})

export default app

API Route Structure

src/app/api/ ├── hello.ts → /api/hello ├── users/ │ ├── index.ts → /api/users │ └── [id].ts → /api/users/:id └── posts/ ├── index.ts → /api/posts └── [...slug].ts → /api/posts/*
Note: Write routes without the /api prefix — Bini.js strips it before your handler sees the request. Requires npm install hono if you choose the Hono style.

Complete Example

Here's a comprehensive file structure showing all special files:

src/app/ ├── layout.tsx ← Root layout (required) ├── page.tsx → / ├── loading.tsx ← Global loading UI ├── error.tsx ← Global error UI ├── not-found.tsx ← Global 404 page ├── about.mdx → /about (MDX page) ├── contact.md → /contact (Markdown page) ├── blog/ │ ├── layout.tsx ← Blog layout │ ├── page.tsx → /blog │ ├── loading.tsx ← Blog loading UI │ ├── error.tsx ← Blog error UI │ ├── [slug]/ │ │ └── page.tsx → /blog/:slug │ └── _components/ ← Private folder (not routable) │ └── PostCard.tsx ├── dashboard/ │ ├── layout.tsx ← Dashboard layout │ ├── page.tsx → /dashboard │ ├── loading.tsx ← Dashboard loading UI │ ├── error.tsx ← Dashboard error UI │ ├── settings/ │ │ └── page.tsx → /dashboard/settings │ └── profile/ │ ├── layout.tsx ← Nested profile layout │ └── page.tsx → /dashboard/profile ├── api/ ← API routes │ ├── hello.ts → /api/hello │ └── users/ │ ├── index.ts → /api/users │ └── [id].ts → /api/users/:id └── docs/ └── [...path]/ └── page.tsx → /docs/* (catch-all)

Route Mapping

File PathURLType
app/page.tsx/Static
app/about.mdx/aboutMDX Page
app/blog/page.tsx/blogStatic
app/blog/[slug]/page.tsx/blog/:slugDynamic
app/dashboard/page.tsx/dashboardStatic
app/dashboard/settings/page.tsx/dashboard/settingsStatic
app/dashboard/profile/page.tsx/dashboard/profileStatic
app/docs/[...path]/page.tsx/docs/*Catch-all
app/api/hello.ts/api/helloAPI
app/api/users/[id].ts/api/users/:idAPI Dynamic