How Sovereign React handles routing at scale without bloating the main bundle.
A typical React SPA puts route definitions and metadata together:
// The naive approach -- fine at 20 routes, breaks at 100+
import { Calculator, FileText, Clock, Image, Music, Lock } from 'lucide-react';
const routes = [
{
path: '/calculator',
component: lazy(() => import('./pages/CalculatorPage')),
name: 'Calculator',
icon: Calculator,
description: 'A full-featured calculator with history',
category: 'math',
tags: ['math', 'arithmetic', 'numbers'],
},
// ... 100+ more entries, each with an icon import
];At 20 features, this is fine. At 100+, you're importing dozens of icons and hundreds of lines of metadata into the main bundle -- data that isn't needed until the user visits the Home page or opens search.
Measured impact: In a production app with 100+ features, this pattern reduced the main bundle from 126KB to 89KB gzipped. That's 37KB of metadata and icon imports that users download on every page load but rarely need immediately.
Split routing data from display metadata into two files:
src/data/routeManifest.js -- Lightweight. Only what the router needs.
import { lazy } from 'react';
// Routing-only data. No icons, descriptions, or categories.
// Full metadata lives in registry.js (lazy-loaded by consumers).
export const routeManifest = [
{ id: 'counter', path: '/counter', component: lazy(() => import('../pages/features/CounterPage')) },
{ id: 'file-processor', path: '/file-processor', component: lazy(() => import('../pages/features/FileProcessorPage')) },
// Add new features here -- one line each
];
export function getRoutableFeatures() {
return routeManifest.filter(f => f.component !== null);
}Cost per entry: ~100 bytes. Even at 200 features, this file stays under 20KB.
src/data/registry.js -- Rich metadata. Icons, descriptions, categories. Only loaded when needed.
import { Calculator, FileText, Clock, Wrench } from 'lucide-react';
export const features = [
{
id: 'counter',
name: 'Counter',
description: 'A simple counter with persistent state',
icon: Calculator,
category: 'examples',
tags: ['counter', 'state', 'example'],
status: 'stable',
},
// Full metadata for display, search, and categorization
];
export function getCategories() { /* ... */ }
export function searchFeatures(query) { /* ... */ }This file is never imported eagerly. It's loaded by:
- The Home page (to display the feature grid)
- Search overlays (to search feature metadata)
- Any component that needs icons or descriptions
main.jsx
└── App.jsx
├── imports routeManifest.js (lightweight, ~5KB)
│ └── generates <Route> elements in a loop
│
└── lazy-loads Home.jsx
└── imports registry.js (heavy, 50-100KB+)
└── renders feature cards with icons
App.jsx only knows about paths and components. It doesn't know feature names, icons, or descriptions -- and it doesn't need to.
Routes are generated dynamically from the manifest -- no manual <Route> elements:
// App.jsx
import { getRoutableFeatures } from './data/routeManifest';
function App() {
const features = getRoutableFeatures();
return (
<Routes>
<Route path="/" element={<Home />} />
{features.map(f => (
<Route
key={f.id}
path={f.path}
element={<ErrorBoundary><f.component /></ErrorBoundary>}
/>
))}
<Route path="*" element={<NotFound />} />
</Routes>
);
}Adding a new feature never touches App.jsx. You add one line to routeManifest.js and one entry to registry.js. Routing, the home page, and search update automatically.
The two-tier split only works if nobody accidentally imports the heavy registry in an eagerly-loaded module. ESLint enforces this mechanically:
// eslint.config.js
'import-x/no-restricted-paths': ['error', {
zones: [
{
target: './src/App.jsx',
from: './src/data/registry.js',
message: 'registry.js must not be imported in App.jsx (bloats main bundle). Use routeManifest.js for routing data.',
},
{
target: './src/main.jsx',
from: './src/data/registry.js',
message: 'registry.js must not be imported in main.jsx. Use dynamic import() instead.',
},
{
target: './src/data/routeManifest.js',
from: './src/data/registry.js',
message: 'routeManifest.js must not import registry.js. Keep routing data separate from metadata.',
},
],
}]If someone writes import { features } from './registry' in App.jsx, the linter catches it with a clear error message explaining what to do instead.
See Bundle Discipline for the full ESLint configuration.
- Create the page:
src/pages/features/MyFeaturePage.jsx - Add to route manifest:
{ id: 'my-feature', path: '/my-feature', component: lazy(() => import('../pages/features/MyFeaturePage')) },
- Add to registry:
{ id: 'my-feature', name: 'My Feature', icon: Wrench, category: 'utilities', description: '...' },
That's it. Three files touched. Routing, home page, and search all update automatically.
The registry supports features that exist in metadata but aren't implemented yet:
// routeManifest.js -- no entry needed for planned features
// registry.js
{ id: 'future-thing', name: 'Future Thing', icon: Clock, category: 'planned', status: 'planned' },This lets you display planned features on the Home page (grayed out, "coming soon") without creating route entries or page components.
| Features | routeManifest.js | registry.js | Main bundle impact |
|---|---|---|---|
| 10 | ~1KB | ~5KB | Negligible |
| 50 | ~5KB | ~25KB | Minimal |
| 100 | ~10KB | ~50KB | registry stays lazy |
| 200 | ~20KB | ~100KB+ | registry stays lazy |
The key insight: routeManifest.js scales linearly at ~100 bytes/feature. registry.js scales linearly too, but its cost is deferred -- users only pay for it when they visit the Home page or use search.