Enterprise mobile apps rarely ship as a single linear flow. A multi-customer React Native monorepo often lets each customer enable different business modules — analytics, operations, planning, reporting, and others — and each module exposes its own set of screens: home, priorities, insights, playlists, and many nested detail views.
Module navigation often grows organically. Routes start living in several places at once: a shared module stack, tab navigators inside feature folders, and ad-hoc deep-link handlers. That works until it does not — deep links land on the wrong screen, QA automation cannot rely on stable route names, and every new module copies patterns from the last one with small inconsistencies.
ModuleConfig is not just a navigation cleanup artifact. It is the client-side customization layer where each module decides its screens, presentation, React providers, and app shell UI independently of every other module.
The problem with duplicated routes
Each module used to declare routes in a central navigator and inside its own tab structure. An operations module, for example, might register issueDetails, scoreboard, and metrics at the module stack level even though users only ever reached those screens through tabs inside Priorities or Insights.
That duplication caused three recurring issues:
- Deep links broke silently — a URL might resolve to a stack route that was never mounted in the current tab context.
- Lazy loading was unreliable — screens registered globally were imported even when the tab that owned them was never opened.
- Onboarding cost — every new engineer had to learn which routes were "real" entry points versus internal tab routes.
The fix was not a new navigation library. It was a contract: ModuleConfig lists the pages that are directly addressable; everything else stays inside tab navigators unless an external entry point requires otherwise.
ModuleConfig: a per-module contract, not just a route list
Each business module exports one typed ModuleConfig object. The platform resolves it at runtime via getModuleConfig(moduleCode) and wires navigation, app shell UI, context providers, and screen behavior from that single file — no central registry to edit when a module adds a screen or changes how it presents itself.
The shape is intentionally small and fully optional beyond navigation:
export type ModuleConfig = {
providers?: Array<React.JSXElementConstructor<{ children: React.ReactNode }>>;
navigation: { [page: string]: PageOptions };
localScreens?: Array<LocalPageOptions>;
theme?: Theme;
hideBottomTabs?: boolean;
hideFABMenu?: boolean;
};
Every field is module-specific. Two modules with the same content management system (CMS) page tree can look and behave completely differently depending on what each config declares.
Navigation: the public surface of each page
navigation maps a screen name (usually aligned with CMS path or manifest entries) to a PageOptions object:
export type PageOptions = {
component: RenderPage;
path?: string;
searchField?: boolean;
screenOptions?: NativeStackNavigationOptions;
hideBottomTabsForScreen?: boolean; // Android-only
mocked?: { id: string; absolutePath: string; pageType: string };
};
This is where most customization happens:
component— the React screen. Modules can wrap it with higher-order components, such as a defer-until-content-ready wrapper, or point at shared metric pages without copying navigator code.screenOptions— per-screen stack behavior: modal presentation, slide animations, header visibility. An operations module might useslide_from_rightfor issue detail modals; an assistant-style module might usefullScreenModalfor a conversational screen.searchField— opt-in search UI on priorities or insights tabs. Larger modules enable it; slim modules leave it off.hideBottomTabsForScreen— hide bottom tabs on specific drill-down screens, such as history or leaderboard pages, without disabling tabs for the whole module.mocked— stable CMS metadata for screens that exist in config before backend provisioning, which is useful for modal or assistant-style pages shipped ahead of CMS content.
The backend CMS still defines which pages a customer sees. ModuleConfig defines how those pages render and navigate on the client.
Module-level app shell UI and context
Beyond individual routes, the config controls shell behavior:
| Field | What it customizes | Typical use |
|---|---|---|
providers |
React context scoped to the module | Filter or feature context wrapped around the whole module tree |
theme |
Design token theme for the module | Override default tokens for a distinct module look |
hideBottomTabs |
Replace tab bar with sidebar or single-stack layout | Assistant-style modules with no tab UI |
hideFABMenu |
Landscape floating action menu | Enabled by default; modules can opt out |
localScreens |
Screens not in CMS sitemap but still navigable | Analysis modals, QR flows, client-only drill-downs |
localScreens deserve a callout: they are client-only routes with explicit path, absolutePath, and screenOptions. They merge with CMS child pages when building tab stacks — the navigation builder combines CMS-derived child screens and localScreens into one stack config. A module can even register a screen component shared from another module locally, reusing UI without duplicating the navigator.
What this looks like in practice
A minimal module declares only what it needs:
export const slimModuleConfig: ModuleConfig = {
navigation: {
[ModuleRoutes.home]: { component: HomePage },
[ModuleRoutes.priorities]: { component: PrioritiesPage },
[ModuleRoutes.detail]: {
component: DetailScreen,
screenOptions: { animation: "slide_from_right" },
},
},
};
A full-featured module uses nearly every per-page option — twenty-plus routes, defer-until-ready wrappers, search field flags, modal animations, per-screen tab hiding, and several localScreens for issue-analysis flows.
A module with a different UX model skips the tab pattern entirely:
export const assistantModuleConfig: ModuleConfig = {
navigation: {
[ModuleRoutes.home]: { component: HomeScreen },
[ModuleRoutes.dailyAssistant]: {
component: DailyAssistantPage,
mocked: {
id: "…",
absolutePath: "mobile/assistant/home/daily-assistant",
pageType: "CONTENT_PAGE",
},
screenOptions: { presentation: "fullScreenModal" },
},
},
localScreens: [
{
component: QrFlowScreen,
path: "qr-routine",
absolutePath: "mobile/assistant/home/qr-routine",
screenOptions: { headerShown: false },
},
],
theme: "default",
hideBottomTabs: true,
providers: [ModuleFilterProvider],
};
Same type, three different module styles — that is the point.
How the platform consumes it
The module navigator resolves config once per active module and applies it consistently:
providers→ a wrapper component injects module-scoped React context around the entire tree.hideBottomTabs→ chooses sidebar vs bottom tabs, or suppresses tab UI on phone.hideFABMenu→ controls the landscape floating navigator.navigation+localScreens→ grouping logic matches CMS pages to components and builds child stack configs.- Per-page flags → the bottom-tabs navigator reacts to
hideBottomTabsForScreenon the active route.
Adding a new business module means: create a {module}ModuleConfig file, register it in getModuleConfig, and tune only the fields that module needs. Shared navigator code stays unchanged unless a genuinely new pattern appears.
Rules that kept the refactor bounded
- Tab pages and directly addressable screens belong in ModuleConfig. Primary tabs always register. Drill-down screens register when deep links, bookmarks, or push notifications must open them without tab context.
- Stack-only shortcuts are removed unless product or deep linking explicitly requires them.
- Exceptions are documented — direct routes for modals or bookmark targets should carry a comment explaining why.
- Only declare what the module uses. Optional fields stay undefined; a slim module does not need
providersorlocalScreensif it does not use them. - Prefer module-level flags over navigator forks. If a module has no tabs, set
hideBottomTabs: true— do not maintain a separate navigator variant. - The mobile client owns routing; the backend owns auth and data. Module availability still comes from customer configuration, but once a module is enabled, navigation is entirely client-side and testable.
Migration approach
Rewriting every module in one PR was avoided. The sequence that worked:
- Pick one module with the most route duplication.
- Audit routes against actual user journeys and QA test flows.
- Remove unused stack entries and verify tab navigators still register internal screens.
- Run visual regression and deep-link smoke tests for that module.
- Repeat module by module.
Most removals were safe. The risky ones were bookmark and notification entry points — those needed explicit handling before deleting a stack route.
What improved
After the cleanup:
- Deep links became predictable — every registered route corresponds to a screen users can actually reach.
- Bundle size improved slightly — fewer orphaned screen imports at the module root.
- Tests stabilized — QA automation targets tab-level routes with consistent names across customer configurations.
- New modules had a template — copy an existing ModuleConfig shape instead of inventing navigation from scratch.
Conclusion
ModuleConfig solved two related problems: what pages exist per module and how each module presents itself. The important part was not the object shape itself, but the ownership boundary it created. Module teams can add screens, adjust presentation, register local flows, and choose app shell behavior without editing shared navigator internals.
If you maintain a large React Native app with feature modules, start by asking: can you draw a diagram of every directly addressable screen without duplicates? If not, the same problem probably exists. A typed per-module config is a practical way to make navigation explicit without rewriting the whole app.