Routing
The routing module in tempest-react-sdk wraps React Router in declarative mode (^7 || ^8) and gives you a single import surface: you declare your route tree with defineRoutes, wire everything up with one <AppRouter>, and import every primitive (Link, Outlet, useNavigate, …) straight from the SDK. On top of that, the SDK adds what every Tempest app rewrites by hand: code-splitting with automatic retry on a stale chunk, declarative route guards, and a ready-made <Suspense> boundary. This page takes you from zero to a tree with nested layouts, lazy loading, and protected routes.
react-router is a peer dependency — install it in your app
npm install react-router
The SDK does not bundle react-router, because it holds React context: a copy nested under tempest-react-sdk/node_modules would be a different <Router> than yours, and every SDK hook would throw useNavigate() may be used only in the context of a <Router>. The accepted range is ^7 || ^8 — the surface re-exported here is identical across both majors, and neither uses react-router-dom (the DOM bindings ship inside react-router itself).
Why the SDK owns routing now
Before, each app built its own <Suspense>, wrote its own guard helper, and reinvented chunk retry. That bred divergence across Tempest apps and import paths scattered everywhere.
With the routing module you get:
- One import surface. Everything comes from
"tempest-react-sdk"— components, hooks, and the re-exported React Router primitives. Your app installsreact-router(the peer) but imports from a single place. - Declarative. You describe what the routes are (a tree of objects), not how to assemble them imperatively.
- Batteries included.
<AppRouter>already builds the router, the<Suspense>, and the<Routes>.defineRoutesgives you typing. Guards and lazy loading are fields on the route itself.
Re-exported primitives
The SDK re-exports React Router's declarative primitives so you import everything from one place: BrowserRouter, HashRouter, MemoryRouter, Routes, Route, Outlet, Navigate, Link, NavLink, useNavigate, useParams, useSearchParams, useLocation, useMatch, useRouteError, and redirect.
Importing straight from react-router also works and resolves to the same copy — the re-export is a convenience, not an isolation boundary. That is what makes incremental adoption possible: an app already importing react-router directly can start using the SDK without rewriting a single import.
Building the tree with defineRoutes
defineRoutes is an identity helper: it takes an array of TempestRouteObject and returns the same array, but fully typed. You get autocomplete and type-checking on the tree without annotating anything by hand.
// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { Home } from "@/pages/Home";
import { Login } from "@/pages/Login";
export const routes = defineRoutes([
{
path: "/",
element: <Home />,
},
{
path: "login",
element: <Login />,
},
]);
Each TempestRouteObject accepts:
| Field | Type | Description |
|---|---|---|
path |
string |
The route's URL segment. |
index |
boolean |
The parent's index route. Mutually exclusive with path. |
element |
ReactNode |
What to render when the route matches. |
lazy |
() => Promise<{ default: ComponentType }> |
Loads the component on demand (code-split). Automatic retry on a stale chunk. |
children |
TempestRouteObject[] |
Nested routes. |
guard |
boolean \| (() => boolean) |
When falsy, renders a redirect instead of the element. |
redirectTo |
string |
The guard's redirect target. Default "/". |
caseSensitive |
boolean |
Makes the path match case-sensitive. |
Then just hand the tree to <AppRouter>:
// App.tsx
import { AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";
export function App() {
return <AppRouter routes={routes} />;
}
<AppRouter> builds the router, the <Suspense>, and the <Routes> from the tree all on its own. Available props:
| Prop | Type | Default | Description |
|---|---|---|---|
routes |
TempestRouteObject[] |
— | The route tree (required). |
router |
"browser" \| "hash" \| "memory" |
"browser" |
Which router kind to use. |
basename |
string |
— | A path prefix shared by every route. |
initialEntries |
string[] |
— | Initial history — only for the "memory" router. |
fallback |
ReactNode |
— | The <Suspense> fallback shown while a lazy chunk loads. |
index vs path
Every route with children has to decide what to show when the URL matches the parent exactly. That is the index route: it has no path of its own, it just sets index: true.
// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { RootLayout } from "@/layouts/RootLayout";
import { Home } from "@/pages/Home";
import { About } from "@/pages/About";
export const routes = defineRoutes([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <Home /> },
{ path: "about", element: <About /> },
],
},
]);
Here, opening / renders <RootLayout> with <Home> inside it; opening /about renders <RootLayout> with <About> inside it.
index and path are mutually exclusive
A route is either an index (index: true) or it has a path, never both. Setting both is a configuration error.
Nested layouts with Outlet
The parent route renders the layout; the children render inside it through <Outlet>. The <Outlet> is the spot where React Router injects the matched child route.
// RootLayout.tsx
import { Link, Outlet } from "tempest-react-sdk";
export function RootLayout() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/dashboard">Dashboard</Link>
</nav>
<Outlet />
</div>
);
}
The <nav> stays visible across every child route; the <Outlet> swaps content as the URL changes. Use <Link> (also re-exported by the SDK) to navigate without reloading the page.
Programmatic navigation
To navigate from code (after a submit, for example), use useNavigate: const navigate = useNavigate(); navigate("/dashboard");.
Lazy loading + the Suspense fallback
Heavy pages don't need to land in the initial bundle. Use the lazy field to load the component on demand. It takes a function that does a dynamic import() and returns the module with a default.
// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { RootLayout } from "@/layouts/RootLayout";
import { Home } from "@/pages/Home";
export const routes = defineRoutes([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <Home /> },
{
path: "dashboard",
lazy: () => import("@/pages/Dashboard"),
},
],
},
]);
Since the chunk takes a moment to download, <AppRouter> wraps everything in a <Suspense>. Pass a fallback to show something while it loads:
// App.tsx
import { AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";
export function App() {
return <AppRouter routes={routes} fallback={<p>Loading…</p>} />;
}
Automatic retry on a stale chunk
When you ship a new deploy, chunk names change. A user who left a tab open for hours may request a chunk that no longer exists and hit an import error. The SDK's lazy detects that case and reloads automatically — you don't have to write that retry by hand.
Guards: protecting routes
Almost every app has routes only authenticated users should see. The guard field handles this right on the tree: when the value is falsy, <AppRouter> renders a redirect instead of the element.
Boolean form
If the condition is already available as a value, pass a boolean:
// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { Dashboard } from "@/pages/Dashboard";
const isAuthenticated = false;
export const routes = defineRoutes([
{
path: "dashboard",
element: <Dashboard />,
guard: isAuthenticated,
redirectTo: "/login",
},
]);
Function form (auth store)
In practice, the auth state lives in a store. Pass a function that reads the store at render time — that way the guard always sees the current value:
// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { useAuth } from "@/stores/auth"; // a createAuthStore-based store
import { RootLayout } from "@/layouts/RootLayout";
import { Home } from "@/pages/Home";
import { Login } from "@/pages/Login";
export const routes = defineRoutes([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <Home /> },
{ path: "login", element: <Login /> },
{
path: "dashboard",
lazy: () => import("@/pages/Dashboard"),
guard: () => useAuth.getState().isAuthenticated,
redirectTo: "/login",
},
],
},
]);
The guard runs on render — read your store via getState() or a hook
The guard function is evaluated during the route's render. So it must read the state at that moment: use useAuth.getState().isAuthenticated (an imperative read, outside React) or a selector hook inside a component. Don't capture the value once outside the function — you'd freeze the auth state at initial load.
When guard is falsy, the user is redirected to redirectTo (default "/"). In the example above, anyone not authenticated who tries to open /dashboard lands on /login.
The standalone RouteGuard
Sometimes you want to protect a piece of UI that isn't a route — or you just prefer the guard explicit in JSX. That's what <RouteGuard> is for: it renders the children when when is truthy, otherwise it emits a <Navigate>.
// ProtectedDashboard.tsx
import { RouteGuard } from "tempest-react-sdk";
import { useAuth } from "@/stores/auth";
import { Dashboard } from "@/pages/Dashboard";
export function ProtectedDashboard() {
const isAuthenticated = useAuth((state) => state.isAuthenticated);
return (
<RouteGuard when={isAuthenticated} redirectTo="/login" replace>
<Dashboard />
</RouteGuard>
);
}
<RouteGuard> props:
| Prop | Type | Default | Description |
|---|---|---|---|
when |
boolean |
— | Renders the children when truthy. |
redirectTo |
string |
"/" |
Target when when is false. |
replace |
boolean |
true |
Replaces the history entry instead of pushing. |
children |
ReactNode |
— | What to protect. |
Same state-reading rule
Here you're inside a React component, so read the store with the hook (useAuth((state) => state.isAuthenticated)) to re-render when auth changes — unlike the tree's guard, which uses getState() because it runs outside the hook lifecycle.
Choosing the router kind
<AppRouter> accepts three kinds via the router prop:
"browser"(default) — uses the History API; clean URLs (/dashboard). This is what you want in production."hash"— URLs with a#(/#/dashboard). Useful when the server can't fall back every route toindex.html."memory"— in-memory history, never touching the browser URL. Ideal for tests and non-DOM environments.
In tests, combine "memory" with initialEntries to start on a specific route:
// App.test.tsx
import { render, screen } from "@testing-library/react";
import { AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";
test("renders the dashboard route", () => {
render(
<AppRouter
routes={routes}
router="memory"
initialEntries={["/dashboard"]}
fallback={<p>Loading…</p>}
/>,
);
expect(screen.getByRole("heading", { name: /dashboard/i })).toBeInTheDocument();
});
initialEntries is memory-only
initialEntries sets the initial history and only makes sense with router="memory". On the browser/hash routers the initial route comes from the browser URL itself.
Recap
- The
routingmodule wraps declarative React Router v8 and gives you a single import surface — everything comes from"tempest-react-sdk". defineRoutestypes your tree;<AppRouter routes={...} />builds router +<Suspense>+<Routes>on its own.- Use
index: truefor a layout's default route andpathfor the rest — never both together. - Nested layouts render children in the
<Outlet>; navigate with<Link>. lazydoes code-splitting with automatic retry on a stale chunk; show the Suspensefallbackwhile it loads.- Protect routes with
guard(boolean or function) +redirectTo, or with<RouteGuard when={...}>in JSX. The guard runs on render — read the store viagetState()(in the tree) or via a hook (in a component). - Pick the router with the
routerprop:"browser"in production,"memory"+initialEntriesin tests.