Router
@praxisjs/router — signal-based client-side router. Configure routes with @Router, define lazy routes with @Lazy, annotate pages with @Route, inject reactive router state with @Router(), @Params, @Query, @Location, and navigate by name with push({ name }) or replace({ name }).
Router
Signal-based client-side routing. Configure routes on your root component with @Router, annotate page components with @Route, and access reactive router state with injection decorators.
npm install @praxisjs/routerpnpm add @praxisjs/routeryarn add @praxisjs/routerbun add @praxisjs/routerSetup with @Router
Apply @Router to the root component to configure the router. Pass an array of page classes (decorated with @Route) or explicit route definition objects:
import { Router, RouterInstance, Route, Lazy } from '@praxisjs/router'
import { RouterView } from '@praxisjs/router'
import { Home } from './pages/Home' // has @Route('/')
import { About } from './pages/About' // has @Route('/about')
@Router([
Home, // path read from @Route
About, // path read from @Route
{ path: '/users/:id', component: Lazy(() => import('./pages/UserDetail')) },
{
path: '/admin',
component: Lazy(() => import('./pages/AdminLayout')),
children: [
{ path: 'users', component: Lazy(() => import('./pages/AdminUsers')) },
{ path: 'settings', component: Lazy(() => import('./pages/AdminSettings')) },
],
},
{ path: '**', component: Lazy(() => import('./pages/NotFound')) },
])
@Component()
class App extends StatefulComponent {
render() {
return (
<div>
<NavBar />
<RouterView /> {/* renders the matched route component */}
</div>
)
}
}@Route(path | options)
Co-locates the route path with the page component. Pass the decorated class directly to @Router — the path is read automatically.
// pages/About.tsx — simple string form
@Route('/about')
@Component()
export default class About extends StatefulComponent {
render() { return <main>About</main> }
}// pages/UserDetail.tsx — options object form (adds name and meta)
@Route({ path: '/users/:id', name: 'user', meta: { requiresAuth: true } })
@Component()
export default class UserDetail extends StatefulComponent {
render() { return <main>User detail</main> }
}// app.tsx
@Router([About, UserDetail]) // path/name/meta read from @Route
@Component()
class App extends StatefulComponent { /* ... */ }You can still use the object form directly in @Router when you need children, beforeEnter, or other options:
@Router([
{ path: '/about', name: 'about', component: About, beforeEnter: authGuard },
])RouteOptions
| Field | Type | Description |
|---|---|---|
path | string | URL pattern (supports :param, :param?, **) |
name | string (optional) | Unique route name for programmatic navigation |
meta | Record<string, unknown> (optional) | Arbitrary metadata (roles, breadcrumbs, title, …) |
@Lazy(loader)
Marks a class as a lazy-loaded route component. The bundle is loaded on first navigation and cached for subsequent visits.
{ path: '/users/:id', component: Lazy(() => import('./pages/UserDetail')) }Default export required
Pages loaded via Lazy must use export default. The loader resolves module.default at runtime.
Injecting router state
Use these field decorators to access reactive router state from any component.
@Router() — full RouterInstance
Injects the RouterInstance singleton — gives access to all signals, computed values, and navigation methods documented above.
import { Router, RouterInstance } from '@praxisjs/router'
@Component()
class NavBar extends StatefulComponent {
@Router() router!: RouterInstance
render() {
return (
<nav>
{() => this.router.loading() && <Spinner />}
<button onClick={() => this.router.push('/home')}>Home</button>
<button onClick={() => this.router.push('/about')}>About</button>
</nav>
)
}
}@Params() — route parameters
@Params() params!: RouteParams
render() {
return <p>User ID: {() => this.params().id}</p>
}@Query() — URL query string
@Query() query!: RouteQuery
render() {
return <p>Page: {() => this.query().page ?? '1'}</p>
}@Location() — full route location
@Location() location!: RouteLocation
render() {
return <p>Path: {() => this.location().path}</p>
}RouteLocation has path, params, query, hash, name, and meta.
@Meta() — route meta
Injects the meta computed for the current route. Useful for reading roles, breadcrumbs, page titles, or any other per-route metadata.
import { Meta } from '@praxisjs/router'
import type { Computed } from '@praxisjs/shared'
import type { RouteMeta } from '@praxisjs/router'
@Component()
class PageTitle extends StatefulComponent {
@Meta() meta!: Computed<RouteMeta>
render() {
return <title>{() => String(this.meta().title ?? 'App')}</title>
}
}RouterInstance
The runtime object that manages navigation state. Obtain it with @Router() on a field or imperatively with useRouter().
import { Router, RouterInstance } from '@praxisjs/router'
@Component()
class NavBar extends StatefulComponent {
@Router() router!: RouterInstance
}Reactive signals
| Field | Type | Description |
|---|---|---|
location | RouteLocation | Current route — path, params, query, hash, name, meta |
currentComponent | Signal<RouteComponent | null> | Matched page component, or null when no route matches |
currentLayout | Signal<RouteComponent | null> | Active layout component, or null |
loading | Signal<boolean> | true while a lazy component or layout is being resolved |
params | RouteParams | Shorthand for location().params |
query | RouteQuery | Shorthand for location().query |
meta | Computed<RouteMeta> | Shorthand for location().meta |
Read them inside {() => ...} to create reactive bindings:
{() => this.router.loading() && <Spinner />}
{() => this.router.params().id}
{() => this.router.location().path}
{() => this.router.meta().title}Navigation methods
Navigation accepts either a path string or a named target — { name, params?, query?, hash? }:
// Push a new entry onto the history stack — by path
await this.router.push('/users/42')
await this.router.push('/search', { q: 'foo' }) // with query
await this.router.push('/docs', undefined, 'intro') // with hash
// Push by name (recommended — decouples navigation from URL shape)
await this.router.push({ name: 'user', params: { id: '42' } })
await this.router.push({ name: 'search', query: { q: 'foo' } })
await this.router.push({ name: 'docs', hash: 'intro' })
// Replace the current history entry (no back-button entry added)
await this.router.replace('/login')
await this.router.replace({ name: 'login' })
// History traversal
this.router.back()
this.router.forward()
this.router.go(-2) // arbitrary deltapush and replace return a Promise<void> that resolves once the navigation (including any beforeEnter guards and lazy-load resolution) is complete.
resolvePath(target)
Converts a named target to its concrete path string without navigating. Useful for href generation or passing to third-party APIs:
router.resolvePath({ name: 'user', params: { id: '42' } })
// → '/users/42'afterEach(handler) — global lifecycle hook
Register a handler that runs after every completed navigation. Returns an unregister function:
const unregister = router.afterEach((to, from) => {
analytics.pageView(to.path)
})
// Call unregister() to remove the handler<Link> component
import { Link } from '@praxisjs/router'
{/* by path */}
<Link to="/home">Home</Link>
<Link to="/users" activeClass="nav-active">Users</Link>
<Link to="/settings" replace>Settings</Link>
{/* by name */}
<Link to={{ name: 'user', params: { id: '42' } }}>User 42</Link>
<Link to={{ name: 'search', query: { q: 'praxis' } }}>Search</Link><Link> resolves named targets to a concrete path for the href attribute and compares it against the current route for the active class.
| Prop | Type | Description |
|---|---|---|
to | string | NamedNavigationTarget | Target path or named route target |
replace | boolean | Replace current entry instead of pushing |
activeClass | string | CSS class added when the route is active |
Layouts
Layouts wrap one or more routes in a persistent shell (header, sidebar, nav) without re-mounting on every navigation.
Declaring a layout
Add a layout property to any route definition. RouterView will render <Layout> around the matched page component:
import { DashboardLayout } from './layouts/DashboardLayout'
import { DashboardHome } from './pages/DashboardHome'
@Router([
{ path: '/', component: HomePage },
{ path: '/dashboard/home', component: DashboardHome, layout: DashboardLayout },
])Layout inheritance via children
When a route has children, its component becomes the layout for all child routes automatically:
@Router([
{
path: '/dashboard',
component: DashboardLayout, // acts as layout for all children
children: [
{ path: '/home', component: DashboardHome },
{ path: '/settings', component: DashboardSettings },
],
},
])Both /dashboard/home and /dashboard/settings will be wrapped in DashboardLayout.
An explicit layout on a child route overrides the inherited one:
{ path: '/settings', component: DashboardSettings, layout: FullScreenLayout }Writing a layout component
Layouts are regular PraxisJS class components. There are two ways to render the matched page inside them.
Layout components must extend StatelessComponent — the router passes the current page as reactive children and StatelessComponent re-renders automatically when children change.
Pattern A — children prop — RouterView passes the matched page as a reactive child. Extend StatelessComponent with a typed props interface and render {this.props.children}:
// layouts/DashboardLayout.tsx
import { StatelessComponent } from '@praxisjs/core'
import type { Children } from '@praxisjs/shared'
interface DashboardLayoutProps {
children?: Children
}
@Component()
class DashboardLayout extends StatelessComponent<DashboardLayoutProps> {
render() {
return (
<div class="dashboard">
<Sidebar />
<main>{this.props.children}</main>
</div>
)
}
}Pattern B — <RouterOutlet> — place <RouterOutlet> anywhere in the layout; it reactively renders the current page component. Use plain StatelessComponent with no props:
// layouts/DashboardLayout.tsx
import { StatelessComponent } from '@praxisjs/core'
import { RouterOutlet } from '@praxisjs/router'
@Component()
class DashboardLayout extends StatelessComponent {
render() {
return (
<div class="dashboard">
<Sidebar />
<main>
<RouterOutlet />
</main>
</div>
)
}
}Both patterns update automatically when the route changes.
Lazy layouts
Layouts support the same Lazy() loader as page components:
{ path: '/dashboard/home', component: DashboardHome, layout: Lazy(() => import('./DashboardLayout')) }@InjectLayout() — reading the current layout
Injects Router.currentLayout as a reactive signal into any component field:
import { InjectLayout } from '@praxisjs/router'
import type { LayoutInstance } from '@praxisjs/router'
@Component()
class DebugBar extends StatefulComponent {
@InjectLayout() layout!: LayoutInstance
render() {
return <p>Active layout: {() => this.layout()?.name ?? 'none'}</p>
}
}Named routes
Give any route a name to navigate by name instead of hardcoding paths. When a URL changes, only the route definition needs updating — all call sites continue to work.
@Router([
{ path: '/', name: 'home', component: HomePage },
{ path: '/users/:id', name: 'user', component: UserDetail },
{ path: '/about', name: 'about', component: AboutPage },
])Navigate with push({ name }) or replace({ name }):
// navigate to /users/42
await router.push({ name: 'user', params: { id: '42' } })
// with query and hash
await router.push({ name: 'about', query: { ref: 'nav' }, hash: 'team' })Use in <Link>:
<Link to={{ name: 'user', params: { id: profile.id } }}>View Profile</Link>Route meta
Attach arbitrary data to any route with the meta field. Common uses: auth requirements, breadcrumbs, page titles.
@Router([
{ path: '/', name: 'home', component: HomePage, meta: { title: 'Home' } },
{ path: '/admin', name: 'admin', component: AdminPage, meta: { requiresAuth: true, roles: ['admin'] } },
{ path: '/docs/**', name: 'docs', component: DocsPage, meta: { breadcrumb: 'Docs' } },
])Access meta in a beforeEnter guard:
{
path: '/admin',
name: 'admin',
meta: { requiresAuth: true },
component: AdminPage,
beforeEnter: async (to) => {
if (to.meta.requiresAuth && !auth.loggedIn) return '/login'
return true
},
}Read reactively in a component with @Meta():
@Component()
class AppTitle extends StatefulComponent {
@Meta() meta!: Computed<RouteMeta>
render() {
return <title>{() => String(this.meta().title ?? 'My App')}</title>
}
}Or via the RouterInstance:
router.meta() // current route meta object
router.location().meta // same, via the location signal
router.location().name // current route name (or undefined)Route path syntax
Named parameters
Use :param to define a dynamic segment. The captured value is available via params().param.
{ path: '/users/:id', component: UserPage } // /users/42 → params().id === '42'
{ path: '/blog/:year/:slug', component: PostPage } // /blog/2024/helloOptional parameters
Append ? to make a segment optional. The router matches the path with and without the segment. When absent, the param value is "".
{ path: '/posts/:slug?', component: PostPage }| Path | params().slug |
|---|---|
/posts/hello-world | 'hello-world' |
/posts/ | '' |
Wildcards
Use ** to match any tail (including slashes). Typically placed last as a catch-all.
{ path: '/docs/**', component: DocsPage } // /docs/guide/intro → full tail captured
{ path: '**', component: NotFound } // catch-all fallbackNavigation lifecycle hooks
afterEnter — per-route hook
Runs after entering a specific route. Executes after the component and layout are resolved. Useful for analytics, page-specific focus management, or side effects scoped to a single route.
{
path: '/dashboard',
component: DashboardPage,
afterEnter(to, from) {
analytics.track('page_view', { path: to.path })
document.getElementById('main')?.focus()
},
}afterEnter receives (to, from) just like beforeEnter. Unlike beforeEnter, the return value is ignored — it runs fire-and-forget (not async-awaited for navigation timing).
router.afterEach() — global hook
Register a handler that fires after every completed navigation, across all routes.
const router = useRouter()
// Register — returns an unregister function
const unregister = router.afterEach((to, from) => {
analytics.pageView(to.path)
})
// Tear down when no longer needed
unregister()afterEach runs after afterEnter for the matched route. Handlers are called in registration order. Navigation that is blocked by beforeEnter returning false does not trigger afterEach.
Typical uses:
| Use case | Approach |
|---|---|
| Analytics / page view tracking | router.afterEach((to) => analytics.track(to.path)) |
| Focus management | router.afterEach(() => document.getElementById('main')?.focus()) |
| Document title | router.afterEach((to) => { document.title = String(to.meta.title ?? 'App') }) |
| Breadcrumb updates | router.afterEach((to) => breadcrumb.set(to.meta.breadcrumb)) |
Hook execution order per navigation:
beforeEnter (guard)
↓ (if not blocked)
component / layout resolved
↓
afterEnter (per-route)
↓
afterEach (global, all handlers in order)
↓
scrollBehavior (if configured)Scroll restoration
Configure scroll behavior per navigation via the scrollBehavior option passed to createRouter (or @Router). Called after every navigation completes (including popstate back/forward).
createRouter(routes, {
scrollBehavior(to, from, savedPosition) {
// Back/forward navigation: restore previous scroll position
if (savedPosition) return savedPosition
// Hash navigation: scroll to the target element
if (to.hash) return { el: `#${to.hash}` }
// Otherwise: scroll to top
return { top: 0, left: 0 }
},
})With @Router:
@Router(
[Home, About, UserDetail],
{
scrollBehavior(to, from, savedPosition) {
return savedPosition ?? { top: 0 }
},
},
)
@Component()
class App extends StatefulComponent { /* ... */ }scrollBehavior signature
type ScrollBehavior = (
to: RouteLocationInternal,
from: RouteLocationInternal | null,
savedPosition: SavedScrollPosition | null,
) => ScrollPosition | Promise<ScrollPosition>scrollBehavior can be async — the router awaits the returned Promise before applying scroll.
ScrollPosition values
| Return value | Effect |
|---|---|
{ top, left } | window.scrollTo(left, top) |
{ el: '#id' } | element.scrollIntoView() |
false | no scrolling |
How scroll positions are saved
Before each push() navigation, the router saves the current window.scrollX / window.scrollY into the current history.state entry. On back/forward (popstate), the saved coordinates are passed as savedPosition to your scrollBehavior. If no coordinates were saved (e.g. first page load), savedPosition is null.
Navigation guards
Add beforeEnter to any route definition to run logic before navigation completes. Return true to allow, false to cancel, or a path string to redirect:
{
path: '/admin',
component: AdminPage,
beforeEnter: async (to, from) => {
const ok = await auth.checkPermission(to.path)
return ok ? true : '/login'
},
}| Return value | Effect |
|---|---|
true | Allow navigation |
false | Cancel navigation (stay on current route) |
string | Redirect to that path |
Using inject() and store() in guards
Guards run outside component context, so decorators like @Inject and @Store are unavailable. Use the functional equivalents instead — they resolve from the same global singletons:
import { inject } from '@praxisjs/di'
import { store } from '@praxisjs/store'
{
path: '/dashboard',
component: DashboardPage,
beforeEnter: async (to) => {
const auth = inject(AuthService) // resolves from global DI container
return auth.loggedIn ? true : '/login'
},
},
{
path: '/admin',
component: AdminPage,
beforeEnter: async (to) => {
const session = store(SessionStore) // resolves from global store registry
return session.role === 'admin' ? true : '/login'
},
},Content
@praxisjs/content — markdown content collections with @Collection, class-based frontmatter schema, runtime validation, and a Vite plugin for zero-boilerplate build-time loading.
Store
@praxisjs/store — class-based singleton stores with @Storable and @Store. Define reactive state with @State, inject anywhere with @Store or store().