CRA doesn't support filesystem routing. Next.js does, but migrating a large production codebase wasn't an option yet. So I built the next best thing: a custom webpack loader that made glob imports work in JavaScript - turning the filesystem into the router.
The Problem
With 11 sub-applications, every new page required manually wiring a route object:
// src/apps/crm/routes/routes.ts
[{
path: '/customers',
component: CustomersTable,
exact: true,
access: 'customers.view',
breadcrumb: registerPath('/customers', () => {
return t('Customers');
}),
}];Route path, component import, permission string, breadcrumb registration - all by hand. Multiply that by 200+ pages across 11 apps and you get a system where adding a page means touching multiple files and hoping you didn't forget anything. New developers would spend their first few days just learning the wiring.
The real issue wasn't the boilerplate itself. It was that routing, access control, and breadcrumbs were defined in separate places, maintained separately, and got out of sync constantly.
Before vs. After
Adding a new page before this system meant editing a massive centralized route file - 120+ route definitions in a single file, each with its path, component, permission, and breadcrumb config:
// src/apps/crm/routes/routes.ts - one of many route files like this
export const routes = [
{
path: '/customers',
component: CustomersTable,
access: 'customers.view',
breadcrumb: registerPath('/customers', () => t('Customers')),
},
{
path: '/customers/:id',
component: CustomerDetail,
access: 'customers.view',
breadcrumb: registerPath('/customers/:id', () => t('Customer')),
},
{
path: '/customers/:id/contracts',
component: CustomerContracts,
access: 'contracts.view',
breadcrumb: registerPath('/customers/:id/contracts', () => t('Contracts')),
},
// ... 117 more routes
];Every new page meant adding a line to this file. With 6 developers working in parallel, merge conflicts on this file were constant - two people adding routes in the same sprint would inevitably collide. The file was long enough that reviewing changes in a merge request meant scrolling through 120+ lines of route definitions to find what actually changed.
After: one file per page, no centralized route config.
// src/apps/crm/pages/customers.tsx - this is everything
export default function CustomersTable() { ... }
export const config = {
title: t('Customers'),
access: 'customers.view',
};Drop the file in the right directory. The route, breadcrumb, and permission exist automatically. No shared file to conflict on.
The Idea: Glob Imports
JavaScript doesn't have glob imports. You can't write import modules from './pages/**/*.tsx' and get back all matching files. But webpack loaders can transform source code before it reaches the bundler - meaning I could intercept that import, resolve the glob at compile time, and replace it with real module imports.
That's exactly what I built. A custom webpack loader that turns this:
import modules from './pages/**/*.tsx';Into an array of discovered modules:
// What the loader generates at compile time
const modules = [
{ path: 'customers', module: () => import('./pages/customers.tsx') },
{ path: 'customers/[id]', module: () => import('./pages/customers/[id].tsx') },
{ path: 'customers/[id]/contracts', module: () => import('./pages/customers/[id]/contracts.tsx') },
// ... every .tsx file under /pages
];Each entry gives you the relative path (derived from the filesystem structure) and a reference to the module. The path becomes the route. Nested folders become nested routes. [id] segments become dynamic parameters - just like Next.js.
Because the loader runs in watch mode, adding a new file to /pages during development automatically triggers a recompile. The route appears without restarting the dev server.
The Convention: One Config Per Page
Discovering files is only half the problem. Each route also needs metadata - a page title for breadcrumbs, an access permission, and optionally prefetch keys for data loading. Previously these lived in a separate route config file far from the component itself.
The solution was a single exported config constant, colocated with the component:
// src/apps/crm/pages/customers.tsx
import { t } from 'i18next';
export default function CustomersTable() {
// ... component code
}
export const config = {
title: t('Customers'),
access: 'customers.view',
};The loader reads each discovered module's exports. If it finds a config export, it extracts title, access, and any other metadata. One file defines the component, the route (via its filesystem location), the breadcrumb (via title), and the permission gate (via access).
No separate route config. No breadcrumb registration file. No permission mapping. It's all in one place, and it's all enforced by convention.
Dynamic Routes and Nested Routing
Following the Next.js convention, square brackets in filenames denote dynamic segments. The folder structure is the route map:
src/apps/crm/pages/
├── customers.tsx -> /customers
├── customers/
│ ├── [id].tsx -> /customers/:id
│ ├── [id]/
│ │ ├── contracts.tsx -> /customers/:id/contracts
│ │ └── invoices.tsx -> /customers/:id/invoices
├── users.tsx -> /users
└── users/
└── [userId].tsx -> /users/:userIdThe loader parses [id] out of the filename and converts it to a React Router :id parameter. Nesting works naturally - the directory structure is the route hierarchy.
This meant the entire routing tree for an app was visible just by looking at the folder structure. No routing file to check, no config to cross-reference.
Link Prefetching
One of the more useful additions was data prefetching tied to the route config. Each page could declare which data keys to prefetch when a link to that page is hovered:
// src/apps/crm/pages/users/[userId].tsx
import { t } from 'i18next';
export { ViewUserPage as default } from '...';
export const config = {
title: t('User'),
access: 'user.view',
prefetchKeys: ({ userId }: { userId: number }) => [`/users/${userId}`],
};When a user hovers over a link to /users/42, the prefetch keys resolve to ['/users/42'] and the corresponding data is fetched in the background. By the time the user clicks, the data is already cached. This gave us near-instant page transitions without any manual prefetch wiring per link.
How the Loader Works
The loader operates at the webpack compilation level. Here's the high-level flow:
- Intercept - The loader matches import statements with glob patterns (e.g.
./pages/**/*.tsx) - Resolve - Using Node's
globmodule, it finds all matching files relative to the importing file's directory - Transform - It replaces the glob import with generated code that imports each discovered file individually
- Watch - In dev mode, webpack watches the glob directories. When a file is added or removed, the loader re-runs and the module graph updates
The critical detail is that this all happens at compile time. There's no runtime glob resolution, no dynamic require calls. The generated code is static imports that webpack can analyze, tree-shake, and code-split normally.
Access Control
With the access field in each page's config, permission gating became automatic. The route renderer checks the current user's permissions against config.access before rendering the component. If the user doesn't have customers.view, the route simply doesn't render - no separate guard component, no permission wrapper, no forgotten access check.
This also meant we could generate a complete permission map of the entire application by scanning all the config exports - useful for admin dashboards and role management.
What Went Wrong
This wouldn't be a real engineering post without the parts that broke.
The glob watcher was expensive. With 300+ page files across 18 apps, the glob resolution on every recompile wasn't free. It never became a blocking issue, but it was one of the reasons we eventually moved to a standalone codegen script - pre-generating a static route file is faster than re-globbing on every save.
TypeScript and linters couldn't handle the convention. import modules from './pages/**/*.tsx' is not valid JavaScript. TypeScript couldn't resolve the types. ESLint flagged it as an unresolvable import. Every tool in the chain was screaming at a syntax that only existed because our custom loader understood it. The fix required a combination of custom type declarations to satisfy TypeScript and ESLint rule overrides to stop the linter from flagging glob paths as broken imports. It worked, but it was a constant friction point - every new developer would see the red squiggles, assume something was broken, and need to be told "no, that's intentional." Not ideal.
Results
This system ran in production for over two years, growing from 11 to 18 sub-applications and from ~100 to 300+ pages. It was eventually replaced by a standalone codegen approach when we migrated to Next.js and needed Turbopack compatibility - but the core convention (filesystem paths as routes, colocated config for metadata) carried over directly.
- Manual route configuration eliminated entirely
- Breadcrumbs and access control wired automatically from colocated config
- New page creation reduced to: create a file, export a component and a config
- Link prefetching with zero per-link wiring
- Onboarding time for new developers dropped significantly - the filesystem was the documentation
This post is part of a series on scaling a frontend platform to 18 apps. Read the full overview.