@m5nv/rr-builder
v2.2.0
Published
Fluent API for seamless route & navigation authoring experience in React Router v7 framework mode
Readme
Fluent Route Configuration
A tiny, fluent builder API to configure React Router v7 framework mode routes, for a seamless, unified route & navigation authoring experience.
Single‑source of truth for routes + navigation
1 · Motivation
Modern web applications benefit from colocating navigation metadata with route configuration—keeping icons, labels, permissions, and UI state together with the routes that use them. This creates a single source of truth that prevents the drift and inconsistencies that can plague growing applications.
React Router v7's framework mode gives us much needed relief from handicapped file-system based routing mechanisms. The one downside is that it discards extra route properties, making colocation impossible. Even if React Router preserved extra metadata, storing complex navigation data in optional route arguments becomes brittle and unmanageable as applications scale.
@m5nv/rr-builder solves this by providing a fluent, type-safe DSL for
authoring augmented routes with colocated navigational metadata. It provides a
tool to generate both React Router configuration and a runtime navigation API
that keeps everything perfectly synchronized. Now, you can author your routes
and all navigation metadata in one fluent, type-safe DSL, and use the
generated runtime‑safe module for your layout, menu, and navigational UI
components.
2 · Installation & Platform Support
# npm or pnpm (dev-only; nothing leaks to runtime)
npm install -D @m5nv/rr-builder
# or
pnpm add -D @m5nv/rr-builderPeer dependency: Make sure you have
@react-router/dev(v7) installed as a peer dependency:npm install @react-router/dev
3 · Quick Start & Example
// routes.ts
import {
build,
external,
index,
layout,
prefix,
route,
section,
} from "@m5nv/rr-builder";
export default build(
[
// Main section (default)
layout("project/layout.tsx").children(
route("overview", "project/overview.tsx").nav({
label: "Overview",
iconName: "ClipboardList",
}),
route("settings", "project/settings.tsx").nav({
label: "Settings",
iconName: "Settings",
id: "project-settings",
})
),
// Dedicated admin section
section("admin").children(
layout("admin/layout.tsx").children(
index("admin/dashboard.tsx").nav({
label: "Admin Dashboard",
iconName: "Shield",
}),
route("users", "admin/users.tsx").nav({
label: "User Management",
iconName: "Users",
}),
route("reports", "admin/reports.tsx").nav({
label: "Reports",
iconName: "BarChart",
id: "admin-reports",
})
)
),
// Documentation section with external links
section("docs").children(
external("https://docs.acme.dev").nav({
label: "API Docs",
iconName: "Book",
}),
external("https://guides.acme.dev").nav({
label: "User Guides",
iconName: "HelpCircle",
})
),
],
{
globalActions: [
{
id: "search",
label: "Search",
iconName: "Search",
sections: ["main", "admin"], // Available in main and admin sections
},
{
id: "feedback",
label: "Send Feedback",
iconName: "MessageSquare",
externalUrl: "https://feedback.acme.dev",
},
],
badgeTargets: ["project-settings", "admin-reports"],
}
);Generate the runtime helper and nav code:
# using npx (works everywhere)
npx rr-check routes.ts --out src/navigation.generated.tsNOTE: to natively handle
typescriptfiles you do need the latest version ofNode.js; or you could use typescript to convertroutes.tsto JS and then userr-check. More details below.
4 · Fluent Authoring API (with Type Safety)
@m5nv/rr-builder provides a type-safe, fluent builder DSL so only legal route/navigation structures are possible:
Route & Layout Builders
route(...)andlayout(...)return builders with.children()and.nav().- Both support all navigation metadata fields.
- Only
routeandlayoutmay have children.
Index & External Builders
index(...)andexternal(...)do not support.children()(type error).- Both support
.nav(), with all fields allowed (exceptexternal()auto-setsexternal: true). external(...)cannot be passed toprefix().
Section Builder
section(name)creates a navigation section for organizing routes into separate trees.- Sections can only be used at the top level of
build()- they cannot be nested within other routes or sections. - Section builders support
.nav()(without thesectionfield) and.children(). - A section's
.children()can accept any non-section builder (route,layout,index,external).
Prefix Builder
prefix(path, [builders...])nests several builders under a path segment.- Only
route,layout, andindexbuilders are allowed;external()andsection()are disallowed by type.
Meta Shape (passed to .nav())
The .nav() method lets you attach navigation and UI metadata directly to your
route definitions—making your navigation menus, sidebars, toolbars, and
breadcrumbs entirely data-driven, consistent, and easy to extend.
Each field serves a specific purpose for your navigation or UI layer:
// Navigation metadata for all builders (external flag is set automatically)
type NavMeta = {
/**
* Human‑friendly display name for this route/menu entry.
* Always provide a label for anything visible in navigation.
*/
label: string;
/**
* Icon to show beside label in menus and sidebars.
* Typically the string name from lucide-react or your icon set.
*/
iconName?: string;
/**
* Used to sort menu items within a group or section.
* Lower numbers appear first. Defaults to 0 if omitted.
*/
order?: number;
/**
* Used for grouping related routes into submenus, tab panels,
* or logical clusters within a section (e.g. "Settings", "Billing" tabs).
*/
group?: string;
/**
* List of keywords for search/filtering or for applying styles/logic
* to groups of routes (e.g. tags: ["admin", "beta"]).
*/
tags?: string[];
/**
* If true, hides this item from all navigation UI,
* but leaves the route itself active and linkable.
* Useful for "secret"/unlinked pages, or wizard steps.
*/
hidden?: boolean;
/**
* If true, menu highlighting should only occur on an exact path match,
* not for descendant routes. Useful for "Home" or main dashboard links.
*/
end?: boolean;
/**
* Arbitrary access control claims for runtime ABAC or RBAC.
* Can be used to hide/show nav entries based on permissions.
*/
abac?: string | string[];
/**
* List of per-route/contextual actions. These surface as menu items,
* action buttons, or FABs—e.g. "Create", "Export", "Refresh".
* UI code can render them as dropdowns, icon buttons, or toolbars.
*/
actions?: Array<{ id: string; label: string; iconName?: string }>;
/**
* Marks this as an external (off-site) link.
* This flag is set automatically by `external()` and cannot be set manually.
* UI code can use this to render with special icons or open in a new tab.
*/
external?: true; // Set automatically, not manually
};
// All builders use: Omit<NavMeta, 'external'> for their .nav() method
// The external flag is only set automatically by the external() builderGlobal Actions & Badge Targets
Beyond per-route metadata, you can define global actions and badge targets that apply across your entire application:
// In your build() call
export default build(
[
// ... your routes
],
{
globalActions: [
{
id: "search",
label: "Global Search",
iconName: "Search",
sections: ["main", "admin"], // Only show in these sections
},
{
id: "help",
label: "Help Center",
iconName: "HelpCircle",
externalUrl: "https://help.acme.dev", // External link
},
{
id: "create-project",
label: "New Project",
iconName: "Plus",
// No sections = available everywhere
},
],
badgeTargets: [
"notifications", // Route IDs that can show badges
"admin-users",
"reports-monthly",
"global-search", // Action IDs that can show badges
],
}
);Global Actions are app-wide commands that appear in your navigation UI regardless of the current route. They can:
- Be scoped to specific sections via the
sectionsarray - Link to external URLs via
externalUrl - Trigger application logic when clicked (handled by your UI code)
Badge Targets are route IDs or action IDs that can display notification badges, counters, or status indicators in your navigation UI.
Type Safety at a Glance
.children(...)only available onroute(),layout(), andsection().section()builders can only be used at the top level ofbuild().prefix()only acceptsroute,layout,indexbuilders (notexternalorsection)..children(...)onindex()orexternal()is a compile-time error.
Example Misuse (flagged for type errors)
index("home.tsx").children(route("foo", "foo.tsx")); // ❌ error
external("http://foo").children(route("foo", "foo.tsx")); // ❌ error
section("admin").children(section("nested")); // ❌ error
prefix("docs", [external("https://foo.dev")]); // ❌ error
prefix("api", [section("admin")]); // ❌ error
// This is also an error - sections can only be at top level
route("admin", "admin.tsx").children(
section("users") // ❌ error
);5 · Section-Based Navigation Architecture
The section() builder enables you to organize your application into distinct
navigation trees, perfect for:
- Monorepo applications: A single repo serving multiple webapps (docs, dashboard, ...)
- Role-based interfaces: Different navigation for different user types
- Feature modules: Isolate documentation, settings, or tool sections
- Progressive disclosure: Show simpler navigation to new users
Section Examples
export default build([
// Main application routes
layout("app/layout.tsx").children(
index("app/dashboard.tsx").nav({ label: "Dashboard" }),
route("projects", "app/projects.tsx").nav({ label: "Projects" })
),
// Admin section with its own navigation tree
section("admin")
.nav({ label: "Administration", iconName: "Shield" })
.children(
layout("admin/layout.tsx").children(
index("admin/overview.tsx").nav({ label: "Admin Overview" }),
route("users", "admin/users.tsx").nav({ label: "Users" }),
route("settings", "admin/settings.tsx").nav({ label: "Settings" })
)
),
// Documentation section
section("docs")
.nav({ label: "Documentation", iconName: "Book" })
.children(
external("https://api-docs.acme.dev").nav({ label: "API Reference" }),
external("https://guides.acme.dev").nav({ label: "User Guides" })
),
]);Your generated navigation API will provide section-aware methods:
// Get all available sections
const sections = nav.sections(); // ["main", "admin", "docs"]
// Get routes for a specific section
const adminRoutes = nav.routes("admin");
const mainRoutes = nav.routes("main"); // or nav.routes() for default6 · CLI & Code Generation (rr-check)
Check your routes, visualize trees, and generate navigation helpers for runtime.
Usage
npx rr-check <routes-file> [--print:<flags>] [--out <file>] [--watch]Flags:
| Flag | Effect |
| ----------------- | ---------------------------------------------------------- |
| --out <file> | Where to emit the navigation helper module |
| --force | Code-gen even if duplicate IDs or missing files are found |
| --print:<flags> | Comma-list: route-tree, nav-tree, include-id, include-path |
| --watch | Watch for file changes and regenerate automatically |
The generated module exports:
// Wire up RR
export function registerRouter(adapter: RouterAdapter): void;
// Data model and utility functions
export type NavigationApi = {
/** list of section names present in the forest of trees */
sections(): string[];
/* pure selectors – NO runtime context */
routes(section?: string): NavTreeNode[];
routesByTags(section: string, tags: string[]): NavTreeNode[];
routesByGroup(section: string, group: string): NavTreeNode[];
/* convenience hook that hydrates results returned by adapter.useMatches */
useHydratedMatches: <T = unknown>() => Array<{ handle: NavMeta }>;
/* static extras */
globalActions: GlobalActionSpec[];
badgeTargets: string[];
/* router adapter injected at factory time */
router: RouterAdapter;
};Typescript support
You can run the codegen/CLI tool with Deno for .ts route files:
deno run --unstable-sloppy-imports --allow-read ./node_modules/@m5nv/rr-builder/src/rr-check.js routes.tsOr, use the latest Node.js (> v23.6.0):
node ./node_modules/@m5nv/rr-builder/src/rr-check.js routes.tsTypical error and output
npx rr-check routes.ts --print:nav-tree,include-id
⚠️ Found 1 duplicate route ID
⚠️ Found 6 missing component files
...
├── Home(*!) [id: foo]
├── Settings(!) [id: routes/settings/page]
└── Overview(*!) [id: foo]
└── Annual(!) [id: routes/dashboard/reports/annual]7 · Using the Generated Navigation Module in Your UI
Register the Router Adapter
import { Link, matchPath, useLocation, useMatches } from "react-router";
import nav, { registerRouter } from "@/navigation.generated";
/// one-time registration
registerRouter({ Link, useLocation, useMatches, matchPath });Rendering Section-Based Navigation
function AppNavigation() {
const sections = nav.sections();
return (
<nav>
{sections.map((sectionName) => (
<section key={sectionName}>
<h3>{sectionName}</h3>
<NavigationSection section={sectionName} />
</section>
))}
</nav>
);
}
function NavigationSection({ section }) {
const items = nav.routes(section);
return (
<ul>
{items.map((item) => (
<li key={item.id}>
<nav.router.Link to={item.path}>
{item.iconName && <Icon name={item.iconName} />}
{item.label}
</nav.router.Link>
</li>
))}
</ul>
);
}Global Actions & Badges
function GlobalActionBar() {
const actions = nav.globalActions;
const currentSection = getCurrentSection(); // Your logic
const availableActions = actions.filter(
(action) => !action.sections || action.sections.includes(currentSection)
);
return (
<div className="action-bar">
{availableActions.map((action) => (
<ActionButton
key={action.id}
action={action}
hasBadge={nav.badgeTargets.includes(action.id)}
/>
))}
</div>
);
}
function ActionButton({ action, hasBadge }) {
const handleClick = () => {
if (action.externalUrl) {
window.open(action.externalUrl, "_blank");
} else {
// Dispatch your custom action
dispatchAction(action.id);
}
};
return (
<button onClick={handleClick} className="action-button">
{action.iconName && <Icon name={action.iconName} />}
{action.label}
{hasBadge && <Badge />}
</button>
);
}Dynamic Layouts with Hydrated Matches
import { Outlet } from "react-router";
import { useHydratedMatches } from "./navigation.generated";
export default function ContentLayout() {
const matches = useHydratedMatches();
const match = matches.at(-1);
let { label, iconName, actions } = match?.handle ?? {
label: "Unknown",
iconName: "Help",
actions: [],
};
return (
<article>
<header>
<h2>
<Icon name={iconName} /> {label}
</h2>
{actions && (
<div className="route-actions">
{actions.map((action) => (
<button key={action.id} onClick={() => handleAction(action.id)}>
{action.iconName && <Icon name={action.iconName} />}
{action.label}
</button>
))}
</div>
)}
</header>
<section>
<Outlet />
</section>
</article>
);
}8 · Concepts & Best Practices
Route vs Layout vs Section
route(): Owns a URL segment (path), can render its file and children.layout(): Pure wrapper with children, no path.section(): Top-level container that creates separate navigation trees.
Index Routes
- Defined with
index(file); rendered at the parent path.
Prefixing
- Use
prefix(path, builders[])to DRY up grouped path segments. - Cannot include
section()orexternal()builders.
Section Organization
- Use sections to create distinct areas of your application
- Each section gets its own navigation tree
- Perfect for admin areas, documentation, or feature modules
- Sections cannot be nested - they're always top-level
Shared Layouts vs Individual Sections
Use sharedLayout() when:
- Multiple sections share the same root layout (app shell, navigation, header)
- You want to eliminate repetitive layout nesting
- You have a common UI structure across different app areas
Use individual section() when:
- Each section has completely different layout requirements
- Sections are truly independent with no shared UI
- You need maximum flexibility in structure
// Shared layout - common app shell
...sharedLayout("layouts/app-shell.tsx", {
"dashboard": dashboardRoutes,
"admin": adminRoutes,
})
// vs Individual sections - different layouts
section("public").children(
layout("layouts/marketing.tsx").children(...)
),
section("app").children(
layout("layouts/authenticated.tsx").children(...)
),Actions & Global Actions
- Route actions: Contextual commands specific to a route (Create, Edit, Delete)
- Global actions: App-wide commands available across sections (Search, Help, New)
- Actions are defined in metadata but wired up by your UI code
- Use
badgeTargetsto show notifications on specific routes or actions
Unique IDs
If multiple routes use the same file, provide a unique ID:
index("Page.tsx", { id: "users-all" });
route("active", "Page.tsx", { id: "users-active" });Now you can switch on match.id in your component.
9 · Troubleshooting & Migration
- Duplicate IDs: Only the first is used in navigation trees; fix to avoid ambiguity.
- Missing files: Shown by the CLI; check spelling or ensure the file exists.
- Type errors in your editor: Confirm
.children()usage follows the constraints (no sections except at top level). - Section nesting errors: Remember sections can only be used at the top
level of
build(). - Switching from manual routes: Replace your raw array with a single
build([...])call and migrate metadata to.nav().
10 · Design notes
- Sections vs. Groups – section splits the full forest by tree; group clusters within a section.
- External links – Stay in your menu, but are filtered out before hitting RR config.
- No dev-only deps leak to runtime – Only the generated module is imported at runtime.
- Type-safety – The API statically prevents misuse.
- First-class codegen – We do codegen so your runtime bundle is tree-shakable and never ships builder helpers.
- Actions are declarative – Define them in route metadata, wire them up in UI code.
11 · Roadmap
- ID collision auto‑resolve – suggestion prompt instead of hard error.
- Docs site – interactive playground, recipes, FAQ.
- Validate iconName - iconName against icon libraries such
lucide‑reactat build time and create aicons.tsready for import and use by UI menus and layouts. - Action validation - Validate action IDs and provide TypeScript autocompletion.
License
© 2025 Million Views, LLC – Distributed under the MIT License. See LICENSE for details.
