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

vortexr

v1.0.0

Published

Zero-dependency React router with nested layouts, dynamic routes, guard chains, and full TypeScript support.

Readme

🌀 vortexr

Zero-dependency React router. Nested layouts · Dynamic routes · Guard chains · Loaders & Actions · Lazy loading · Prefetch · Blockers · Hash routing · Typed routes · DevTools

npm version bundle size license TypeScript zero deps tests

npm install vortexr

Why vortexr?

Most routing libraries are massive. They carry years of legacy decisions, layers of abstraction, and APIs that require reading 40 pages of docs before writing a single route.

vortexr is different. It's built on two things you already have: React and the History API. Nothing more.

react-router-dom   →  53kb gzipped   →  3 peer deps
@tanstack/router   →  43kb gzipped   →  4 peer deps
vortexr            →  ~4kb gzipped   →  0 deps       ✓

Despite the size, vortexr ships with features some frameworks don't have: prefetching, route-level caching, navigation blockers, hash routing, and fully typed navigation.


Features

| | Feature | Details | |----|------------------------------|--------------------------------------------------------------------| | 🧭 | Nested layouts | Stack layouts like Next.js App Router — without the framework | | 🚪 | <Outlet /> | True nested rendering — react-router style | | 🔀 | Dynamic segments | /users/:id/posts/:postId just works | | 🛡️ | Route guards | Sync or async. Single guard or full middleware chain | | 🔁 | Guard inheritance | Child routes automatically inherit parent guards | | 📦 | Loaders | Fetch data before render. Access via useLoaderData() | | 📝 | Actions + <Form> | Handle form submissions without page reload (remix-style) | | ⚡ | useNavigation | Global "idle" / "loading" state | | 💥 | Error Boundary | Per-route or global, with reset support | | 🗂️ | Route meta | Auto document.title + description sync | | 🌍 | Basename | Deploy on a subdirectory with zero config | | #️⃣ | Hash routing | /#/path mode for static hosts (GitHub Pages, etc.) | | 🚦 | useBlocker | Warn before leaving with unsaved changes | | 🪝 | beforeEach/afterEach | Global navigation hooks — analytics, auth, redirects | | ⚡ | Lazy routes | lazyRoute() + built-in <Suspense> | | 🔗 | Prefetch | <Link prefetch="hover" \| "render"> warms the loader cache | | ⏱️ | staleTime cache | Skip re-fetching loader data within a time window | | 🧬 | Typed routes | createRouter() — type-checked push("/users/:id", { id }) | | 🔧 | DevTools | Floating panel: active route, guards, cache, history | | 📜 | Scroll restoration | "top", "restore", or "none" | | 🔷 | Full TypeScript | Everything typed — params, guards, loaders, actions, meta | | 🪶 | Tiny | ~4kb gzipped, zero runtime dependencies | | ✅ | 81 passing tests | Every utility and core module covered |


Installation

npm install vortexr

Requires React 18+


Quick Start

import { Router, Link, defineRouteConfig } from "vortexr";

function HomePage()  { return <h1>Home</h1>;  }
function AboutPage() { return <h1>About</h1>; }

const routes = defineRouteConfig([
  { path: "/",      component: HomePage  },
  { path: "/about", component: AboutPage },
]);

export default function App() {
  return <Router routes={routes} />;
}

No providers. No context setup. Just routes.


Layouts & <Outlet />

function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/dashboard">Dashboard</Link>
      </nav>
      <main>
        <Outlet /> {/* renders the matched child route */}
      </main>
    </>
  );
}

function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "200px 1fr" }}>
      <aside>
        <Link to="/dashboard/settings">Settings</Link>
        <Link to="/dashboard/profile">Profile</Link>
      </aside>
      <section><Outlet /></section>
    </div>
  );
}

const routes = defineRouteConfig([
  {
    path: "/",
    component: HomePage,
    layout: RootLayout,
  },
  {
    path: "/dashboard",
    component: DashboardPage,
    layout: RootLayout,
    children: [
      { path: "/settings", component: SettingsPage, layout: DashboardLayout },
      { path: "/profile",  component: ProfilePage,  layout: DashboardLayout },
    ],
  },
]);

Render chain (outside → in):

RootLayout → DashboardLayout → SettingsPage

<Outlet /> and {children} are equivalent — use whichever feels natural.


Dynamic Routes

function PostPage() {
  const { id, postId } = useParams<{ id: string; postId: string }>();
  return <p>User {id} — Post {postId}</p>;
}

const routes = defineRouteConfig([
  { path: "/users/:id/posts/:postId", component: PostPage },
]);

| Pattern | Example URL | Params | |----------------|--------------------------|--------------------------------| | /users/:id | /users/42 | { id: "42" } | | /posts/:slug | /posts/hello-world | { slug: "hello-world" } | | /a/:x/b/:y | /a/1/b/2 | { x: "1", y: "2" } | | /docs/* | /docs/anything/here | — |


Route Guards

const isAuthenticated = () => Boolean(localStorage.getItem("token"));

const isAdmin = async () => {
  const user = await fetchCurrentUser();
  return user.role === "admin" ? true : "/403"; // redirect to /403 if not admin
};

const routes = defineRouteConfig([
  {
    path: "/admin",
    component: AdminPage,
    guards: [isAuthenticated, isAdmin], // all must pass, short-circuits on failure
    redirectTo: "/login",
    guardFallback: LoadingSpinner,       // shown while async guards resolve
  },
]);

Child routes automatically inherit parent guards:

{
  path: "/dashboard",
  guard: isAuthenticated,  // inherited by all children
  children: [
    { path: "/settings", component: SettingsPage },                    // inherits isAuthenticated
    { path: "/admin",    component: AdminSection, guard: isAdmin },    // runs AFTER isAuthenticated
  ],
}

Loaders

Data fetches before the page renders. No useEffect, no loading state in your component.

const userLoader: LoaderFn<User> = async ({ params }) => {
  const res = await fetch(`/api/users/${params.id}`);
  if (!res.ok) throw new Error("User not found"); // caught by ErrorBoundary
  return res.json();
};

const routes = defineRouteConfig([
  { path: "/users/:id", component: UserPage, loader: userLoader },
]);

function UserPage() {
  const user = useLoaderData<User>();
  return <h1>{user.name}</h1>;
}

The loader receives { params, searchParams }.

Caching with staleTime

{
  path: "/posts",
  component: PostsPage,
  loader: postsLoader,
  staleTime: 30_000, // cache for 30s — re-navigating skips the loader
}

Actions + <Form>

Handle form submissions remix-style — no page reload, no manual fetch wiring.

const loginAction: ActionFn<{ error?: string }> = async ({ formData }) => {
  const email    = formData.get("email");
  const password = formData.get("password");

  const ok = await login(email, password);
  if (!ok) return { error: "Invalid credentials" };
  return "/dashboard"; // returning a string = redirect
};

const routes = defineRouteConfig([
  { path: "/login", component: LoginPage, action: loginAction },
]);

function LoginPage() {
  const { data, state } = useActionData<{ error?: string }>();

  return (
    <Form>
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button disabled={state === "submitting"}>
        {state === "submitting" ? "Signing in..." : "Sign in"}
      </button>
      {data?.error && <p>{data.error}</p>}
    </Form>
  );
}

Lazy Loading + Prefetch

import { lazyRoute } from "vortexr";

const routes = defineRouteConfig([
  {
    path: "/dashboard",
    component: lazyRoute(() => import("./pages/Dashboard")),
  },
]);

// <Router suspenseFallback={...}> controls what shows while loading
<Router routes={routes} suspenseFallback={<Spinner />} />

Prefetch a route's loader before the user even navigates:

<Link to="/users" prefetch="hover">Users</Link>   {/* warms cache on hover  */}
<Link to="/posts" prefetch="render">Posts</Link>  {/* warms cache on mount */}

Combine with staleTime — the prefetched data is reused instantly on navigation.


useBlocker — unsaved changes guard

function SettingsPage() {
  const [dirty, setDirty] = useState(false);

  useBlocker({
    when: dirty,
    message: "You have unsaved changes. Leave anyway?",
  });

  return <input onChange={() => setDirty(true)} />;
}

For full control, pass a custom fn:

useBlocker({
  when: isDirty,
  fn: ({ nextPath }) => {
    if (nextPath === "/save") return true; // always allow this path
    return "Unsaved changes will be lost. Continue?";
  },
});

Global Navigation Hooks

import { routerStore } from "vortexr";

// Runs before every navigation. Return a string to redirect instead.
routerStore.beforeEach((to, from) => {
  analytics.track("page_view", { path: to });
  if (!isAuthed && to !== "/login") return "/login";
});

// Runs after every navigation.
routerStore.afterEach((to, from) => {
  console.log(`navigated ${from} → ${to}`);
});

Hash Routing

For static hosts with no server-side routing (GitHub Pages, S3, etc.):

import { routerStore } from "vortexr";

routerStore.setMode("hash");
// /dashboard  →  /#/dashboard

Everything else — <Link>, <Router>, guards, loaders — works exactly the same.


Typed Routes

Type-checked navigation, separate from your <Router routes={...}> config:

import { createRouter } from "vortexr";

export const appRouter = createRouter([
  "/",
  "/users/:id",
  "/users/:id/posts/:postId",
] as const);

appRouter.push("/users/:id", { id: 42 });
// → navigates to "/users/42"

appRouter.push("/users/:id/posts/:postId", { id: 1, postId: 7 });
// → "/users/1/posts/7"

appRouter.push("/userz");        // ❌ TypeScript error — not in the list
appRouter.push("/users/:id");    // ❌ TypeScript error — missing params

DevTools

import { VortexrDevTools } from "vortexr";

export default function App() {
  return (
    <>
      <Router routes={routes} />
      <VortexrDevTools />
    </>
  );
}

A floating panel (bottom-right, dev-only) showing:

  • Route — matched pattern, params, active guards, loader/action/cache flags, meta
  • History — last 20 navigations
  • CachestaleTime cache status per route (fresh/stale)

Automatically disabled when NODE_ENV === "production".


Route Meta

const routes = defineRouteConfig([
  {
    path: "/dashboard",
    component: DashboardPage,
    meta: { title: "Dashboard — MyApp", description: "Manage your account" },
  },
]);

document.title and <meta name="description"> are synced automatically. Access via useRouteMeta().


Error Boundaries

// Global
<Router
  routes={routes}
  errorFallback={({ error, reset }) => (
    <div>
      <p>{error.message}</p>
      <button onClick={reset}>Retry</button>
    </div>
  )}
/>

// Per-route — overrides global
{
  path: "/admin",
  component: AdminPage,
  errorFallback: ({ error, reset }) => <AdminCrashScreen error={error} onRetry={reset} />,
}

Scroll Restoration & Basename

import { routerStore } from "vortexr";

routerStore.setScrollBehavior("top");     // always scroll to top (default)
routerStore.setScrollBehavior("restore"); // restore position on back/forward
routerStore.setScrollBehavior("none");    // do nothing

routerStore.setBasename("/my-app");       // for subdirectory deployments
// or: <Router routes={routes} basename="/my-app" />

API Reference

Components

| Component | Description | |---|---| | <Router routes notFound errorFallback basename suspenseFallback /> | Root router | | <Link to replace prefetch /> | Client-side navigation | | <NavLink to activeClassName activeStyle exact /> | Link with active state | | <Navigate to replace /> | Declarative redirect | | <Form> | Submits to the active route's action | | <Outlet /> | Renders matched child route | | <VortexrDevTools /> | Floating dev panel |

Hooks

| Hook | Returns | |---|---| | usePathname() | string — current path | | useRouter() | { push, replace, back, forward } | | useNavigate() | (to: string \| number, opts?) => void — react-router style | | useParams<T>() | T — typed dynamic segments | | useSearchParams() | [URLSearchParams, setParams] | | useMatch(pattern) | { params } \| null | | useLoaderData<T>() | T — data from route loader | | useActionData<T>() | { data: T \| undefined, state } | | useNavigation() | { state: "idle" \| "loading" } | | useRouteMeta() | RouteMeta — active route's meta | | useBlocker(options) | void — blocks navigation when when is true |

Utils

| Export | Description | |---|---| | defineRouteConfig(routes) | Type-safe route config helper | | lazyRoute(() => import(...)) | Wraps React.lazy for component field | | createRouter(paths) | Type-checked push/replace/build | | runGuards(guards, redirectTo) | Manually run a guard chain | | clearCache() | Clears the prefetch/staleTime cache | | routerStore | Low-level store — see below |

routerStore

routerStore.push(path)
routerStore.replace(path)
routerStore.back()
routerStore.forward()
routerStore.getPath()           // current path (basename-stripped)
routerStore.subscribe(fn)        // → unsubscribe fn

routerStore.setMode("hash" | "history")
routerStore.getMode()

routerStore.setBasename("/my-app")
routerStore.getBasename()

routerStore.setScrollBehavior("top" | "restore" | "none")

routerStore.beforeEach((to, from) => void | string)  // → unsubscribe fn
routerStore.afterEach((to, from) => void)             // → unsubscribe fn

RouteConfig

type RouteConfig = {
  path: string;
  component: VortexrComponent;
  layout?: VortexrLayout;
  children?: RouteConfig[];

  guard?: GuardFn;
  guards?: GuardFn[];
  redirectTo?: string;
  guardFallback?: VortexrComponent;

  errorFallback?: VortexrErrorFallback;

  loader?: LoaderFn;
  staleTime?: number;

  action?: ActionFn;

  meta?: RouteMeta;
  prefetch?: "hover" | "render" | "none";
};

Testing

npm install vitest happy-dom --save-dev
npm test
✓ matcher.test.ts      14 tests — static, dynamic, wildcard, nested
✓ guards.test.ts       11 tests — allow, deny, chains, async
✓ flatten.test.ts      15 tests — path/layout/guard/meta inheritance
✓ prefetch.test.ts     11 tests — cache, staleTime, prefetchLoader
✓ blocker.test.ts       8 tests — register, block, custom messages
✓ beforeEach.test.ts    6 tests — global hooks, redirects
✓ typedRoutes.test.ts   7 tests — build(), push() with params
✓ hashMode.test.ts      5 tests — hash mode navigation
✓ store.test.ts         4 tests — basename, scroll behavior

81 passed

How It Works

URL change (pushState / hashchange / popstate)
        │
        ▼
   routerStore          ← pub/sub · history or hash mode · basename · blockers · beforeEach/afterEach
        │
        ▼
  usePathname()         ← useState + subscribe
        │
        ▼
   <Router />           ← flattenRoutes → matchPath → pick winning route
        │
        ▼
  <GuardedRoute />      ← guard chain (sync/async) → allow / deny+redirect
        │
        ▼
  <Suspense>            ← supports lazyRoute() components
        │
        ▼
  <LoadedRoute />       ← runs loader (staleTime-aware) + action handler
        │
        ▼
  layout chain          ← Outlet-based: Layout[0] → Layout[1] → Page
        │
        ▼
  Context providers     ← Router · Navigation · Loader · Action · RouteMeta
        │
        ▼
  <ErrorBoundary>        ← per-route or global fallback
        │
        ▼
     Page renders        ← useLoaderData, useActionData, useParams, ...

Recommended Project Structure

src/
├── router/
│   ├── routes.tsx          ← route definitions
│   └── typedRoutes.ts       ← createRouter([...])
│
├── guards/
│   ├── isAuthenticated.ts
│   └── isAdmin.ts
│
├── loaders/
│   └── usersLoader.ts
│
├── actions/
│   └── loginAction.ts
│
├── layouts/
│   ├── RootLayout.tsx
│   └── DashboardLayout.tsx
│
├── pages/
│   ├── home/page.tsx
│   └── users/
│       ├── page.tsx
│       └── [id]/page.tsx
│
└── App.tsx

Roadmap

  • [x] Nested layouts, dynamic routes, guard chains
  • [x] <Outlet />, loaders, useNavigation
  • [x] Error boundaries, route meta, scroll restoration, basename
  • [x] Lazy routes, prefetch, staleTime cache
  • [x] useBlocker
  • [x] Actions, <Form>, useActionData
  • [x] beforeEach/afterEach, hash routing, typed routes, DevTools
  • [ ] View Transitions API integration
  • [ ] SSR / streaming support

Contributing

git clone https://github.com/mohammadpy8/vortexr
cd vortexr
npm install
npm run dev

PRs welcome — open an issue first for larger changes.


License

MIT © Mohammad