modular-react: Modular Architecture for React Applications
- kibertoad
- Typescript , React
- June 21, 2026
Table of Contents
Introduction
Most React applications start simple and then accumulate features until adding one stops being simple. Adding a new screen is rarely just “add a screen.” You register a route in App.tsx, add a navigation entry to the sidebar config, register a command in the command palette, maybe wire up an auth guard, and update a feature-flag map somewhere. Every one of those files is shared with every other feature. Once a few teams are editing the same files, you get merge conflicts on every branch, nobody clearly owns anything, and removing a feature turns into a scavenger hunt for the fragments it left behind.
This was the motivation behind modular-react, a framework that sits on top of React Router or TanStack Router and lets you structure an application as a set of self-contained, independently-owned modules rather than one big shell that knows about everything. Each feature lives in a single modules/<name>/ directory that declares everything it contributes: routes, navigation items, command palette entries, UI zone contributions, and its dependencies. You add a feature by creating a module and registering it once. You delete a feature by deleting a directory and removing one line. The shell never has to know about any specific module; it just registers them, and the runtime wires everything together.
The Problem at Hand
Routers handle routing well. But a real application is more than a route table. A feature usually needs to:
- Register one or more routes
- Add itself to the navigation (sidebar, header, breadcrumbs)
- Contribute commands to a command palette
- Drop UI into shared zones (detail panels, toolbars, status bars)
- Respect cross-cutting concerns like auth and feature flags
- Declare dependencies on shared services or other features
In a router-only setup, none of that lives together. Routing is in one file, navigation in another, commands in a third, and the feature itself ends up scattered across the codebase. The shared files in the middle turn into a coordination bottleneck. One team and a small app can live with this. Several teams pushing features into the same shell cannot.
Prior Alternatives
Teams have tried a few approaches to this, each trading off differently on how much isolation you get, whether features can deploy independently, and what it costs to run:
| Approach | Isolation | Independent deploys | Main cost |
|---|---|---|---|
| Convention and discipline | None; the shared files stay shared | No | Holds up until someone is in a hurry, and nothing enforces the rules |
| Micro-frontends (Module Federation, single-spa, iframes) | Strong | Yes | Runtime integration complexity, version skew, duplicated dependencies, cross-app communication boilerplate, harder-to-debug builds |
| In-house plugin systems | Medium | No | Usually untyped, undocumented, and specific to one codebase, so the patterns never transfer |
| modular-react | Module-level, in one build | No | Isolation is by convention (enforceable with lint rules); no independent deploys |
Micro-frontends in particular are a heavy solution for what is usually an organizational problem rather than a deployment one. modular-react sits in between the rows above: the ownership and isolation of a plugin architecture, in a single application and a single build, with full TypeScript types end to end and no runtime federation machinery.
One build does not mean one eager bundle. Route components code-split with a lazy: () => import(...) in the module descriptor, so a module’s UI only ships when a user first reaches it, and in the library-owned router mode entire modules can be registered lazily with registerLazy (a LazyModuleDescriptor). Initial load stays proportional to what the user actually opens, not to the total number of modules.
The Vocabulary
modular-react has a small set of concepts. Naming them up front makes the code in the rest of this post easy to read.
Shell. The host application. It owns the top-level layout (where the sidebar, header, main content, and any panels live) and the router. The shell knows nothing about individual features; it registers modules and renders what they contribute.
Module. A single feature, expressed as a plain object via defineModule. It declares everything the feature contributes: its routes, navigation items, slot contributions, zone contributions, dependencies, and optional lifecycle hooks. A module is the unit of ownership: one team owns one modules/<name>/ directory.
Registry and manifest. The registry (createRegistry) is where the shell registers modules and provides app-wide dependencies. Resolving it (resolveManifest) produces the manifest: the composed route tree, the wrapped React providers, and the aggregated contributions the shell renders. The intended pattern is that features don’t import each other directly; they meet at the registry instead. The library doesn’t physically block a cross-module import, but because each module is a self-contained directory, that boundary is easy to enforce with a linter (see Enforcing module boundaries below).
Slots. Static contributions aggregated across every module, answering “what does the whole app offer”: the set of command-palette actions, the list of external systems. Modules declare slots; contributions that depend on runtime state (a role, a feature flag) use dynamicSlots and are refreshed with recalculateSlots.
Navigation. The typed navigation items each module contributes. The shell aggregates them into the sidebar, header, or breadcrumbs.
Zones. Named layout regions (a detail panel, a toolbar, a status bar) that the active route fills. Unlike slots, which are static and collected from all modules, zone contributions change on every navigation and come from whichever route is currently active.
Dependencies. What a module needs from the app: shared stores (Zustand state that changes at runtime), reactive services (external sources you subscribe to, like a websocket), and plain services (static utilities like an HTTP client). Modules list them with requires (throws if missing) or optionalRequires (warns if missing), and the registry provides them, fully typed.
The two router integrations. modular-react ships two integrations on a shared, router-agnostic foundation (@modular-react/*): @react-router-modules/* for React Router and @tanstack-react-modules/* for TanStack Router. They are peers; pick the one matching the router you already use. The concepts above are identical across both, and router-neutral code (shared stores, contracts, typed hooks) is written once.
Three optional layers build on this core, each covered in its own section below. Journeys compose several modules into a typed, serializable workflow that runs in sequence (profile, then plan, then payment), with each step a different module. Compositions arrange several modules onto one screen at once (an editor with a canvas, a source picker, and an inspector) rather than in sequence. Catalog is a build-time tool that scans a workspace for every module and journey and emits a static, searchable discovery portal.
The Core Model
With the vocabulary in place, the code reads directly. A module is a plain object that describes what it contributes. Here is a billing module:
import { defineModule } from "@react-router-modules/core";
export default defineModule<AppDependencies, AppSlots>({
id: "billing",
version: "1.0.0",
createRoutes: () => [{ path: "billing", Component: BillingPage }],
navigation: [{ label: "Billing", to: "/billing", group: "finance" }],
slots: {
commands: [{ id: "export", label: "Export Invoices", onSelect: exportInvoices }],
},
dynamicSlots: (deps) => ({
commands: deps.auth.user?.isAdmin
? [{ id: "void", label: "Void Invoice", onSelect: voidInvoice }]
: [],
}),
});
Everything that feature contributes is right there: its route, its navigation entry, its static command palette contributions, and, via dynamicSlots, contributions that depend on application state (here, only showing “Void Invoice” to admins).
The shell assembles modules through a typed registry. It never imports a BillingPage or knows that billing has an admin-only command; it just registers the module and resolves a manifest:
// app/registry.ts
import { createRegistry } from "@react-router-modules/runtime";
import billingModule from "./modules/billing";
const registry = createRegistry<AppDependencies, AppSlots>({
stores: { auth: authStore },
services: { httpClient },
});
registry.register(billingModule);
export const manifest = registry.resolveManifest();
// app/root.tsx
import { Outlet } from "react-router";
import { manifest } from "./registry";
export default () => <manifest.Providers><Outlet /></manifest.Providers>;
// Recompute dynamic slot contributions when state changes
authStore.subscribe(manifest.recalculateSlots);
The same pattern works on TanStack Router by swapping the package and mounting the manifest’s Providers in __root.tsx. Framework modes for both routers are supported (@react-router/dev/vite and TanStack Start with file-based routing), and legacy setups can drop down to registry.resolve() directly.
A few things make this more than just “objects in a folder”:
- Zone-based layouts (slots). The app defines named zones (sidebars, headers, command palettes, detail panels) and modules fill them. Only the contributions relevant to the current context appear in each zone, and
recalculateSlotskeeps dynamic contributions in sync with state changes. - Typed dependencies. Modules declare what stores and services they need; the registry provides them. The generics (
AppDependencies,AppSlots) flow through, so a module’sdynamicSlotscallback sees a fully typeddeps. - Real deletion. Because a module owns everything it contributes, removing it is removing a directory plus one
registercall. There is no central file to clean up.
You rarely write this object by hand. A CLI scaffolds modules, stores, and journeys for you, which the CLI section below covers in full.
Enforcing module boundaries
modular-react keeps features apart by convention: the registry is the meeting point, and one module is not meant to import another’s internals directly. The library doesn’t stop you from writing such an import, but because every module is a self-contained modules/<name>/ directory, the boundary maps onto a directory and is straightforward to enforce with a linter.
- ESLint has the most mature options:
no-restricted-imports,eslint-plugin-import’sno-restricted-pathsfor directory-to-directory dependency rules, oreslint-plugin-boundariesfor architectures defined by element type. - Biome supports it through
noRestrictedImports, which since v2.2.0 takes gitignore-style path patterns, plusnoPrivateImportsfor package-private visibility. - Oxlint supports it through
no-restricted-importswith path patterns. (A dedicatedno-restricted-pathsis still on its roadmap.)
Point any of these at your modules/ directory to fail the build when one module reaches into another instead of going through the registry.
Journeys: Multi-Module Workflows
Modules work well for features that stand on their own. The harder flows are the ones that run through several features in sequence: a customer onboarding flow that goes profile confirmation → plan selection → payment, where each step lives in a different module and state has to be carried through. Wiring these by hand usually means one module importing another, shared mutable state, and brittle conditional navigation, which is exactly the cross-module coupling modular-react is trying to avoid.
Journeys (@modular-react/journeys) handle exactly this case. A journey is a typed, serializable workflow that composes several modules into a stepped flow, while the modules themselves stay journey-unaware. A module just declares what input each entry point accepts and what outcomes (exits) it can emit; the journey owns the transitions between them and the shared state.
The roles are cleanly separated:
- A module declares entry points (with typed input) and exit points (with typed output), and stays decoupled from any journey logic.
- A journey owns the transitions between one module’s exit and the next module’s entry, and manages the accumulated state.
- The shell registers modules and journeys and mounts a
<JourneyOutlet>to render the current step.
A module exposes its contract like this:
export const profileExits = {
profileComplete: defineExit<{ customerId: string; hint: PlanHint }>(),
cancelled: defineExit(),
} as const;
export default defineModule({
id: "profile",
version: "1.0.0",
exitPoints: profileExits,
entryPoints: {
review: defineEntry({
component: ReviewProfile,
input: schema<{ customerId: string }>(),
}),
},
});
The component receives typed props with the input and an exit(name, output) callback. The journey then defines how those exits route forward:
export const customerOnboardingJourney = defineJourney<Modules, OnboardingState>()({
id: "customer-onboarding",
version: "1.0.0",
initialState: ({ customerId }: { customerId: string }) => ({
customerId,
hint: null,
selectedPlan: null,
}),
start: (s) => ({ module: "profile", entry: "review", input: { customerId: s.customerId } }),
transitions: {
profile: {
review: {
profileComplete: ({ output, state }) => ({
state: { ...state, hint: output.hint },
next: {
module: "plan",
entry: "choose",
input: { customerId: state.customerId, hint: output.hint },
},
}),
},
},
},
});
Transition handlers are pure, synchronous functions that return exactly one of three outcomes:
{ next: StepSpec }: advance to the next step{ complete: payload }: terminal success{ abort: reason }: terminal failure
readyToBuy: ({ output }) => ({
next: {
module: "billing",
entry: "collect",
input: { customerId: output.customerId, amount: output.amount },
},
}),
needsMoreDetails: ({ output }) => ({
abort: { reason: "profile-incomplete", missing: output.missing },
}),
Because the entire flow is described by typed entry/exit contracts and pure transitions, the boundaries between modules are checked end to end at compile time, and the journey’s state is serializable. Serializability is the part that pays off in practice: a user can reload mid-flow, or hand the flow off to another session, and pick up where they left off. You register a persistence adapter and the runtime handles the rest:
registry.registerJourney(customerOnboardingJourney, {
persistence: defineJourneyPersistence<OnboardingInput, OnboardingState>({
keyFor: ({ input }) => `journey:${input.customerId}:customer-onboarding`,
load: (k) => backend.loadJourney(k),
save: (k, b) => backend.saveJourney(k, b),
remove: (k) => backend.deleteJourney(k),
}),
});
Rendering a running journey is a single component, and starting one is a single typed call against a handle:
import { JourneyOutlet } from "@modular-react/journeys";
function TabContent({ tab }) {
return (
<JourneyOutlet
instanceId={tab.instanceId}
loadingFallback={<LoadingSpinner />}
onFinished={(outcome) => workspace.closeTab(tab.tabId)}
/>
);
}
// Elsewhere in the shell:
export const customerOnboardingHandle = defineJourneyHandle(customerOnboardingJourney);
const instanceId = manifest.journeys.start(customerOnboardingHandle, { customerId });
For flows where several modules need to be on screen at once rather than one-after-another (think an editor with a canvas, a source picker, and an inspector, each owned by a different team), there is a companion Compositions package, and the two interoperate: a composition zone can host a journey, and a journey step can render a composition.
Catalog: Build-Time Discovery
The Catalog tackles a different scaling problem. Once you have dozens of modules from many teams, “is there already a module that does X?” becomes a question nobody can answer quickly, and people end up rebuilding things that already exist.
The Catalog (@modular-react/catalog) is a build-time discovery portal. It scans your monorepo, harvests every module and journey descriptor, and emits a static, deployable, searchable HTML/JS/CSS portal, with no server-side runtime required. You can host it on S3 and CloudFront, GitHub Pages, or plain nginx.
Setup is a config file at the workspace root and one command:
import { defineCatalogConfig } from "@modular-react/catalog";
export default defineCatalogConfig({
out: "dist-catalog",
title: "Acme Portal",
roots: [
{ name: "modules", pattern: "packages/*/src/index.ts", resolver: "defaultExport" },
{ name: "journeys", pattern: "journeys/*/src/index.ts", resolver: "defaultExport" },
],
theme: { brandName: "Acme Portal", primaryColor: "#0E7C66" },
});
pnpm exec modular-react-catalog build
# preview locally:
pnpm exec modular-react-catalog serve dist-catalog
It produces more than a list of names. Modules can carry a typed meta block covering owning team, domain, tags, lifecycle status, and links to docs, source, Slack, or runbooks:
export default defineModule({
id: "billing",
version: "1.2.0",
meta: {
name: "Billing",
description: "Issues invoices and processes payments.",
ownerTeam: "billing-platform",
domain: "finance",
tags: ["payments", "invoicing"],
status: "stable",
links: { docs: "https://internal/docs/billing", slack: "https://acme.slack.com/archives/CXYZ" },
},
// …routes, slots, navigation, etc.
});
The portal turns that metadata into faceted browsing with pivot pages (/teams/$team, /domains/$domain, /tags/$tag), so you can see everything a team owns, or everything in the “finance” domain, in one click, with all filter state driven by the URL.
The most useful piece is the cross-reference graph. The harvester doesn’t just read descriptors; it statically parses journey source with oxc-parser and recovers transition destinations from the return statements of transition handlers. That lets the catalog pre-compute, at build time, indexes like:
journeysByModule: which journeys route through each modulejourneysByInvokedJourney: which journeys invoke each journeymoduleEntryUsageandmoduleExitUsage: which journeys route into a given module entry, and where each exit leads
So before you delete or change a module, the catalog already shows you which workflows depend on it. There is also an enrich hook for injecting org-specific metadata (for example, inferring ownerTeam from CODEOWNERS), and an extension API for adding custom detail-page tabs and filter facets.
The CLI
Most of the repetitive setup is handled by a CLI. There are two router-specific packages with the same set of commands: @react-router-modules/cli (binary react-router-modules) and @tanstack-react-modules/cli (binary tanstack-react-modules). Both are thin wrappers around @modular-react/cli-core, which holds the templates and the actual command implementations. You can run either through npx without installing it, or add it to the workspace devDependencies and call it with pnpm exec.
init scaffolds a new pnpm workspace:
npx @react-router-modules/cli init my-app --scope @myorg --module dashboard
--scope sets the npm scope for the workspace packages, and --module seeds an initial feature module (leave it off and the CLI prompts interactively).
create module generates a routable module under modules/<name>/:
npx @react-router-modules/cli create module billing --route billing --nav-group finance
--route sets the route path, and the optional --nav-group places the navigation entry into a group.
create store scaffolds a headless Zustand store, adds it to the AppDependencies interface, and registers it on the registry:
npx @react-router-modules/cli create store notifications
create journey generates a typed multi-module workflow under journeys/<name>/:
npx @react-router-modules/cli create journey customer-onboarding \
--modules dashboard,billing --persistence
--modules is the comma-separated list of modules to compose into the journey’s typed module map. --persistence also generates a localStorage adapter built on createWebStoragePersistence; from there you install journeysPlugin() on the registry and call registerJourney(...) in the shell.
One thing to keep in mind: run pnpm install after any scaffolding command. The generated module is its own workspace package, and until you install, it isn’t linked. Every command accepts --help for its full flag set, and the TanStack CLI mirrors all of the above with the tanstack-react-modules binary.
The catalog has its own separate binary, modular-react-catalog (build and serve), shown in the Catalog section above.
Examples
The snippets above are isolated on purpose. To see the concepts working together as entire apps, the repository ships several runnable example workspaces under examples/, most in both React Router and TanStack Router variants:
- integration-manager (React Router, TanStack Router): three sibling modules (Contentful, Strapi, GitHub) that all render the same generic screen with different columns, buttons, and feature flags. A good illustration of shared screens with per-module configuration.
- customer-onboarding-journey (React Router, TanStack Router): the multi-step enrollment flow used throughout this post, showing entry/exit contracts, branching, shared state, and persistence.
- editor-composition (React Router, TanStack Router): an editing interface where canvas, source picker, and inspector panels are each owned by different modules and coordinated through the Compositions package.
- remote-capabilities (React Router, TanStack Router): navigation and slots driven by backend-served JSON rather than hardcoded module source.
- active-project-manifest (React Router): the manifest switches dynamically when the user changes projects, with each project supplying its own configuration.
See examples/README.md for how to run each one.
Getting Started
Pick your router and scaffold a workspace:
# React Router
npx @react-router-modules/cli init my-app --scope @myorg --module dashboard
# TanStack Router
npx @tanstack-react-modules/cli init my-app --scope @myorg --module dashboard
cd my-app && pnpm install && pnpm dev
Adoption doesn’t have to start from init. In an existing router-only app you can add a registry, mount the manifest’s Providers at the root, and convert features into modules one at a time: the shell only registers whatever modules exist, so there’s no rewrite, and a single feature can move behind a module boundary while the rest of the app stays as it is.
The framework targets React 19, Node 22+, and pnpm workspaces. npm is not supported, because the scaffold relies on the workspace:* protocol that npm doesn’t implement; running npm install in the generated workspace fails. Yarn Berry and Bun both understand workspace:* and work with minor script edits after scaffolding. The router integration packages (@react-router-modules v2.x and @tanstack-react-modules v2.x) are stable, as are @modular-react/core, react, and the catalog and testing packages; the compositions package is earlier (v0.1.x) and may still have breaking changes.
modular-react is most worthwhile when an application has outgrown a single hand-maintained shell: plugin-style apps, teams contributing independent features in parallel, and large codebases where App.tsx and the sidebar config have become merge-conflict magnets. For a small single-team app, a plain router is still the right call.
The project is available on GitHub. Give it a try, and if you run into issues or have suggestions, please open an issue. Contributions are welcome!
