npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@exodra/router

v0.2.0

Published

File-based and programmatic routing for Exodra - Next.js-like routing with dynamic routes and layouts

Readme

@exodra/router

Routing for Exodra applications, with optional Next.js-style file-based routing via @exodra/vite-plugin.

Installation

npm install @exodra/router @exodra/core @exodra/reactivity

Overview

@exodra/router provides a routing system with:

  • File-based Routing - Generated from your file structure (via @exodra/vite-plugin)
  • Dynamic Routes - Parameters, catch-all, optional catch-all
  • Route Guards - beforeEach / beforeEnter / beforeLeave
  • Lazy Loading - Code splitting with lazy()
  • Reactive Location - router.location is a bindable you can derive() from
  • Query Helpers - Parse / stringify / validate query strings

Quick Start

createRouter(routes, options?) takes the routes array as the first positional argument. Pass a history implementation in options.history (browser or memory).

import {
  createRouter,
  createBrowserHistory,
  RouterProvider,
  Outlet,
} from '@exodra/router';
import { h } from '@exodra/core';
import { mount } from '@exodra/dom';

const router = createRouter(
  [
    { path: '/', component: HomePage },
    { path: '/about', component: AboutPage },
    { path: '/blog/:id', component: BlogPost },
  ],
  { history: createBrowserHistory() }
);

mount(
  h(RouterProvider, {
    static: {
      router,
      children: h(Outlet),
    },
  }),
  document.getElementById('root')!
);

For tests or non-browser environments, use createMemoryHistory():

import { createRouter, createMemoryHistory } from '@exodra/router';

const router = createRouter(routes, {
  history: createMemoryHistory('/about'),
});

File-based Routing

With @exodra/vite-plugin, routes are generated from your file structure:

src/pages/
├── index.tsx          → /
├── about.tsx          → /about
├── _layout.tsx        → Shared layout
├── _error.tsx         → Error boundary
├── blog/
│   ├── index.tsx      → /blog
│   ├── [id].tsx       → /blog/:id
│   └── [...slug].tsx  → /blog/* (catch-all)
└── docs/
    └── [[...path]].tsx → /docs (optional catch-all)

Special Files

  • _layout.tsx - Wraps all pages in directory
  • _error.tsx - Error boundary for pages
  • index.tsx - Directory index route

Dynamic Routes

  • [param].tsx - Single parameter /blog/123
  • [...params].tsx - Catch-all /blog/2024/12/post
  • [[...params]].tsx - Optional catch-all (matches /docs and /docs/intro)

Components

Exodra components take typed prop buckets, not flat React-style props. Static props (including children) go in static, event handlers go in handlers, reactive values go in bindables. There are no flat attributes.

RouterProvider

Provides routing context to your app. The router and children both live in static:

h(RouterProvider, {
  static: {
    router,
    children: h(Outlet), // Renders matched route
  },
});

RouterProvider can also create its own router from routes (and optional history) if you don't pass one:

h(RouterProvider, {
  static: {
    routes,
    history: createBrowserHistory(),
    children: h(Outlet),
  },
});

Outlet

Renders the matched route component:

function Layout() {
  return h('div', {
    static: {
      children: [
        h('nav', { static: { children: '...' } }),
        h(Outlet), // Child routes render here
        h('footer', { static: { children: '...' } }),
      ],
    },
  });
}

Link

Navigation component. The destination prop is to (not href), and lives in static along with children. There is no activeClass prop — derive active state yourself from router.location (see below).

h(Link, {
  static: {
    to: '/about',
    children: 'About',
  },
});

Routes / Route

Routes provides a router and renders an Outlet; Route is a declarative config marker consumed by createRoutesFromChildren. Route props go in static:

h(Routes, {
  static: {
    children: [
      h(Route, { static: { path: '/', component: HomePage } }),
      h(Route, { static: { path: '/about', component: AboutPage } }),
    ],
  },
});

Hooks

useRouter

useRouter(context) is the only router hook. It returns the router instance, from which you read the current match, location, and query.

function Component(context) {
  const router = useRouter(context);

  return h('button', {
    static: { children: 'Go' },
    handlers: {
      onClick: () => {
        router.navigate('/dashboard');
      },
    },
  });
}

Reading the current match / params

There is no useParams. Read the current match via the router instance:

function BlogPost(context) {
  const router = useRouter(context);
  const match = router.getMatch(); // TExoRouteMatch | undefined
  const id = match?.params.id;
  return h('div', { static: { children: `Post ID: ${id}` } });
}

A route renderer also receives the match directly:

const route = {
  path: '/blog/:id',
  component: (match) =>
    h('div', { static: { children: `Post ID: ${match.params.id}` } }),
};

Reading the location reactively

There is no useLocation. router.location is a bindable — read it once with router.getLocation(), or derive() from it for reactive updates (including browser back/forward):

import { derive } from '@exodra/reactivity';

const activeClass = derive(router.location, (loc) =>
  loc.pathname === '/about' ? 'nav__link--active' : 'nav__link'
);

Reading the query

There is no useQuery. Use router.getQuery(options?), or the standalone parseSearch / readSearch helpers:

import { parseSearch } from '@exodra/router';

const query = router.getQuery(); // parsed from the current location
const q = parseSearch(router.getLocation().search).q;

Route Guards

Guards run as beforeEach (createRouter option), or per-route beforeEnter / beforeLeave. Returning a string redirects; returning false blocks; true allows.

const router = createRouter(
  [
    {
      path: '/admin',
      component: AdminPanel,
      beforeEnter: async (match) => {
        const user = await getUser();
        if (!user.isAdmin) {
          return '/login'; // Redirect
        }
        return true; // Allow
      },
    },
  ],
  {
    history: createBrowserHistory(),
    beforeEach: (to, from) => true, // global guard
    afterEach: (to, from) => {
      /* analytics, etc. */
    },
  }
);

Programmatic Navigation

const router = useRouter(context);

// Navigate to a path (returns a Promise that resolves with the new location)
router.navigate('/products');

// Replace the current entry
router.navigate('/login', { replace: true });

// Update only the pathname
router.setPathname('/new-path');

// Replace the search string / query object
router.setSearch({ page: 2 });
router.setSearch('?page=2');

// Merge a patch into the existing query
router.patchQuery({ page: 2 });

Lazy Loading

Wrap a dynamic import with lazy(); the router resolves it on match. Use preloadRoute(component) to warm a chunk ahead of time.

import { lazy, preloadRoute } from '@exodra/router';

const dashboard = lazy(() => import('./Dashboard'));

const router = createRouter(
  [{ path: '/dashboard', component: dashboard }],
  { history: createBrowserHistory() }
);

// Optionally preload the chunk before navigating
void preloadRoute(dashboard);

Query Helpers

import {
  query,
  parseSearch,
  stringifySearch,
  readSearch,
  createSearch,
  mergeQuery,
} from '@exodra/router';

// Validate/coerce with a schema
const parsed = parseSearch('?page=2&active=true', {
  schema: {
    page: query.number({ default: 1 }),
    active: query.boolean({ default: false }),
  },
});

const search = stringifySearch({ page: 2, tags: ['a', 'b'] }); // '?page=2&tags=a&tags=b'
const merged = mergeQuery({ page: '1' }, { sort: 'asc' });

TypeScript Support

import type { TExoRoute, TExoRouter } from '@exodra/router';

const routes: TExoRoute[] = [
  { path: '/', component: Home },
  { path: '/about', component: About },
];

With Vite Plugin

Use @exodra/vite-plugin for automatic route generation:

// vite.config.ts
import exodra from '@exodra/vite-plugin';

export default {
  plugins: [
    exodra({
      pagesDir: 'src/pages',
    }),
  ],
};

// main.tsx
import { createRouter, createBrowserHistory } from '@exodra/router';
import { routes } from 'virtual:exodra-routes';

const router = createRouter(routes, { history: createBrowserHistory() });

API Reference

Router & History

  • createRouter(routes, options?) - Create a router; options = { history?, beforeEach?, afterEach? }
  • createBrowserHistory(options?) - Browser history (uses the History API)
  • createMemoryHistory(initialPathOrOptions?) - In-memory history (tests / SSR)

Components

  • RouterProvider - Provides routing context
  • Outlet - Renders the matched route
  • Routes - Provides a router and renders an Outlet
  • Route - Declarative route config marker
  • Link - Navigation anchor (prop to, children in static.children)
  • createRoutesFromChildren - Build a routes array from Route children

Hook & Context

  • useRouter(context) - Returns the router instance
  • routerContextKey - Context key the router is provided under

Router Instance

  • router.location - Bindable of the current location (derive() from it)
  • router.match / router.getMatch() - Current route match
  • router.navigationState / router.getNavigationState()
  • router.getLocation() - Current location snapshot
  • router.getQuery(options?) - Parsed query
  • router.navigate(to, { replace? })
  • router.setPathname(pathname, options?)
  • router.setSearch(search, options?) / router.setQuery(query, options?) / router.patchQuery(query, options?)
  • router.bindQuery(key, options?) - Two-way bindable for one query parameter
  • router.createHref(to) / router.dispose()

Lazy Loading

  • lazy(loader) - Wrap a dynamic import as a route loader
  • preloadRoute(component) - Preload a lazy route's chunk
  • isLazyLoader, loadLazyComponent, resolveRouteComponent

Matching & Paths

  • matchRoutes(routes, pathname) - Match a pathname against routes
  • parsePath(path) - Parse a path into { pathname, search, hash, href }

Query Helpers

  • query - Field builders (query.string, query.number, query.boolean, query.array, query.optional)
  • parseSearch, readSearch, stringifySearch, createSearch, mergeQuery
  • ExoQueryValidationError

Guards

  • collectRouteGuards, executeGuard, executeGuards

Route Configuration (TExoRoute)

  • path - Route path pattern
  • component - Schema, renderer (match) => schema, or lazy() loader
  • children - Nested routes
  • beforeEnter / beforeLeave - Route guards
  • layout / error - Layout and error components
  • catchAll / optionalCatchAll - Catch-all parameter names

Types

  • TExoRouter, TExoRoute, TExoRouteMatch, TExoLocation, TExoRouterOptions, and more

License

MIT


📖 Full documentation: exodra.org