This guide shows how to use @react-router-modules/runtime alongside React Router v7 framework mode (@react-router/dev/vite). The Vite plugin owns route discovery, type generation, HMR, and SSR/client splits; the registry owns everything else — shared dependencies, slots, navigation, zones, module lifecycle.
For a new React Router v7 app, this is the recommended path. The alternative (registry.resolve()) gives up a lot.
resolve() calls createBrowserRouter(routes) directly. It's the shortest path to a working app — one call and you're rendering — but you give up everything @react-router/dev/vite provides:
| Feature | Framework mode (resolveManifest) |
resolve() |
|---|---|---|
| HMR on route files | ✅ | ❌ — full reload |
Generated +types/route.ts (typed params/loaders) |
✅ | ❌ |
File-based route discovery (flatRoutes()) |
✅ | ❌ — imperative only |
| SSR / client-splits | ✅ | ❌ |
route() / index() / prefix() ergonomics |
✅ | ❌ |
| Library owns router creation1 | ❌ | ✅ |
Single-file wiring2 (app's full shape in one resolve() call) |
❌ | ✅ |
1 In framework mode, the library intentionally defers router creation to @react-router/dev/vite so it can keep the framework's route discovery, type generation, and dev-server features. The ❌ here is the tradeoff that unlocks everything above — not a regression.
2 The ❌ is similarly the inverse of the ✅s above: when file-based discovery and generated types are owned by the framework, route shape lives in routes.ts instead of a resolve() call. Most apps find this a net win; plugin-host apps that need every module's full shape in one place are the counter-case.
Pick resolve() only when the tradeoff genuinely favors it:
- Plugin-host apps where modules arrive at runtime (external bundles, remote federation) and you can't pre-declare them in
routes.ts. - CSR-only tools with no need for typed params, SSR, or route-file HMR — a tiny internal dashboard where the one-call wiring is the point.
- Legacy React Router setups (pre-framework-mode) that haven't migrated yet.
resolve()exists so you don't have to migrate to use this library.
For everything else — greenfield apps, anything shipping to real users, anything that benefits from typed routes — use resolveManifest(). The setup is a few more lines (one registry.ts, one root.tsx wrap, one routes.ts), and you keep the full React Router developer experience.
Read Getting started with React Router first for the library-agnostic tour of modules and slots. This document focuses on the integration seam.
resolveManifest() returns everything the registry can assemble without creating a router:
interface ResolvedManifest<TSlots> {
Providers: React.ComponentType<{ children: React.ReactNode }>;
routes: RouteObject[];
navigation: NavigationManifest;
slots: TSlots;
modules: readonly ModuleEntry[];
recalculateSlots: () => void;
}Providerswraps the full modular-react context stack — shared deps, navigation, slots, modules, recalculate signal, and anyproviders?option you passed. Place it around<Outlet />in your root layout.routesholds any routes modules contribute viacreateRoutes(). Empty array if no module declares routes — the common case when route shape lives inroutes.ts.- The rest matches
resolve().
No DataRouter is created. The framework Vite plugin bootstraps the router as usual.
resolveManifest() is idempotent — call it as many times as you want. The first call does the work (validation, onRegister hooks, route building, provider wiring) and caches the result. Later calls return the same manifest.
Options are honored only on the first call. Passing options on a subsequent call throws, so misconfiguration is loud instead of silently ignored. The recommended pattern is to resolve once in a shared module and import it from both routes.ts and root.tsx:
// app/registry.ts
import { createRegistry } from "@react-router-modules/runtime";
import portalModule from "./modules/portal";
import type { AppDependencies, AppSlots } from "./types";
import { I18nProvider } from "./providers/i18n";
import { authStore, httpClient } from "./services";
const registry = createRegistry<AppDependencies, AppSlots>({
stores: { auth: authStore },
services: { httpClient },
slots: { commands: [] },
});
registry.register(portalModule);
export const manifest = registry.resolveManifest({
providers: [I18nProvider],
});// app/root.tsx
import { Outlet } from "react-router"
import { manifest } from "./registry"
export default function Root() {
return (
<manifest.Providers>
<Outlet />
</manifest.Providers>
)
}// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
import { route, index } from "@react-router/dev/routes";
// Routes live in framework-mode primitives — the host owns route shape.
export default [
...(await flatRoutes({ ignoredRouteFiles: ["portal/**"] })),
route("portal", "routes/portal/layout.tsx", [
index("routes/portal/index.tsx"),
route(":workspaceId/requests", "routes/portal.workspace.requests.tsx"),
]),
] satisfies RouteConfig;Note that nothing in routes.ts references the registry. That's intentional — route shape is declared by the host using framework primitives; the module contributes navigation, slots, zones, and lifecycle, not route file paths.
A module can still return RouteObject[] from createRoutes() if it wants to. Those routes surface on manifest.routes and the host can mount them anywhere:
// app/routes.ts
import { manifest } from "./registry";
export default [
...(await flatRoutes()),
// Mount module-contributed routes under a catch-all the host owns:
route("plugins/*", "routes/plugins-root.tsx"),
] satisfies RouteConfig;// app/routes/plugins-root.tsx
import { useRoutes } from "react-router";
import { manifest } from "../registry";
export default function PluginsRoot() {
return useRoutes(manifest.routes);
}This is a useful pattern for plugin registries where external modules deliver routes at runtime. For modules shipped as part of the app, declare their shape in routes.ts instead — you keep generated types and HMR.
All of these move out of resolveManifest() options and into routes.ts / loaders:
resolve() option |
Framework-mode equivalent |
|---|---|
rootComponent |
app/root.tsx |
indexComponent |
index("routes/home.tsx") in routes.ts |
notFoundComponent |
route("*", "routes/not-found.tsx") in routes.ts |
authenticatedRoute |
layout("routes/_auth.tsx", [...]) in routes.ts with a loader for the guard |
shellRoutes |
Regular entries in routes.ts, outside the auth layout |
loader (root-level) |
loader export in app/root.tsx |
providers |
Still on resolveManifest({ providers }) — applied to the context tree |
slotFilter |
Still on resolveManifest({ slotFilter }) — applied to the dynamic-slots pipeline |
The two options that remain on resolveManifest() — providers and slotFilter — are about the context tree, not about routing. They stay because the Providers component owns them.
Concrete sketch of the framework-mode equivalent to authenticatedRoute on resolve():
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { layout, route, index } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default [
route("login", "routes/login.tsx"),
route("signup", "routes/signup.tsx"),
layout("routes/_auth.tsx", [
index("routes/home.tsx"),
// Module routes — file-based discovery for everything under the auth
// boundary. Module files contribute `handle` / `loader` / components
// as normal; the guard in `_auth.tsx` gates them all.
...(await flatRoutes({ rootDirectory: "routes/protected" })),
]),
] satisfies RouteConfig;// app/routes/_auth.tsx
import { Outlet, redirect } from "react-router";
import type { Route } from "./+types/_auth";
export async function loader({ request }: Route.LoaderArgs) {
const res = await fetch(new URL("/api/auth/session", request.url), {
headers: { cookie: request.headers.get("cookie") ?? "" },
});
if (!res.ok) throw redirect("/login");
const session = await res.json();
return { session };
}
export default function AuthLayout() {
// The loader already gated entry; everything nested under this route is
// authenticated. Replace <Outlet /> with your real shell layout if you
// had a Component on `authenticatedRoute`.
return <Outlet />;
}Under resolve(), the library built this tree for you. In framework mode, you declare it with layout() + a regular loader export — fewer library concepts, fully typed by the framework's generated +types/_auth.
resolveManifest() is fully testable without a router. The Providers component mounts the same context stack resolve() uses, so hooks like useNavigation, useSlots, useModules, and useStore work in tests that only render <Providers>:
import { render } from "@testing-library/react";
import { createRegistry } from "@react-router-modules/runtime";
import { useNavigation } from "@modular-react/react";
const registry = createRegistry({ stores: { auth: authStore }, services: { httpClient } });
registry.register(billingModule);
const { Providers } = registry.resolveManifest();
function Probe() {
const nav = useNavigation();
return (
<ul>
{nav.items.map((i) => (
<li key={i.label}>{i.label}</li>
))}
</ul>
);
}
const { getByText } = render(
<Providers>
<Probe />
</Providers>,
);
expect(getByText("Billing")).toBeInTheDocument();For tests that exercise real routing, use the existing @react-router-modules/testing utilities.
- Pick a mode early. The registry commits on first call — mixing
resolve()andresolveManifest()throws. Decide whether the library or the host owns the router before you start registering modules. - Resolve once. Put
resolveManifest()in a shared module (app/registry.tsor similar) and import the manifest from every callsite. The idempotency safety net exists so a slip-up is loud, not so you have a license to scatter calls. - Route shape in
routes.ts, everything else in modules. Modules still own navigation, slots, zones, lifecycle, and shared-deps requirements. They just stop declaring their owncreateRoutes()— route files live where the framework Vite plugin can find them. - Typed DI and startup validation. Both work identically:
createSharedHooks<AppDependencies>()andrequires: [...]do what they always did.Providersdelivers the dependency container; no router involvement.
- Framework-mode integration (TanStack Router & Start) — the TanStack-side equivalent, including SSR considerations.
- Getting started with React Router — for the library-owns-router path.
- Shell Patterns for React Router — module route shape, zones, auth guards.
useRouteData— non-component route metadata (headerVariant, page titles).- Navigation: typed labels, dynamic hrefs, meta — the full
NavigationItemgeneric surface.