| 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 |
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.
| Doc | Topic |
|---|---|
| docs/MIGRATION-RSC.md | Client → RSC opt-in migration |
| docs/RUNTIME-SUPPORT.md | D18.b runtime matrix (Node / Edge / Workers) |
- Dual-mode (0.4) -
new Router()SPA, ornew 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
useSyncExternalStorefor stable rendering - Edge-first (D18.b) - Web Platform APIs on the RSC path; ALS
getRequest()
pnpm add @revealui/routerimport { 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)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>
)
}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>
`,
}))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 hatchMethods:
register(route: Route)- Register a single routeregisterRoutes(routes: Route[])- Register multiple routesmatch(url: string)- Match a URL to a routeresolve(url: string)- Match and load route datanavigate(url: string, options?)- Client-side navigationback()/forward()- Browser history navigationsubscribe(listener)- Subscribe to route changesinitClient()- 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 fromoptions.rsc)seedCurrentMatch(match)- Seed the current match (and loader data) without re-running middleware/loaders — used by SSRhydrate()souseData()works on first client paint. Clientnavigate()still does not run loaders (0.3.x SPA contract).
Provides router instance to your app:
<RouterProvider router={router}>
<App />
</RouterProvider>Renders the matched route component:
<Routes />Client-side navigation link:
<Link to="/about" replace={false}>
About Us
</Link>Declarative navigation:
<Navigate to="/login" replace />Access the router instance:
const router = useRouter()
router.navigate('/about')Get route parameters:
const { id } = useParams<{ id: string }>()Get route data from loader:
const { post } = useData<{ post: Post }>()Get current route match:
const match = useMatch()
console.log(match?.route.path, match?.params)Get navigation function:
const navigate = useNavigate()
navigate('/about', { replace: true })Get current location (pathname, search, hash):
const { pathname, search, hash } = useLocation()Get parsed query string parameters:
const params = useSearchParams()
params.get('page') // '2'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)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>
}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>
)
}Enable streaming SSR for better performance:
createSSRHandler(routes, {
streaming: true,
onError: (error, context) => {
console.error('SSR Error:', error)
},
})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 insiderenderRequest/runWithRequestx-rsc-actionPOST:useActionmiddleware thenloadServerAction+returnValueon stream ctx
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).
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 (
AbortSignaltoken). navigate(to, { skipRscFetch: true })when a server action already applied a payload.useNavigationError()surfaces loader failures.
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 headerClient helper: getRouterRedirect(response) after action fetch; navigate when set.
Quick development server:
import { createDevServer } from '@revealui/router/server-ssr'
await createDevServer(routes, {
port: 3000,
template: (html, data) => `...`,
})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] }
},
})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) }
},
}| Feature | @revealui/router | TanStack Router | React Router |
|---|---|---|---|
| Bundle Size | ~5KB | ~50KB | ~20KB |
| SSR Built-in | ✅ | ||
| Type Safety | ✅ | ✅ | |
| Data Loading | ✅ | ✅ | ✅ |
| Learning Curve | Low | Medium | Low |
- 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
- 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
MIT - RevealUI
See CONTRIBUTING.md