routexiz
v0.2.4
Published
A modern tree-based router for React with intent-driven navigation, Suspense-first data loading, and built-in guards, middleware, and caching.
Maintainers
Readme
🕹️ routexiz
LIVE EXAMPLE
A lightweight, modern, and flexible React router with tree-based routing, Suspense-first data loading, and nested route support.
Routes are modeled like a tree, where each route is a node and navigation resolves a path from root to leaf. Nested layouts, guards, middlewares, and per-route hooks are fully supported.
Why routexiz?
Traditional routing:
navigate("/users/1")routexiz:
navigate("/users/:id", {
params: { id: 1 }
})- ⚡ Suspense data loading (like React 18 philosophy)
- 🧠 Guards (before navigation)
- 🔧 Middleware (side-effects)
- 🔁 Nested routing builder API
- 🚀 Prefetch (hover + viewport)
- 🧩 Loader caching with TTL
- ❌ Error boundary per route
- 🎨 Transition support
Installation
npm install routexizBasic
import { route, RouterProvider } from "routexiz"
route("/", () => <div>Home</div>)
export default function App() {
return <RouterProvider />
}Nested Routing & Builder API
route("/", Layout, root => {
root.route("/dashboard", Dashboard, dash => {
dash.route("/stats", () => <div>Dashboard Stats Page</div>);
dash.route("/settings", () => <div>Dashboard Settings Page</div>);
});
root.route("/users", Users);
root.route("/users/:id", User, {
loader: async ({ params }) => ({ id: params.id, name: "User " + params.id }),
fallback: <div>Loading user...</div>,
errorBoundary: ({ error }) => <div>Error: {String(error)}</div>,
});
root.route("*", NotFound); // wildcard fallback
});Each node can have its own layout, children, loader, hooks, and options.
Navigation
useNavigate / navigate
const navigate = useNavigate()
navigate("/users/:id", {
params: { id: 1 },
query: { tab: "profile" }
// transition: 'fade',
// duration: 100
})redirect
import { redirect } from "routexiz";
redirect("/users/:id", { params: { id: 1 } });Link
<Link to="/users/:id" params={{ id: 1 }} query={{ tab: "profile" }}>
User 1
</Link>
<NavLink
to="/users/:id"
params={{ id: 1 }}
className={({ isActive }) => isActive ? "active" : "link"}
>
User 1
</NavLink>Data Loading
Loader allows async data fetching per route, compatible with React Suspense:
root.route("/users/:id", User, {
loader: async ({ params }) => fetchUser(params.id),
fallback: <div>Loading user...</div>,
errorBoundary: ({ error }) => <div>Error: {String(error)}</div>
});Use useLoaderData() to access loaded data inside components
// Access loader data:
const data = useLoaderData()Route Options & Hooks
1️⃣ Guard
A guard determines whether navigation is allowed.
- Runs before entering the route
- Can block navigation
root.route("/dashboard", Dashboard, dash => {
dash.guard(({ params }) => {
if (!isLoggedIn()) return false; // block
return true; // allow
});
});2️⃣ Middleware
Middleware runs after guards, used for side effects.
- Cannot block navigation
- Can be async
dash.middleware(async ({ params, query }) => {
console.log("Visited dashboard with params:", params);
});3️⃣ onEnter / onLeave
Hooks triggered when entering or leaving a route node:
root.onEnter(({ path, params }) => console.log("Enter root:", path));
root.onLeave(({ path, params }) => console.log("Leave root:", path));- onEnter → called when node becomes active
- onLeave → called when node is left
Guards - Middleware
| Creteria | Guard | Middleware |
| --------------- | ------------------------- | -------------------- |
| Purpose | Control access | Perform side effects |
| Can block route | ✅ Yes | ❌ No |
| Return value | true / false / redirect | Ignored |
| Execution order | First | After guards |
| Async support | ✅ Yes | ✅ Yes |
Mental Model
- Guard = "Can we enter?"
- Middleware = "Do something while entering"
4️⃣ prefetch
Prefetch loader data in advance:
<Link to="/users/:id" params={{ id: 1 }}>User 1</Link>- Hover → prefetch by default
- Viewport → prefetch when enters viewport
- Disable: disablePrefetch
5️⃣ errorBoundary / fallback
root.route("/users/:id", User, {
fallback: <div>Loading user...</div>,
errorBoundary: ({ error }) => <div>Error: {String(error)}</div>,
});Lazy
import React, { Suspense } from "react"
import { route, RouterProvider } from "routexiz"
// Lazy load page
const Dashboard = React.lazy(() => import("./pages/Dashboard"))
route("/", Dashboard, {
fallback: <div>Loading Dashboard...</div>,
})Cache (TTL)
1️⃣ Declare route with cache
route("/users/:id", User, {
loader: async ({ params }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${params.id}`)
if (!res.ok) throw new Error("User not found")
return res.json()
},
ttl: 5000, // cache expires in 5 seconds
fallback: <div>Loading user...</div>,
errorBoundary: ({ error }) => <div>Error: {String(error)}</div>
})2️⃣ Access cached data inside component
import { useLoaderData, useParams } from "routexiz"
function User() {
const data = useLoaderData<any>()
const params = useParams()
return (
<div>
User {params.id}
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)
}
3️⃣ Prefetch / warm up cache
import { prefetch } from "routexiz"
// Prefetch user 1 in background
prefetch("/users/1")4️⃣ Cleanup expired cache
import { cleanupCache } from "routexiz"
// Remove all expired resources
cleanupCache()
// Remove only 10 oldest expired items
cleanupCache(10)TTL cache ensures route loaders are cached for fast navigation and automatically cleaned after expiry.
Global Fallback & Error
routexiz allows you to configure global fallback UI and global error boundaries for your entire app.
1️⃣ Set Global Fallback
import { setGlobalFallback } from "routexiz"
// Enable global fallback with a custom loader
setGlobalFallback(true, <div>App is loading...</div>)If a route does not provide its own fallback, this global fallback will be used.
Works with Suspense loaders.
2️⃣ Set Global Error
import { setGlobalError } from "routexiz"
// Enable global error handling with a custom render
setGlobalError(true, (error) => (
<div style={{ color: "red" }}>Oops! {String(error)}</div>
))If a route does not provide its own errorBoundary, this global error component will be used.
Receives an error object and must return a valid React element.
3️⃣ Example with RouterProvider
import React, { Suspense } from "react"
import { RouterProvider, setGlobalFallback, setGlobalError } from "routexiz"
setGlobalFallback(true, <div>Loading app...</div>)
setGlobalError(true, (error) => <div>Error occurred: {String(error)}</div>)
export default function App() {
return (
<Suspense fallback={null}>
<RouterProvider />
</Suspense>
)
}The Suspense wrapper can still override the global fallback if needed.
Per-route fallback and errorBoundary take priority over global settings.
Hooks
1️⃣ useLoaderData()
Returns the data loaded by the route's loader. Suspense-aware.
import { useLoaderData } from "routexiz"
function User() {
const data = useLoaderData<{ id: string; name: string }>()
return (
<div>
User {data.id}: {data.name}
</div>
)
}
2️⃣ useParams()
Returns the dynamic route parameters.
import { useParams } from "routexiz"
function User() {
const params = useParams()
return <div>User ID: {params.id}</div>
}3️⃣ useRouteContext()
Returns the current route path.
import { useRouteContext } from "routexiz"
function CurrentRoute() {
const path = useRouteContext()
return <div>Current route: {path}</div>
}4️⃣ useNavigation()
Returns router state: loading, current path, and pending path.
import { useNavigation } from "routexiz"
function NavStatus() {
const { loading, path, pendingPath } = useNavigation()
return (
<div>
{loading ? `Navigating to ${pendingPath}...` : `Current path: ${path}`}
</div>
)
}5️⃣ useTransition()
Returns transition info (name and stage). Useful for page animations.
import { useTransition } from "routexiz"
function PageTransition() {
const { name, stage } = useTransition()
return <div>Transition {name}: {stage}</div>
}.fade-entering {
opacity: 0;
transition: opacity 0.3s ease;
}
.fade-exiting {
opacity: 1;
}You are responsible for defining CSS transitions for each stage.
6️⃣ useRouterData()
import React from "react"
import { useRouterData } from "./hooks"
export function UserPage() {
const { data, params, query, path, meta } = useRouterData<{ id: string; name: string }>()
return (
<div>
<h2>User Page</h2>
<div>Path: {path}</div>
<div>Params: {JSON.stringify(params)}</div>
<div>Query: {JSON.stringify(query)}</div>
<div>Data: {JSON.stringify(data)}</div>
<div>Meta: {JSON.stringify(meta)}</div>
</div>
)
}7️⃣ useQuery()
import { useQuery } from "routexiz"
function Users() {
const query = useQuery()
return (
<div>
Tab: {query.tab}
</div>
)
}RouterProvider
transition
import "routexiz/styles.css"
export default function App() {
return <RouterProvider transition="fade" duration={100} />;
}Support fade | slide | scale | none
transition with Wrapper
export default function App() {
return <RouterProvider transition="fade" duration={100} wrapper={Layout} />;
}Data Preloading (SSR / Advanced)
routexiz provides loadRouteData() to preload all route loaders before rendering.
This is useful for:
- SSR (Server-Side Rendering)
- Prefetching with full data
- Avoiding loading flicker
1️⃣ Basic Usage
import { loadRouteData } from "routexiz"
const data = await loadRouteData("/users/1")2️⃣ Example Route
route("/users/:id", User, {
loader: async ({ params }) => {
await new Promise(r => setTimeout(r, 300))
return { id: params.id, name: "User " + params.id }
}
})3️⃣ Access Loaded Data
loadRouteData returns a map of resources (Suspense-based):
const data = await loadRouteData("/users/1")
const user = data["/users/:id"].get()
console.log(user)
// { id: "1", name: "User 1" }4️⃣ SSR Example
import { loadRouteData } from "routexiz"
async function render(url: string) {
const data = await loadRouteData(url)
const user = data["/users/:id"]?.get()
return `
<html>
<body>
<h1>${user?.name}</h1>
</body>
</html>
`
}
render("/users/1")5️⃣ Nested Routes Example
route("/", Layout, root => {
root.route("/dashboard", Dashboard, dash => {
dash.route("/users/:id", User, {
loader: async ({ params }) => {
return { id: params.id }
}
})
})
})
const data = await loadRouteData("/dashboard/users/1")
const user = data["/dashboard/users/:id"].get()6️⃣ Notes
Each route loader returns a resource (Suspense-based)
Use .get() to read data safely
.read() is used internally to trigger loading
7️⃣ Difference vs prefetch
| Function | Behavior |
| ----------------- | ------------------------------ |
| prefetch() | Trigger loading (non-blocking) |
| loadRouteData() | Await full data (blocking) |
When to use
- ✅ SSR (recommended)
- ✅ Full preloading before navigation
- ✅ Testing / debugging loaders
Comparison
routexiz focuses on modern React routing with Suspense-first data loading, nested routes, guards, middleware, and prefetch/caching.
It’s lightweight and flexible, designed for client-side SPAs.
| Criteria | routexiz | React Router | TanStack Router | Remix | | ------------------------- | -------- | ------------ | --------------- | ----- | | Nested routes builder API | ✅ | ✅ | ✅ | ✅ | | Suspense-first loaders | ✅ | ⚠️ | ✅ | ✅ | | Guards & middleware | ✅ | ⚠️ | ✅ | ⚠️ | | Prefetch / caching | ✅ | ❌ | ✅ | ⚠️ | | Error boundary per route | ✅ | ⚠️ | ✅ | ✅ | | Transition support | ✅ | ❌ | ⚠️ | ⚠️ | | Lightweight & minimal | ✅ | ⚠️ | ⚠️ | ⚠️ |
Full Example
Users.tsx
// Users.tsx
import { Link } from "routexiz";
export function Users() {
return (
<div>
<h3>Users List</h3>
<Link to="/users/1" query={{ tab: [1, 2] }}>User 1</Link>
<br />
<Link to="/users/2">User 2</Link>
<br />
<Link to="/users/999">User 999 (error)</Link>
<br />
<Link to="/users/1/profile">User 1 Profile</Link>
</div>
);
}App.tsx
import React, { ReactNode, Suspense } from "react";
import {
Link,
RouterProvider,
route,
useLoaderData,
useParams,
useQuery,
} from "routexiz";
import "routexiz/styles.css";
/* =========================
DEMO PAGES
========================= */
function Layout({ children }: { children?: ReactNode }) {
return (
<div style={{ padding: 20 }}>
<h2>App Layout</h2>
<nav>
<Link to="/dashboard">Dashboard</Link> |{" "}
<Link to="/dashboard/stats">Dashboard Stats</Link> |{" "}
<Link to="/dashboard/settings">Dashboard Settings</Link> |{" "}
<Link to="/users">Users</Link> |{" "}
<Link to="/users/1">User 1</Link> |{" "}
<Link to="/about">About</Link> |{" "}
<Link to="/about/team">Team</Link> |{" "}
<Link to="/contact">Contact</Link> |{" "}
<Link to="/blog/my-first-post">Blog Post</Link> |{" "}
<Link to="/search/react#top">Search React</Link> |{" "}
<Link to="/unknown/path">Unknown (404)</Link>
</nav>
<hr />
{children}
</div>
);
}
function Dashboard({ children }: { children?: ReactNode }) {
return <div>Dashboard {children}</div>;
}
// Lazy-loaded Users list
const Users = React.lazy(() => import("./Users"));
function User() {
const data = useLoaderData<any>();
const params = useParams();
const query = useQuery();
return (
<div>
<h3>User {params.id}</h3>
<div>Query: {JSON.stringify(query)}</div>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
function About({ children }: { children?: ReactNode }) {
return (
<div>
<h2>About Page</h2>
{children}
</div>
);
}
function Team() {
return <div>Team Page</div>;
}
function Contact() {
return <div>Contact Page</div>;
}
function Blog() {
const data = useLoaderData<any>();
return <div>Blog Post: {data.slug}</div>;
}
function Search() {
const params = useParams();
const query = useQuery();
const term = Array.isArray(query.term) ? query.term[0] : query.term;
return (
<div>
<h3>Search Page</h3>
<div>Param: {params.term || "none"}</div>
<div>Query: {term || "none"}</div>
</div>
);
}
function NotFound() {
return <div>Page Not Found (404)</div>;
}
/* =========================
ROUTES SETUP
========================= */
function setupRoutes() {
route("/", Layout, (root) => {
// Dashboard + nested
root.route("/dashboard", Dashboard, (dash) => {
dash.guard(({ path }) => {
console.log("Guard dashboard:", path);
return true;
});
dash.middleware(({ path }) => console.log("Middleware dashboard:", path));
dash.onEnter(({ path }) => console.log("Enter dashboard:", path));
dash.onLeave(({ path }) => console.log("Leave dashboard:", path));
dash.route("/stats", () => <div>Dashboard Stats Page</div>);
dash.route("/settings", () => <div>Dashboard Settings Page</div>);
});
// Users list
root.route("/users", Users); // lazy page
// Dynamic user pages
root.route("/users/:id", User, {
loader: async ({ params }) => {
await new Promise((r) => setTimeout(r, 400));
if (params.id === "999") throw new Error("User not found");
return { id: params.id, name: "User " + params.id };
},
fallback: <div>Loading user...</div>,
errorBoundary: ({ error }) => <div>Error: {String(error)}</div>,
});
// Profile & posts subroutes
root.route("/users/:id/profile", () => <div>User Profile Page</div>);
root.route("/users/:id/posts", () => <div>User Posts Page</div>);
// About nested
root.route("/about", About, (about) => {
about.route("team", Team);
});
root.route("/contact", Contact);
// wildcard fallback
root.route("*", NotFound);
});
// Blog route with loader
route("/blog/:slug", Blog, {
loader: async ({ params }) => {
await new Promise((r) => setTimeout(r, 200));
return { slug: params.slug };
},
});
// Search route
route("/search", Search);
route("/search/:term", Search);
}
setupRoutes();
/* =========================
APP
========================= */
export default function App() {
return (
<div>
<h1>Full RouteXiz Demo</h1>
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider />
</Suspense>
</div>
);
}Architecture
Link / navigate / redirect
↓
matchRouteChain
↓
guards → middleware
↓
loader (cached)
↓
Suspense
↓
renderDuplicate Routes
routexiz does not support duplicate route paths.
Each route must resolve to a unique full path in the route tree.
Why?
- Navigation resolves a single path from root to leaf
- Duplicate paths create ambiguous matches
- This leads to unpredictable behavior
- Duplicate routes are NOT validated automatically.
- The router will NOT throw an error or warning.
Requirement
Ensure all routes are unique:
route("/", Layout, root => {
root.route("/dashboard", Dashboard)
root.route("/dashboard/users", Users)
// ❌ BAD: duplicate
root.route("/dashboard/users", AnotherUsers)
})Recommendation
- Keep route paths explicit and unique
- Avoid defining the same full path in multiple branches
- Use nesting correctly to prevent duplication
License
MIT
