Our custom webpack loader worked well for years - it had been running quietly in the background, scanning the filesystem and turning files into routes without anyone questioning it. Then a new tool came along that we wanted to use, and the old one stood in the way.
The Problem: A Tool That Was Blocking Progress
Turbopack was released - a faster build tool for Next.js. There was just one issue: Turbopack doesn't support custom webpack loaders, and our routing setup was built entirely as one.
As long as routing depended on webpack, we couldn't use Turbopack. And staying on webpack had a real cost: dev server startup took ~30 seconds, hot reload took ~5 seconds. For a team of 6 developers shipping every day, that time adds up fast.
The loader had to be replaced. The question was what to replace it with.
What the Loader Was Actually Doing
The loader scanned the filesystem, found route files, and handed the results to the app - that part had nothing to do with webpack. The only piece tied to webpack was how it did this: intercepting a custom import syntax during the build.
So instead of finding a Turbopack-compatible webpack loader, the fix was to stop running this logic inside a bundler at all.
The Fix: A Script Instead of a Loader
Before: a webpack loader that intercepted a custom glob-import syntax during the build - tightly connected to webpack, and impossible to run without it.
After: a plain Node.js script using fast-glob, run once before dev or build starts. It writes its output to a .hub folder (not committed to git):
// .hub/crm/routes.ts (auto generated)
import * as module0 from '../../src/apps/crm/pages/customers.tsx';
import * as module1 from '../../src/apps/crm/pages/customers/[id].tsx';
export const modules = [
{ module: module0, path: '/customers' },
{ module: module1, path: '/customers/[id]' },
];Plain imports. A plain array. No special bundler behavior - just normal TypeScript that the bundler reads like any other file. This is exactly why it works with Turbopack.
A Second Problem, Solved Along the Way
Writing the new script created an opening I hadn't set out to use.
Before: adding a new sub-application meant opening a shared config file, adding an import for the new app, and adding it to a list. If two developers did this in the same week, they would likely create a merge conflict on that file.
After: the script already needed to scan src/apps/* to find pages. Making it register apps at the same time only required a small addition:
// .hub/apps.config.ts (auto generated)
import { config as module0 } from '../src/apps/crm/config.ts';
import { config as module1 } from '../src/apps/hrms/config.ts';
export const configs = [module0, module1];Now, creating a new sub-application only takes two steps: create a folder, and add a config.ts file inside it. The app is registered automatically. No shared file to edit, no merge conflicts.
Just Two Files to Create a New App
A config.ts file is not just a marker - it defines the app's label, its path, its required permission, and its navigation menu:
// src/apps/crm/config.ts
export const config: AppConfig = {
label: 'CRM',
path: '/crm',
access: 'crm',
menu: [
{ access: 'crm.customers', title: t('Customers'), to: '/crm/customers' },
{ access: 'crm.contracts', title: t('Contracts'), to: '/crm/contracts' },
],
};A page file follows the same filesystem routing convention as before. It re-exports its component and adds a small route config:
// src/apps/crm/pages/customers/[id].tsx
export { CustomerDetail as default } from '@apps/crm/components/customers';
export const config = {
title: t('Customer'),
access: 'crm.customers.view',
};One config.ts for the app. One file like this for each page. That's all it takes.
Results
- Sub-application registration automated - a capability that didn't exist before
- Dev server startup: ~30s → ~10s (67% faster)
- Hot reload: ~5s → near-instant
- ~30 minutes saved per day across the team, just from faster save/refresh cycles
This post is part of a series on scaling a frontend platform to 18 apps. Read the full overview.