
Modular Frontend First: The Cheaper Step Before Microfrontends
Microfrontends keep coming up in conference talks and architecture discussions, and at some companies they really do work. But the problem they solve is organizational, not technical. If your whole frontend team fits in one standup, you probably don’t have that problem yet. What you have is a codebase that keeps growing and needs some structure. Between “messy monolith” and “microfrontends” there is a cheaper option that doesn’t get talked about enough: a modular frontend.
What Microfrontends Actually Solve
Cam Jackson’s article on martinfowler.com defines micro frontends as independently deployable frontend applications composed into a greater whole. Two parts of that definition matter: independently deployable, and tied to team ownership. It’s Conway’s Law in the browser: your software ends up shaped like your org chart. Microfrontends make sense when several teams have to ship the same product without stepping on each other’s releases.
The backend experience applies here too. Martin Fowler’s “Monolith First” writeup notes that almost every successful microservice story started as a monolith that grew too big and was split up later, while systems designed as microservices from day one mostly ended up in trouble. The reason is what he calls the Microservice Premium: running a bunch of services costs you, and that cost only pays off once the system is complex enough. Every remote you add to a frontend is the same trade: one more pipeline, one more deploy target, one more thing that can break at 2am.
So the question to ask isn’t “are microfrontends good?” It’s “do we actually have the problem they solve?” For most small teams, the answer is no.
What Microfrontends Cost
Small teams pay the full price
The thresholds people cite for when microfrontends start paying off are surprisingly consistent. One team, or anything below five to seven frontend engineers: stick with a modular monolith and lazy-loaded routes. Below ten frontend developers, the coordination overhead that microfrontends remove hasn’t shown up yet. Below three frontend teams, you’re adding distributed-system complexity to a problem you could fix with a folder structure and a lint rule.
The whole point is that Team A can deploy without waiting for Team B. If you are Team A and Team B, there’s nothing to unblock, but you still pay: N build pipelines, N deployments, N monitoring dashboards, N rollback procedures, all for one team’s worth of output. And splitting doesn’t fix a messy codebase. A messy monolith becomes several messy microfrontends plus a messy integration layer. You’d have to clean up the boundaries first anyway, and doing that inside a single build is a lot cheaper.
Production and infrastructure costs
Every remote adds work that never goes away: its own CI pipeline, a URL that has to stay up, CORS and publicPath config, a versioned remoteEntry or manifest, and a shared-dependency contract (singletons, pinned versions) that all teams have to respect. One consultancy estimate puts the extra operational cost at 20–40% compared to a monolith, around $100k–$300k a year for organizations under thirty engineers, mostly duplicated infrastructure, per-service CI runs, and runtime glue. Deploy-related incidents roughly double too, because regressions now cross deployment boundaries.
There’s a runtime cost too, and your users are the ones paying it. A federated page loads the shell, then a remote manifest, then the remote’s chunks: a chain of requests a monolith simply doesn’t have. If the shared-dependency contract drifts, you ship two copies of React. COMSysto published measurements from one of their migrations: time-to-interactive went from 7.3s to about 14.5s, incremental builds from 5.42s to 13.16s, runtime memory up 18.8%, and that was a migration they considered successful because they got the deployment independence they wanted. Other writeups report a 100–300ms median TTI hit unless you add edge caching and deduplication on top.
Day-to-day development gets slower as well. A monolith cold-starts in seconds and a new hire has it running in minutes. A federated setup usually needs several dev servers, remote URLs wired up, and versions aligned before the first page renders. And version drift that would fail a monorepo build at CI time instead fails at runtime, in production, for whoever happened to load that remote.
None of this rules microfrontends out for a 40-engineer org with four autonomous teams. For a team of five, it’s a lot of cost for a problem you don’t have.
The Middle Path: A Modular Frontend
A modular frontend is a single deployable app with strict internal module boundaries, shared packages, and route-level code splitting. You get most of what microfrontends promise (clear ownership, small payloads) at build time instead of runtime. Fowler makes the same point about monoliths in general: nothing stops a monolith from having a clean modular structure. The risk is that boundaries erode over time. Someone imports an internal helper from the checkout module into the catalog page, and six months later nothing is separable anymore. So you enforce the boundaries with tooling, not with good intentions.
For a small team this setup gives you:
- One dependency tree. Everyone on the same framework version, because there’s only one lockfile. The “two versions of React” problem can’t happen.
- One pipeline, one rollback point. A bad deploy is one revert, not an investigation across three remotes.
- Atomic changes. Rename a shared component and fix all twelve call sites in the same PR.
- Type-checked sharing. Breaking changes fail in CI instead of in a user’s session.
- A small initial payload. Route-level splitting ships the first view plus cached vendor chunks, which is the performance win people often adopt federation for, without any of the runtime machinery.
Building One with Vite
1. One repo, one deployable, real packages
Use npm, pnpm, or Yarn workspaces. One app, feature folders with a single public surface, and shared packages underneath.
acme/
├── apps/
│ └── web/ # the only deployable
│ └── src/
│ ├── app/ # shell: router, auth, providers, error boundary
│ └── features/
│ ├── catalog/ # public surface: index.ts only
│ ├── checkout/
│ └── account/
├── packages/
│ ├── ui/ # design system primitives, no routing
│ └── data/ # API clients, auth helpers, shared types
└── package.json
{
"private": true,
"workspaces": ["apps/*", "packages/*"]
}
One lockfile, one resolved dependency graph. This is the same guarantee federated setups try to get with singleton contracts, except here it comes for free.
2. Enforce boundaries at build time
Each feature folder exposes exactly one entry, index.ts. Everything else is private. Then make cross-feature imports fail CI instead of relying on code review to catch them:
// eslint.config.js
export default [
{
files: ['apps/web/src/features/catalog/**/*'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/features/checkout/**', '**/features/account/**'],
message: 'Features may not import each other. Share code via packages/.',
},
],
},
],
},
},
];
Repeat the rule per feature, or generate the config from the folder list so you don’t maintain it by hand. If ESLint gets unwieldy, Nx’s enforce-module-boundaries does the same job with tag-based constraints. Throw in a CODEOWNERS file per feature and package, and you have clear ownership without separate deployments. That’s the organizational half of what microfrontends give you.
3. Split at the route, not at the org chart
Route-level code splitting is where the payload win comes from. Each feature becomes its own chunk, downloaded only when the user navigates there:
// apps/web/src/app/routes.tsx
import { lazy } from 'react';
export const routes = [
{ path: '/', Component: lazy(() => import('../features/catalog')) },
{ path: '/checkout', Component: lazy(() => import('../features/checkout')) },
{ path: '/account', Component: lazy(() => import('../features/account')) },
];
Wrap the router outlet in <Suspense> with a real fallback, and let Vite do the chunking. A small manualChunks hint keeps stable vendor code in its own cache line, so repeat visitors and future deploys reuse it:
// apps/web/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) return 'vendor';
},
},
},
},
});
With content-hashed filenames and immutable cache headers, a deploy only invalidates what actually changed. The initial payload is shell + first route + vendor chunks, and Vite’s 500 kB warning stops showing up.
4. Share UI through an internal package
packages/ui holds the primitives: no routing, no app state. Inside a workspace, the simplest setup that works is an internal package whose exports point straight at the source. Vite compiles it on the fly, so there’s no build-watch loop during development:
{
"name": "@acme/ui",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
Apps consume it like any other dependency: "@acme/ui": "workspace:*". If the package ever needs to leave the repo, say for a second repository or an external consumer, switch it to Vite’s library mode and ship built output instead:
// packages/ui/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [react(), dts()],
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
},
},
});
This is build-time composition: explicit, type-checked, and stack traces stay inside one repo. It’s the same sharing microfrontends do over the network, minus the network.
When Microfrontends Earn Their Keep
Most writeups on this converge on the same checklist, and every item has to pass:
- Multiple independent teams. Two or more teams that actually own separate domains: not folders in a repo, but business capabilities with their own backlogs.
- Diverging release cadence. Deploy history shows teams shipping on different trains, or one team’s compliance review blocking everyone else’s release.
- Separable domains. Most PRs touch a single area. If changes routinely cross areas, federation just moves your coupling to the network.
- Operational maturity. Someone owns the shell, the pipelines, observability, and the shared-dependency contract. If the shell “belongs to everyone”, it belongs to nobody, and it rots.
If two or more of these fail, stay modular and look again in a quarter or two. The teams that publicly walked back their microfrontend setup almost all tell the same story: they adopted it because the architecture sounded right, not because coordination pain was measurable.
And a modular frontend isn’t a detour you’ll have to undo later. Once your boundaries exist as feature folders with lint-enforced exports, route namespaces, and auth owned by the shell, pulling one feature out into a remote is a contained refactor, not a rewrite. The Vite ecosystem is ready when you get there: @module-federation/vite is the officially recommended plugin for running Module Federation on Vite, and the package boundaries you already drew tell you exactly what each remote should expose.
Final Thoughts
Microfrontends are a good tool for a specific situation, and a bad way to postpone decisions about your product and process. For a small team, the boring option is the right one: one repo, one deployable, boundaries the linter defends, routes split into lazy chunks, and builds that finish in seconds. You ship faster now, your users download less, and if the org chart ever changes enough to need independent deploys, the seams are already where they need to be.
Sources
- Martin Fowler — Monolith First
- Martin Fowler — Microservice Premium
- Martin Fowler — Conway’s Law
- James Lewis & Martin Fowler — Microservices
- Cam Jackson — Micro Frontends
- Micro-Frontend Architecture — When to Avoid Micro-Frontends for Small Teams
- Eltherion — Microfrontend Architecture: Split or Monolith Guide
- SnipShift — Micro Frontends: When They Make Sense (And When They Don’t)
- PkgPulse — Micro-Frontends in 2026: Solution or Over-Engineering?
- CoderCops — Micro-Frontends in 2026: When They Work, When They Don’t, and What We Actually Use
- Edi Graovac (COMSysto) — Microfrontend Migration: Real Data
- Steve Kinney — Monoliths, Microfrontends, Monorepos, and the Real Differences
- Vite — Building for Production (Library Mode) and Build Options
- Module Federation — @module-federation/vite
- Turborepo — Structuring a Repository / Internal Packages
- Nx — Enforce Module Boundaries
- React — lazy Reference
- npm — Workspaces