Router
@viewfly/router adds browser routing: map URLs to page components so URL changes drive view changes.
How to read this chapter
If this is your first pass, start with Minimal setup and Defining routes to get routing running. Then dive where you need: declarative navigation and programmatic navigation for jumps; Dynamic params for param-driven effects; Redirects & guards for access control. For bugs, jump to Troubleshooting at the end.
Install
npm install @viewfly/routerMinimal setup
To enable routing:
- Construct
RouterModuleand register it withapp.use(...)before the app renders. RouterModuleowns the route table—the mapping from addresses to page components.- Place
RouterOutletwhere matched views should render. - Use
Linkfor in-app navigation and active styling.
import { createApp } from '@viewfly/platform-browser'
import { RouterModule } from '@viewfly/router'
import { App } from './app'
import { Home } from './home'
import { List } from './list'
createApp(<App />)
.use(
new RouterModule({
routes: [
{ path: '', component: Home },
{ path: 'list', component: List },
],
}),
)
.mount(document.querySelector('#main')!)import { Link, RouterOutlet } from '@viewfly/router'
export function App() {
return () => (
<div>
<nav>
<Link active="active" exact to="/">
Home
</Link>
<Link active="active" to="/list">
List
</Link>
</nav>
<RouterOutlet>No route matched</RouterOutlet>
</div>
)
}import { inject } from '@viewfly/core'
import { Router } from '@viewfly/router'
export function Home() {
const router = inject(Router)
return () => (
<div>
<p>Home</p>
<button type="button" onClick={() => router.navigateTo('/list')}>
Go to list
</button>
</div>
)
}export function List() {
return () => <div>List page</div>
}Roles in this example:
RouterModule: registers the route table;Link: declarative navigation;RouterOutlet: renders the matched page component.
Defining routes
Common path shapes:
- Static segment:
'list' - Dynamic param:
'user/:id' - Optional param:
'user/:id?' - Catch-all:
'*'
new RouterModule({
routes: [
{ path: '', component: HomePage },
{ path: 'list', component: ListPage },
{ path: 'user/:id', component: UserPage },
{ path: '*', component: NotFoundPage },
],
})Put * last as the final fallback.
Matching uses a prefix consumption model: when a level matches and consumes its segment, the remainder flows to the child RouterOutlet / child routes.
Declarative navigation: Link
Common props:
to: destination path;active: class name appended while matched;exact: require exact match (often for home);queryParams: query object;hash: hash fragment;tag: rendered tag (defaulta).
<nav>
<Link active="active" exact to="/">
Home
</Link>
<Link active="active" to="/list">
List
</Link>
<Link to="/list" queryParams={{ page: 2 }} hash="top">
List page 2
</Link>
</nav>Programmatic navigation: inject(Router)
For jumps from logic (buttons, after submit), inject Router.
import { inject } from '@viewfly/core'
import { Router } from '@viewfly/router'
function Toolbar() {
const router = inject(Router)
return () => (
<div>
<button type="button" onClick={() => router.navigateTo('/list')}>
Go to list
</button>
<button type="button" onClick={() => router.replaceTo('/list')}>
Replace with list
</button>
<button
type="button"
onClick={() => router.navigateTo('/user/42', { tab: 'profile' }, 'info')}
>
Open user 42
</button>
</div>
)
}Common methods:
navigateTo(path, queryParams?, hash?)— push history entry;replaceTo(path, queryParams?, hash?)— replace current entry;back()/forward()/go(offset)— history navigation.
Dynamic params and reads
Two families with different meaning:
- Path params (
params): dynamic URL segments—resource identity, e.g.42in/user/42; - Query params (
query):?key=value—filters, paging, sort, e.g.?page=2&keyword=phone.
Declare path params in the route, read them in the page.
new RouterModule({
routes: [{ path: 'user/:id', component: UserPage }],
})import { useParams } from '@viewfly/router'
function UserPage() {
const params = useParams<{ id: string }>()
return () => <div>User id: {params.id}</div>
}Use useQueryParams() for the query string:
import { useQueryParams } from '@viewfly/router'
function ListPage() {
const query = useQueryParams<{ page?: string; keyword?: string }>()
return () => <div>page={query.page || '1'}</div>
}When the URL changes, useParams() / useQueryParams() update accordingly.
To run side effects on param changes (refetch, etc.):
import { watch } from '@viewfly/core'
import { useParams, useQueryParams } from '@viewfly/router'
function UserPage() {
const params = useParams<{ id: string }>()
const query = useQueryParams<{ tab?: string }>()
watch(
() => params.id,
(nextId, prevId) => {
console.log('id changed:', prevId, '->', nextId)
// e.g. refetch user by nextId
},
)
watch(
() => query.tab,
(nextTab, prevTab) => {
console.log('tab changed:', prevTab, '->', nextTab)
// e.g. swap panel data
},
)
return () => <div>User page</div>
}For optional path params (:id?), when missing the current implementation yields an empty string '', not undefined. Handle '' explicitly in business logic.
Writing vs reading query params
Write:
router.navigateTo('/list', { page: '2', tag: ['a', 'b'] }, 'top')Resulting URL shape:
/list?page=2&tag=a&tag=b#topRead:
const query = useQueryParams<{ page?: string; tag?: string | string[] }>()queryParams accepts array values, so one key may repeat (tag=a&tag=b). The read field may be string or string[]—type it explicitly.
Nested routes
Parent layouts render RouterOutlet for child matches:
function SettingsLayout() {
return () => (
<section>
<h2>Settings</h2>
<RouterOutlet>No settings child matched</RouterOutlet>
</section>
)
}Keeps outer chrome stable while inner pages swap.
Child route config (minimal)
new RouterModule({
routes: [
{
path: 'settings',
component: SettingsLayout,
children: [
{ path: '', component: ProfilePage },
{ path: 'security', component: SecurityPage },
],
},
],
})/settings → ProfilePage; /settings/security → SecurityPage.
Named outlets
When one screen needs parallel routed regions, use named RouterOutlet values with namedComponents.
function DashboardLayout() {
return () => (
<div class="layout">
<aside>
<RouterOutlet name="sidebar">Default sidebar</RouterOutlet>
</aside>
<main>
<RouterOutlet>Default main</RouterOutlet>
</main>
</div>
)
}new RouterModule({
routes: [
{
path: 'dashboard',
component: DashboardLayout,
namedComponents: [{ name: 'sidebar', component: DashboardSidebar }],
children: [{ path: '', component: DashboardHome }],
},
],
})One match can feed both main and sidebar components.
Async route components
Routes support asyncComponent for lazy pages:
new RouterModule({
routes: [
{
path: 'report',
asyncComponent: async () => {
const mod = await import('./pages/report.page')
return mod.ReportPage
},
},
],
})Child routes can be async too:
{
path: 'settings',
component: SettingsLayout,
children: async () => {
const mod = await import('./settings.routes')
return mod.settingsRoutes
},
}Redirects and navigation guards
redirectTo
redirectTo jumps immediately after the route matches. Typical uses:
- default landing page;
- legacy path aliases;
- branching one entry by params.
Static redirect:
new RouterModule({
routes: [
{ path: '', redirectTo: 'dashboard' },
{ path: 'dashboard', component: DashboardPage },
],
})redirectTo may be a function that returns the target from context:
{
path: 'user/:id',
redirectTo({ params }) {
return `/profile/${params.id}`
},
}The argument exposes to, from, params, router for dynamic decisions.
canActivate
canActivate runs before entering a route—answers “may we open this page now?”.
new RouterModule({
routes: [
{
path: 'admin',
component: AdminPage,
canActivate() {
return checkLogin()
},
},
],
})true continues; false aborts navigation and rolls the address bar back to the last confirmed URL.
Context fields:
to: target (pathname,queryParams,hash);from: previous confirmed location (nullon first entry);params: matched path params (e.g.:id);router: router instance for further navigation or state reads.
{
path: 'user/:id',
component: UserPage,
canActivate({ to, from, params }) {
console.log('from:', from?.pathname ?? '(first enter)')
console.log('to:', to.pathname)
console.log('user id:', params.id)
return true
},
}canActivate may be async:
{
path: 'admin',
component: AdminPage,
async canActivate() {
const ok = await checkPermission()
return ok
},
}Typical uses: login checks, permission gates, prerequisite state.
RouterModule options
RouterConfig highlights:
baseUrl: app base path;routes: route table;hooks: navigation lifecycle hooks.
hooks
hooks.beforeEach can block navigation by calling next(); hooks.afterEach runs after navigation completes:
new RouterModule({
hooks: {
beforeEach(from, to, next) {
if (to.pathname.startsWith('/admin') && !checkLogin()) {
// omit next() to block
return
}
next()
},
afterEach(to) {
console.log('navigated to', to.pathname)
},
},
routes: [...],
})Hard rule for beforeEach: the allow branch must call next(). Forgetting leaves navigation pending—URL or UI appears stuck.
Tips:
- Spell out allow vs deny paths clearly;
- async work must still end in “call
next()or not”.
Full field list and types: see @viewfly/router public typings.
Troubleshooting
1) URL changes but nothing renders
- Cause: no
RouterOutletin the layout. - Fix: add
RouterOutletwhere routed content should appear.
2) Link does not match expectations
- Cause:
to,path, orbaseUrldisagree, or home lacksexactfor active state. - Fix: align path strings and
baseUrl; useexacton home when needed.
3) Params read empty
- Cause: route path is not parametric (
user/:id), or key names differ. - Fix: match route definition and reader keys.
4) Back button surprises
- Cause:
replaceTo(replace history) vsnavigateTo(push). - Fix: choose based on whether history should retain the current entry.
5) Guarded page flashes then bounces
- Cause:
canActivatereturnedfalse. - Fix: review guard logic and auth/permission state.