Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

title @revealui/router
description Lightweight file-based router for React apps with SSR, data loaders, middleware, and nested layouts. No framework required - works with Vite, Hono, or any React setup.
visibility public
status verified
audience user

@revealui/router

Lightweight dual-mode router for React apps: client SPA (default) plus opt-in RSC mode. SSR, data loaders, middleware, nested layouts. Works with Vite, Hono, or any React setup.

Docs (0.4 dual-mode)

Doc Topic
docs/MIGRATION-RSC.md Client → RSC opt-in migration
docs/RUNTIME-SUPPORT.md D18.b runtime matrix (Node / Edge / Workers)

Features

  • Dual-mode (0.4) - new Router() SPA, or new Router({ rsc: {} }) for RSC
  • File-based routing - named params (:id), wildcards (*path), optional segments
  • Nested routes - composable layouts that stack automatically
  • Data loaders - async per-route data loading with typed access via useData()
  • Middleware - global + per-route, supports blocking and redirects
  • SSR + streaming - Hono integration with renderToReadableStream (SPA) + renderRequest (RSC)
  • Client-side navigation - History API, link interception, back/forward; RSC soft-nav flight fetch
  • Type-safe - full TypeScript support, generic route data types
  • React 18/19 - uses useSyncExternalStore for stable rendering
  • Edge-first (D18.b) - Web Platform APIs on the RSC path; ALS getRequest()

Installation

pnpm add @revealui/router

Quick Start

1. Define Your Routes

import { Router, type Route } from '@revealui/router'
import Home from './pages/Home'
import About from './pages/About'
import Post from './pages/Post'

const routes: Route[] = [
  {
    path: '/',
    component: Home,
    meta: { title: 'Home' },
  },
  {
    path: '/about',
    component: About,
    meta: { title: 'About Us' },
  },
  {
    path: '/posts/:id',
    component: Post,
    loader: async ({ id }) => {
      const post = await fetch(`/api/posts/${id}`).then(r => r.json())
      return { post }
    },
  },
]

const router = new Router()
router.registerRoutes(routes)

2. Client-Side Usage

import { RouterProvider, Routes, Link } from '@revealui/router'

function App() {
  return (
    <RouterProvider router={router}>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
      </nav>
      <Routes />
    </RouterProvider>
  )
}

3. Server-Side Rendering (SSR)

import { Hono } from 'hono'
import { createSSRHandler } from '@revealui/router/server-ssr'
import routes from './routes'

const app = new Hono()

app.get('*', createSSRHandler(routes, {
  template: (html, data) => `
    <!DOCTYPE html>
    <html>
      <head>
        <title>${data?.title || 'My App'}</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script id="__REVEALUI_DATA__" type="application/json">
          ${JSON.stringify(data)}
        </script>
        <script type="module" src="/client.js"></script>
      </body>
    </html>
  `,
}))

API Reference

Router

const router = new Router(options)
// Dual-mode (0.4.0-rc): omit rsc → client (default, SPA). Pass rsc: {} → RSC mode.
// const rscRouter = new Router({ rsc: { endpoint: '/.rsc' } }) // endpoint optional CDN escape hatch

Methods:

  • register(route: Route) - Register a single route
  • registerRoutes(routes: Route[]) - Register multiple routes
  • match(url: string) - Match a URL to a route
  • resolve(url: string) - Match and load route data
  • navigate(url: string, options?) - Client-side navigation
  • back() / forward() - Browser history navigation
  • subscribe(listener) - Subscribe to route changes
  • initClient() - Initialize client-side routing (popstate + link-click listeners)
  • dispose() - Remove client-side event listeners (call before unmounting or on HMR teardown)
  • use(...middleware) - Add global middleware (runs before all route middleware)
  • useAction(...middleware) - Middleware for server actions (RSC mode; ADR D2.d)
  • mode - 'client' | 'rsc' (derived from options.rsc)
  • seedCurrentMatch(match) - Seed the current match (and loader data) without re-running middleware/loaders — used by SSR hydrate() so useData() works on first client paint. Client navigate() still does not run loaders (0.3.x SPA contract).

Components

<RouterProvider>

Provides router instance to your app:

<RouterProvider router={router}>
  <App />
</RouterProvider>

<Routes>

Renders the matched route component:

<Routes />

<Link>

Client-side navigation link:

<Link to="/about" replace={false}>
  About Us
</Link>

<Navigate>

Declarative navigation:

<Navigate to="/login" replace />

Hooks

useRouter()

Access the router instance:

const router = useRouter()
router.navigate('/about')

useParams()

Get route parameters:

const { id } = useParams<{ id: string }>()

useData()

Get route data from loader:

const { post } = useData<{ post: Post }>()

useMatch()

Get current route match:

const match = useMatch()
console.log(match?.route.path, match?.params)

useNavigate()

Get navigation function:

const navigate = useNavigate()
navigate('/about', { replace: true })

useLocation()

Get current location (pathname, search, hash):

const { pathname, search, hash } = useLocation()

useSearchParams()

Get parsed query string parameters:

const params = useSearchParams()
params.get('page') // '2'

Route Patterns

Uses a hand-rolled path matcher (no path-to-regexp). Supported syntax:

'/posts/:id'           // Named parameter
'/posts/*path'         // Wildcard with name
'/posts/*'             // Anonymous wildcard
'{/optional}'          // Optional segment (curly-brace syntax)

Data Loading

Routes can have loaders for data fetching:

{
  path: '/user/:id',
  component: UserProfile,
  loader: async ({ id }) => {
    const user = await fetchUser(id)
    return { user }
  },
}

Access data in your component:

function UserProfile() {
  const { user } = useData<{ user: User }>()
  return <div>{user.name}</div>
}

Layouts

Wrap routes with layouts:

{
  path: '/dashboard',
  component: Dashboard,
  layout: DashboardLayout,
}

Layout component:

function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>{children}</main>
    </div>
  )
}

SSR with Streaming

Enable streaming SSR for better performance:

createSSRHandler(routes, {
  streaming: true,
  onError: (error, context) => {
    console.error('SSR Error:', error)
  },
})

RSC mode (0.4.0-rc+)

Import map (dual-mode packaging):

Subpath Use for
@revealui/router Client SPA components/hooks (RouterProvider, Link, …)
@revealui/router/core Router class only — safe in RSC + browser shared route tables
@revealui/router/server RSC handler (renderRequest, redirect, getRequest, …) — no react-dom/server
@revealui/router/server-ssr SPA SSR (createSSRHandler, hydrate, createDevServer)
import { Router } from '@revealui/router/core'
import { renderRequest } from '@revealui/router/server'

const router = new Router({ rsc: {} }) // or { rsc: { endpoint: '/.rsc' } }
router.registerRoutes(routes)

export default {
  async fetch(request: Request) {
    return renderRequest(request, {
      router,
      // Wire your bundler/RSDW pipeline here (ADR D11 — router stays plugin-agnostic)
      createRscStream: async (request, ctx) => {
        // return a text/x-component flight ReadableStream for ctx.pathname
        return myRscFlightStream(request, ctx)
      },
      loadBootstrapScriptContent: async () => myClientBootstrap(),
    })
  },
}
  • Accept: text/x-component → flight body (Vary: accept)
  • Otherwise → HTML with chunked base64 self.__RSC_PAYLOAD__=... + bootstrap
  • Endpoint escape hatch forces RSC when CDNs mishandle Vary
  • redirect(path) / notFound() throw-sentinels → 307/308 or 404 (from @revealui/router/server)
  • getRequest() via ALS inside renderRequest / runWithRequest
  • x-rsc-action POST: useAction middleware then loadServerAction + returnValue on stream ctx

Client-mode compat (T9 / D16)

Default new Router() (no rsc option) is the 0.3.x SPA contract used by apps/docs and apps/marketing. Regression suite: src/__tests__/client-mode-compat.test.tsx (export surface, navigate without loaders, 0.3.10 scroll behavior, hooks composition).

RSC client navigation (2.2.3 / D3)

In 'rsc' mode, soft navigations fetch flight payloads via a pluggable loader:

import { Router, RouterProvider, useRscPayload, useNavigationStatus } from '@revealui/router'
import { RSC_ACCEPT } from '@revealui/router/core'

const router = new Router({ rsc: {} })
router.setRscPayloadLoader(async (url, signal) =>
  createFromFetch(fetch(url, { headers: { accept: RSC_ACCEPT }, signal })),
)
router.applyRscPayload(initialPayload) // SSR hydrate seed
router.initClient() // popstate + <a> intercept → navigate → fetch

function App() {
  const payload = useRscPayload<{ root: React.ReactNode }>()
  const status = useNavigationStatus()
  return (
    <RouterProvider router={router}>
      {status === 'loading' ? <Progress /> : null}
      {payload?.root}
    </RouterProvider>
  )
}
  • New navigations abort the previous fetch (AbortSignal token).
  • navigate(to, { skipRscFetch: true }) when a server action already applied a payload.
  • useNavigationError() surfaces loader failures.

Server actions + progressive forms (2.2.4 / D2)

import { renderRequest, redirect } from '@revealui/router/server'

// JS path: POST + x-rsc-action → loadServerAction + returnValue on flight
// Form path (no JS): POST multipart/urlencoded → decodeFormAction + formState HTML

await renderRequest(request, {
  router,
  loadServerAction: (id) => loadServerAction(id),
  decodeActionArgs: (req) => decodeReply(...),
  decodeFormAction: (formData) => decodeAction(formData),
  decodeFormState: (result, formData) => decodeFormState(result, formData),
  createRscStream: async (req, ctx) => { /* ctx.formState | ctx.returnValue */ },
})

// From any action/loader:
redirect('/done') // HTML 307/308 or RSC X-Router-Redirect header

Client helper: getRouterRedirect(response) after action fetch; navigate when set.

Dev Server

Quick development server:

import { createDevServer } from '@revealui/router/server-ssr'

await createDevServer(routes, {
  port: 3000,
  template: (html, data) => `...`,
})

Integration with RevealUI

Works seamlessly with other RevealUI packages:

import { Router } from '@revealui/router'
import { getRevealUI } from '@revealui/core'

const router = new Router()

router.register({
  path: '/cms/:slug',
  component: CMSPage,
  loader: async ({ slug }) => {
    const revealui = await getRevealUI()
    const page = await revealui.find({
      collection: 'pages',
      where: { slug: { equals: slug } },
    })
    return { page: page.docs[0] }
  },
})

TypeScript

Full type safety:

import type { Route, RouteParams } from '@revealui/router'

interface PostParams extends RouteParams {
  id: string
}

const route: Route = {
  path: '/posts/:id',
  component: Post,
  loader: async (params: PostParams) => {
    // params.id is typed as string
    return { post: await fetchPost(params.id) }
  },
}

Comparison with Other Routers

Feature @revealui/router TanStack Router React Router
Bundle Size ~5KB ~50KB ~20KB
SSR Built-in ⚠️ Requires Start ⚠️ Complex setup
Type Safety ⚠️ Limited
Data Loading
Learning Curve Low Medium Low

When to Use This

  • You need a lightweight, type-safe router with built-in SSR for a Hono + React app
  • You want file-based routing conventions with data loaders and layouts
  • You need a ~5KB router that avoids the bundle size of React Router or TanStack Router
  • Not for Next.js apps - Next.js has its own App Router
  • Not for API-only services - use Hono's native routing directly

Design Principles

  • Orthogonal: Routing, data loading, and SSR are cleanly separated - loaders run independently of components
  • Hermetic: SSR hydration uses a sealed data channel (__REVEALUI_DATA__) with no implicit global state
  • Sovereign: No framework lock-in - works with any Hono server and standard React

License

MIT - RevealUI

Contributing

See CONTRIBUTING.md