PraxisJS

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/router
pnpm add @praxisjs/router
yarn add @praxisjs/router
bun add @praxisjs/router

Setup 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>
    )
  }
}
Storybook
Live demo — Router

@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

FieldTypeDescription
pathstringURL pattern (supports :param, :param?, **)
namestring (optional)Unique route name for programmatic navigation
metaRecord<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

FieldTypeDescription
locationRouteLocationCurrent route — path, params, query, hash, name, meta
currentComponentSignal<RouteComponent | null>Matched page component, or null when no route matches
currentLayoutSignal<RouteComponent | null>Active layout component, or null
loadingSignal<boolean>true while a lazy component or layout is being resolved
paramsRouteParamsShorthand for location().params
queryRouteQueryShorthand for location().query
metaComputed<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 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 delta

push 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

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.

PropTypeDescription
tostring | NamedNavigationTargetTarget path or named route target
replacebooleanReplace current entry instead of pushing
activeClassstringCSS 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 propRouterView 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')) }
Storybook
Live demo — Layout (children prop)
Storybook
Live demo — RouterOutlet (sidebar)

@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>
Storybook
Live demo — Named routes

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)
Storybook
Live demo — Route meta (title, breadcrumbs, auth)

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/hello

Optional 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 }
Pathparams().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 fallback

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 caseApproach
Analytics / page view trackingrouter.afterEach((to) => analytics.track(to.path))
Focus managementrouter.afterEach(() => document.getElementById('main')?.focus())
Document titlerouter.afterEach((to) => { document.title = String(to.meta.title ?? 'App') })
Breadcrumb updatesrouter.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)
Storybook
Live demo — afterEnter + afterEach event log

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 valueEffect
{ top, left }window.scrollTo(left, top)
{ el: '#id' }element.scrollIntoView()
falseno 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.

Storybook
Live demo — scrollBehavior (top · hash · savedPosition)

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 valueEffect
trueAllow navigation
falseCancel navigation (stay on current route)
stringRedirect 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'
  },
},
Storybook
Live demo — Navigation Guard

On this page