Skip to content
contemberPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

Buzola

Type-safe SPA router for React built on the browser's Navigation API.

Buzola uses a page-centric approach -- each page declares itself via createPage() with typed params, loaders, and error handling. Combined with a Vite or Bun plugin for file-based routing and automatic type generation, you get full end-to-end type safety with zero manual route configuration.

Features

  • Type-safe navigation -- page IDs and params are checked at compile time
  • File-based routing -- Vite or Bun plugin scans your routes directory and generates the route tree
  • Page-centric design -- pages declare their own params, loaders, and components via createPage()
  • Async data loading -- loaders with Suspense, stale-while-revalidate caching, and invalidation
  • Type-safe redirects -- redirect by page ID, not raw URLs
  • Nested layouts -- file hierarchy determines component nesting
  • Standard Schema validation -- validate params with Zod 4, Valibot, or ArkType
  • View Transitions -- built-in support for the View Transitions API
  • Persistent params -- parameters that carry across navigations
  • Navigation blocking -- prevent navigation when there are unsaved changes
  • Prefetching -- preload page data on hover intent or render
  • Hot page updates (Vite) -- a changed page swaps in place; the app keeps its state instead of reloading

Installation

# With Vite
npm install @buzola/router @buzola/vite-plugin

# With Bun
bun add @buzola/router @buzola/bun-plugin

Quick Start

1. Wire up the bundler plugin

Vite:

// vite.config.ts
import { buzolaPlugin } from '@buzola/vite-plugin'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default defineConfig({
	plugins: [
		buzolaPlugin(),
		react(),
	],
})

Bun:

// bunfig.toml preload: preload = ["./buzola.preload.ts"]
// buzola.preload.ts
import { buzolaPlugin } from '@buzola/bun-plugin'

Bun.plugin(buzolaPlugin())

2. Create your routes

src/routes/
  _layout.tsx      # Root layout
  _404.tsx         # Not found page
  home.tsx         # Home page
  about.tsx        # About page
  users/
    _layout.tsx    # Users section layout
    index.tsx      # User list
    detail.tsx     # User detail

3. Define pages

A simple page:

// src/routes/home.tsx
import { createPage } from '@buzola/router'

export default createPage()
	.route('/')
	.render(() => (
		<div>
			<h1>Home</h1>
			<p>Welcome to Buzola!</p>
		</div>
	))

A page with params and loaders:

// src/routes/users/detail.tsx
import { createPage, Link } from '@buzola/router'

export default createPage()
	.params({ userId: 'string', tab: '?string' })
	.loader(async ({ params }) => {
		const user = await fetchUser(params.userId)
		return { user }
	})
	.loader(async ({ params }) => {
		const posts = await fetchPosts(params.userId)
		return { posts }
	})
	.route('/users/:userId')
	.render(({ params, data, invalidate, isLoading }) => (
		<div>
			<h2>{data.user.name}</h2>
			<p>Posts: {data.posts.length}</p>
			<button onClick={invalidate}>Reload data</button>
			<Link to="users/detail" params={{ userId: params.userId, tab: 'posts' }}>
				Posts tab
			</Link>
		</div>
	))

4. Create a layout

// src/routes/_layout.tsx
import { Link, Outlet } from '@buzola/router'

export default function RootLayout() {
	return (
		<div>
			<nav>
				<Link to="home">Home</Link>
				<Link to="about">About</Link>
				<Link to="users">Users</Link>
			</nav>
			<main>
				<Outlet />
			</main>
		</div>
	)
}

5. Mount the router

// src/app.tsx
import { BuzolaProvider } from '@buzola/router'
import { pageRegistry, routes } from './buzola.gen'

export function App() {
	return <BuzolaProvider routes={routes} pageRegistry={pageRegistry} />
}

The buzola.gen.ts file is automatically generated by the Vite or Bun plugin (both wrap @buzola/codegen) and contains the route tree, page registry, and TypeScript type augmentations. You can also generate it manually with the buzola CLI from @buzola/codegen. In a Vite project, pass --hot vite: the Vite plugin adds hot update code, and the CLI then writes the same file.

API

createPage()

Fluent builder for defining pages:

createPage()
  .params({ id: 'string', q: '?string' })         // Define typed parameters
  .loader(async ({ params, redirect }) => { ... })  // Add async data loader (chainable)
  .route('/custom/:id')                              // Override URL pattern
  .catch(({ error, retry }) => <ErrorUI />)          // Error boundary
  .render(({ params, data, invalidate, isLoading }) => <Page />)

Parameter types: 'string', 'number', 'uuid', optional variants ('?string'), arrays ('string[]'), or any Standard Schema object.

Components

Component Description
<BuzolaProvider> Root provider. Props: routes, pageRegistry, persistentParams, shouldIntercept, middleware
<Link> Type-safe navigation link. Props: to, params, prefetch, viewTransition, asChild, activeExact
<Outlet> Renders matched child route. Props: fallback, errorFallback, notFound
<ErrorBoundary> Catches component errors in the route tree

Hooks

Hook Returns
useNavigate() Type-safe navigate function
useParams() Current route parameters
useSearchParams(schema?) Search parameters with optional validation
useRoute() Route info: matches, pathname, isPending, pendingLocation
useRouterState() Full router state
useRouter() Router instance
useBlocker(when) Navigation blocker: { state, proceed, reset }
useInvalidate() Function to invalidate all loader caches
useNavigationState() Custom state from Navigation API

Leaving the app

A router built on the Navigation API sees every navigation on its origin, and a catch-all _404 route matches all of them, including URLs the server owns, such as an auth handoff or a server-rendered page. Left alone, the router intercepts those and renders its own not-found page without a request ever reaching the server.

The platform offers no way to mark a navigation un-interceptable: location.assign, location.href, a link click and a form submit all raise the same event. So say it explicitly:

const router = useRouter()

// Hand this one to the browser: a real cross-document navigation, no interception.
router.leaveApp('/api/auth/github/start')

The release is recorded before the navigation is requested and is keyed to the resolved URL, so it cannot race the event or release a different navigation. Unlike navigate(), the URL is passed to the browser as given: no base path is prepended, because the target is outside the app.

Loading a navigation as a new document

By default the router handles every in-app navigation within the current document. shouldIntercept decides that per navigation: return false and the browser loads the destination as a new document instead. An app that shows a "new version available" notice can use it to load the new build on the next in-app navigation, and a page whose lazy chunk from an old build is gone can recover the same way.

let outdated = false
onNewVersionDeployed(() => {
	outdated = true
})

<BuzolaProvider routes={routes} pageRegistry={pageRegistry} shouldIntercept={() => !outdated} />

// or, when you construct the router yourself:
new Router({ routes, adapter, shouldIntercept: () => !outdated })

The function receives { destination, from, navigationType, userInitiated }. It runs synchronously inside the navigate event, so it cannot await anything. It is asked only about navigations the router would otherwise intercept, not about unmatched URLs, leaveApp() or navigations the browser does not let it intercept. A declined navigation proceeds as follows:

Navigation When declined
push, replace Not intercepted. The browser performs a regular document navigation: the URL changes when the new document arrives, and the history entry is pushed or replaced as requested.
traverse (Back, Forward) Browsers complete a traversal between entries of the same document without loading anything, so not intercepting would leave the old page under the new URL. The router intercepts it and reloads the destination entry. The URL changes first.
reload Never asked. A reload always goes to the browser.
fragment (#section on the same page) Never asked. Browsers keep fragment navigations in the current document whether they are intercepted or not.

Blockers are async, and a navigation the router does not intercept cannot wait for them. So while a blocker is registered, a declined push or replace is handled like a traversal: the router intercepts it, asks the blockers and, if they allow it, reloads the destination entry. The URL changes before the new document arrives, and a declined form submission is not resubmitted. If a blocker refuses, the URL stays at the destination while the previous page stays on screen, as with any blocked navigation.

Plugin Options

Both @buzola/vite-plugin and @buzola/bun-plugin accept the same options:

buzolaPlugin({
	routesDir: 'src/routes', // Routes directory (default: "src/routes")
	output: 'src/buzola.gen.ts', // Generated types file (default: "src/buzola.gen.ts")
	persistentParams: ['lang'], // Params that persist across navigations
	name: 'admin', // Virtual module suffix for multi-entrypoint setups
})

Options can also be defined in buzola.config.{ts,js,mjs} at the project root; plugin options take precedence.

File Conventions

Pattern Description
_layout.tsx Layout component (wraps children via <Outlet>)
_404.tsx Not found handler for unmatched routes
index.tsx Index route (renders at parent's exact path)
[param].tsx Dynamic segment
[...slug].tsx Catch-all segment
(group)/ Pathless group (organizes files without affecting URL)

Files use createPage() to define their params, loaders, and component. The .route() method can override the URL pattern without affecting file-tree nesting.

Key Concepts

File tree vs. route tree -- File hierarchy determines layout nesting (what components wrap what), while route patterns from .route() only affect URL matching. These are independent concerns.

Type generation -- The bundler plugin (or buzola CLI) generates buzola.gen.ts which augments BuzolaPageMap and BuzolaPersistentParams interfaces, giving you compile-time type safety for all navigation.

Loaders -- Multiple loaders can be chained on a single page. Their results are merged into the data prop. Loaders support redirects via redirect(pageId, params) with full type safety.

Back and forward -- The router leaves a traversal to the browser and renders its destination as soon as the browser switches to that entry, just before it restores the scroll position. It intercepts a traversal only while a blocker is registered or a view transition runs. The browser can only restore a position the page is tall enough for, so a page whose loader fetches again on Back comes back at the top, and Safari keeps its swipe snapshot up for 3 seconds. Create the router with loaderCacheSize so such a page renders its cached data first.

Requirements

  • React 18 or 19
  • A browser with Navigation API support (or a polyfill)
  • Vite or Bun (for the file-based routing plugin)

License

MIT

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages