Four years. One codebase. A platform that kept growing faster than the tools that managed it. This is the story of every infrastructure problem I ran into - and what I built to solve them.
The Routing Problem - Building Filesystem Routing Without Next.js
With 11 sub-applications and growing, manual routing had become a real bottleneck. Every new page required wiring an object like this:
// src/apps/crm/routes/routes.ts - one of many route files
[{
path: '/customers',
component: CustomersTable,
exact: true,
access: 'customers.view',
breadcrumb: registerPath('/customers', () => t('Customers')),
},
// ... 119 more routes
];Every new page meant editing this file. With 6 developers in parallel, merge conflicts were constant. Migrating to Next.js wasn't an option yet - so I built a custom webpack loader that made glob imports work in JavaScript, turning the filesystem into the router.
Drop a file in /pages, export a config - route, breadcrumb, access control, and prefetching all handled automatically. No shared file to conflict on.
Results
- Eliminated manual route configuration
- Merge conflicts on route files eliminated
- Faster new developers onboarding
๐ Deep dive: "Building Filesystem Routing in CRA".
Migrating CRA to Next.js, Incrementally
CRA was being deprecated. We had to move - but a direct migration to Next.js broke immediately: env variable conventions didn't match, SSR conflicted with four years of localStorage and window calls, and a Mapbox worker broke under the different bundler.
Rewriting everything at once across a 4,000-file codebase was too risky. So I spread the migration over a few months, making the codebase look more like Next.js one step at a time - patching env vars, forcing full CSR, migrating routing to a codegen approach - without ever breaking what was already working.
By the time the actual migration commit landed, the codebase was already so aligned that switching frameworks touched 18 files. Rolling back was just a single git revert.
Results
- 18 files changed in the final commit, across a 4,000-file codebase
- Zero breaking changes in production
- Instant client-side navigation preserved
- Full rollback possible with one commit revert
๐ Deep dive: "Migrating a 4-Year CRA Codebase to Next.js".
Rearchitecting i18n Layer
Every app kept its own locale files, so adding new text meant digging through nested keys and translating Spanish and Catalan by hand - every string, every app, every sprint.
The real problem was the keys themselves. t('crm.actions.confirm-delete') is unreadable, and a script can't translate something it can't understand. The fix: use the text shown to the user as the key instead - t('Confirm'). Instantly readable, and finally something a translation script could work with. With 10,000+ usages on the old structure, a codemod handled the migration in one pass.
Results
- Reduced bundle size by 2.4MB
- Significantly more readable codebase
- Duplicate translation keys eliminated, one source of truth per language
๐ Deep dive: "Rearchitecting i18n Layer".
Building an Automatic Translation Pipeline
With self-describing keys, automatic translation became possible. I built a script that scans for new strings and auto-fills missing translations, plus a CI linter that blocks any merge request with an untranslated key before it ships.
Results
- Zero manual translations
- Zero missing keys reaching production
๐ Deep dive: "Building an Automatic Translation + CI Linter".
Migrating 364 files to TailwindCSS in just a second
When the team decided to adopt TailwindCSS, we had 364 files using inline styles - style={{ display: 'flex', padding: '16px' }} - everywhere. Migrating manually meant matching every value to its Tailwind equivalent, merging classnames by hand, and doing it carefully enough not to introduce bugs. At a sustainable pace of 20โ30 files a day, that's two weeks of work.
I searched for an existing tool to automate it. Nothing existed. So I wrote one.
Using JSCodeShift, I built a codemod that transformed inline styles to Tailwind classes at the AST level. The basic case is straightforward - style={{ display: 'flex' }} becomes className="flex". The edge cases are where it gets interesting:
- Dynamic values:
style={{ display: props.display }}- can't be statically converted, left in place automatically - Template literals: backtick expressions have a different AST node signature than string literals and require separate parsing logic
- Merging with existing
className: ifclassNameis a variable rather than a string literal, you can't simply concatenate - the expression has to be preserved as-is - Style/className conflicts:
<div style={{ display: 'flex' }} className="block" />- inline style takes precedence in the browser, soblockmust be removed fromclassNameentirely
Results
- 364 files migrated in a single script execution
- ~2 weeks of manual work โ done in one run
- Eliminated style/className conflicts across the entire codebase
- Performance improved: No more inline style objects, no more unneeded re-renders
- Consistent output across the entire codebase, zero human error
MUI to TailwindCSS - A 12x Performance Gain
Migrating to React 19 surfaced a problem we knew was coming: MUI v4 was incompatible. It relied on makeStyles - a CSS-in-JS solution that injects styles into the DOM at runtime via <style> tags, triggering style recalculation on every render cycle. It also used React's legacy context API and several internal APIs removed in React 19.
I read through the MUI v4 source code component by component - tracing internal hooks, theming APIs, and the makeStyles runtime to understand exactly what each component was doing - then re-implemented them in TailwindCSS. The key architectural shift was moving from runtime style injection to static Tailwind utility classes resolved at build time. That's not a cosmetic change. It's a fundamentally different rendering model.
The most striking example: the Autocomplete component rendered 12x faster after the rewrite.
Scope
- 25 components fully re-implemented
- 40
makeStylesusages migrated to Tailwind classes - 110
useThemehooks replaced - 10
withStylesHOCs removed
All with no visual regressions in production.
Results
- 3xโ12x faster render times across re-implemented components
- 20% bundle size reduction from removing MUI entirely
From Webpack Loader to Codegen - Enabling Instant Reloads
Our custom webpack loader had become a ceiling. Turbopack was available in Next.js but couldn't be enabled - Turbopack doesn't support custom webpack loaders. The cost was real: dev server startup took ~30 seconds, hot reload took ~5 seconds. For a team of 6 shipping features every day, that compounds fast.
The solution was to extract the core idea out of webpack entirely. Instead of a custom loader using glob to discover route files at bundle time, I wrote a standalone codegen script that does the same thing - globs the filesystem, discovers route files, and generates a static .routes.autogen.ts file with all the imports. Same result, no webpack dependency. No loader, Turbopack enabled.
Results
- Dev server startup: ~30s โ ~10s (67% faster)
- Hot reload: ~5s โ near-instant
- Estimated ~30 minutes saved per day across the team - 6 developers, dozens of save-refresh cycles each, every single day
Zero-Config Sub-Application Creation
As the platform grew, a small friction kept accumulating: every new sub-application required manually editing a central apps.config.ts file to register it. With 6 developers working in parallel, this file became a merge conflict magnet - two developers starting new apps in the same sprint would inevitably collide on the same lines.
Creating a new sub-application now takes minutes:
- Create a directory:
/crm - Add
config.tsand a/pagesfolder - Done - discovered, routed, access managed, and running
The Changelog Generator
Deployments were happening regularly, but users had no way to know what had changed. Hardcoding release notes meant a redeploy just to update text - so I built something simpler: control the entire release note experience through a markdown file.
A changelog generator reads git logs and produces a structured markdown file that developers review and edit before publishing, rendered in-app by a custom Next.js markdown renderer.
---
version: '1.94.7'
date: '2026-01-31T13:45:57.491Z'
---
### feat(Cell Contracts): Roaming feature added [[tour:roaming-feature]]
### feat(Calendar): Users info shown in calendarThe detail worth highlighting is the custom markdown syntax: [[tour:roaming-feature]]. That token compiles to a Start Tour button in the UI, launching the corresponding interactive product tour at exactly the moment a user is reading about the feature. No separate onboarding flow. The changelog is the onboarding.
Results
- 509 versions shipped through this system
- 6 interactive tours linked directly from release notes
- Zero hardcoded release notes - no redeploys just to update text
Every problem in this post had the same shape: the platform grew faster than the tools that managed it. My job, consistently, was to close that gap - identify the friction, build the tool, and make sure the new system was meaningfully easier than what it replaced.
Good infrastructure means your team delivers faster without knowing why.
Questions, feedback, or want to talk architecture? Reach me at alirezawbhr@gmail.com