MFE Folder Structure

Micro Frontend Folder Structure: Best Practices

How you organize your Micro Frontend project determines how easy it is to develop, build, deploy, and scale. A poor folder structure leads to tight coupling between MFEs, unclear ownership boundaries, and painful onboarding for new developers. A well-structured monorepo makes each MFE self-contained while sharing only what needs to be shared.

In this article, you'll learn how to structure a Micro Frontend monorepo — the apps/ directory for independent MFEs, the packages/ directory for shared code, key config files like turbo.json and root package.json, and the internal structure of each MFE app. Every example is based on real-world Micro Frontend projects built with React (opens in a new tab), Next.js (opens in a new tab), Webpack Module Federation (opens in a new tab), and Turborepo (opens in a new tab).

New to Micro Frontend? Start with What is Micro Frontend Architecture? for the foundations.

The Problem: Unorganized MFE Projects

When teams start building Micro Frontends, they often make one of these structural mistakes:

1. Flat File Dump

All MFE apps dumped in the root with no clear separation:

  • host-app/, product-app/, cart-app/ all at the root level
  • Shared code duplicated across every MFE
  • No workspace linking — shared packages published manually to npm

2. Over-Nested Directories

Deep nesting like src/modules/mfe/products/components/pages/ where finding a file requires clicking through 6 levels. Every directory contains one file.

3. No Shared Package Layer

Each MFE has its own copy of the Redux store, its own axios instance, its own UI components. When the auth token logic changes, you update it in 8 places.

4. Missing Infrastructure Co-location

Dockerfiles and Kubernetes manifests stored in a separate infrastructure/ repo. The MFE team writes code, but a different team manages deployment — leading to mismatches and delays.

A well-designed Micro Frontend folder structure solves all of these problems.

Monorepo vs Polyrepo

Before diving into folder structure, you need to choose between monorepo (all MFEs in one repository) and polyrepo (each MFE in its own repository).

Monorepo (Recommended)

All MFE applications and shared packages live in a single Git repository. npm workspaces (opens in a new tab) link shared packages locally — no npm publish needed.

Polyrepo

Each MFE lives in its own Git repository. Shared packages are published to an npm registry (private or public) and installed as regular dependencies.

Polyrepo layout
# Each MFE lives in its own Git repository
github.com/my-org/host-app           ← Shell application
github.com/my-org/products-mfe       ← Products MFE
github.com/my-org/orders-mfe         ← Orders MFE
github.com/my-org/cart-mfe            ← Cart MFE
github.com/my-org/shared-store        ← Published to npm registry
github.com/my-org/shared-api          ← Published to npm registry
github.com/my-org/shared-ui           ← Published to npm registry

Comparison

FeatureMonorepoPolyrepo
Shared packagesLinked locally via workspacesPublished to npm registry
Cross-MFE changesOne PR updates everythingMultiple PRs across repos
Dependency versionsEnforced at root levelEach repo manages its own
CI/CDOne pipeline, multiple targetsSeparate pipeline per repo
Code discoverabilityEasy — everything in one placeHarder — spread across repos
Team independenceMedium — share same repoHigh — fully independent repos
Best forTeams in the same organizationTeams in different organizations

Most production MFE projects use monorepo. The ability to link shared packages locally, make cross-MFE changes in a single PR, and enforce consistent tooling outweighs the minor reduction in team independence. Use polyrepo only when teams are in completely separate organizations or need fully isolated CI/CD pipelines.

Recommended Monorepo Structure

Here is the recommended Micro Frontend folder structure for a production monorepo:

my-mfe-platform/ (Monorepo)
my-mfe-platform/
├── apps/                          ← Independent MFE applications
│   ├── host/                      ← Shell app — loads all MFEs
│   ├── products/                  ← Products domain MFE
│   ├── orders/                    ← Orders domain MFE
│   ├── cart/                      ← Cart domain MFE
│   ├── auth/                      ← Authentication MFE
│   ├── account/                   ← User account MFE
│   ├── settings/                  ← Settings MFE
│   └── analytics/                 ← Analytics dashboard MFE
│
├── packages/                      ← Shared code used by all MFEs
│   ├── core/
│   │   ├── api/                   ← Shared axios instance + interceptors
│   │   └── store/                 ← Shared Redux store + slices
│   └── uicomponents/              ← Shared UI component library
│
├── package.json                   ← Root workspace config
├── turbo.json                     ← Turborepo build pipeline
├── package-lock.json
└── .gitignore

Micro Frontend monorepo folder structure diagram showing apps directory with independent MFE applications and packages directory with shared store, API, and UI components

The structure has two main directories:

  1. apps/ — Each subdirectory is an independent MFE application with its own build, dev server, Dockerfile, and Kubernetes manifests
  2. packages/ — Shared code consumed by all MFEs via workspace linking — Redux store, API layer, UI components

This same two-directory pattern works regardless of whether your Host is React or Next.js. Let's explore each directory in detail.

The apps/ Directory — Independent MFE Applications

Every subdirectory under apps/ is a fully self-contained application. Each MFE has its own package.json, its own build config, its own build output, and its own deployment configuration.

React MFE App Structure (Remote)

For MFE apps built with React + Webpack 5, here is the internal folder structure. This applies to every remote MFE — whether it's a products page, an analytics dashboard, or a settings panel:

apps/analytics/ (React Remote MFE)
apps/analytics/
├── src/
│   ├── components/                ← Domain-specific React components
│   │   ├── AnalyticsDashboard.jsx
│   │   ├── CustomerInsights.jsx
│   │   ├── Performance.jsx
│   │   └── SalesAnalytics.jsx
│   ├── App.jsx                    ← Root component
│   ├── bootstrap.js               ← Module Federation async entry
│   ├── index.js                   ← Entry point (imports bootstrap.js)
│   └── index.html                 ← HTML template
├── public/                        ← Static assets
├── docker/
│   └── nginx/                     ← Nginx config for production
├── k8s/
│   ├── deployment.yaml            ← Kubernetes Deployment
│   └── service.yaml               ← Kubernetes Service
├── package.json
├── webpack.config.js              ← Module Federation config
├── tailwind.config.js
├── postcss.config.js
├── Dockerfile
└── .gitignore

Key directories inside each React remote MFE:

DirectoryPurpose
src/components/Domain-specific React components — only this MFE's business logic
src/bootstrap.jsAsync entry point required by Module Federation
docker/nginx/Nginx config for serving the built static files in production
k8s/Kubernetes Deployment and Service manifests
webpack.config.jsModule Federation plugin with exposes and shared config
⚠️

Every React MFE needs bootstrap.js. Module Federation requires shared dependencies to be resolved asynchronously before the app renders. The index.js file calls import('./bootstrap.js') which triggers async loading of shared modules (React, Redux). Without this pattern, you get the error: "Shared module is not available for eager consumption."

The Bootstrap Pattern

apps/analytics/src/index.js + bootstrap.js
// apps/analytics/src/index.js
// Entry point — imports bootstrap asynchronously
import('./bootstrap.js')

// apps/analytics/src/bootstrap.js
// Async entry — needed for Module Federation shared dependencies
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import StoreProvider from '@myapp/store/StoreProvider'

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
  <StoreProvider>
    <App />
  </StoreProvider>
)

Local vs Production — Remote MFE Webpack Config

Local development and production configs are fundamentally different. Local uses HTTPS with localhost certificates, full localhost URLs, and disabled chunk splitting for fast rebuilds. Production uses relative paths behind a reverse proxy, deterministic module IDs, and vendor chunk splitting for caching.

apps/analytics/webpack.config.js (Local)
// webpack.config.js — Remote MFE (Local Development)
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const { ModuleFederationPlugin } = require('webpack').container
const dependencies = require('./package.json').dependencies

// HTTPS config for local development
const fs = require('fs')
const certPath = path.join(__dirname, '../../localhost.pem')
const keyPath = path.join(__dirname, '../../localhost-key.pem')
const httpsConfig = fs.existsSync(certPath) && fs.existsSync(keyPath)
  ? { type: 'https', options: { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) } }
  : { type: 'https', options: { requestCert: false, rejectUnauthorized: false } }

module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src', 'index.js'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    publicPath: 'https://localhost:PORT/',   // Full localhost URL
    clean: true,
  },
  devServer: {
    server: httpsConfig,                     // HTTPS required locally
    static: { directory: path.resolve(__dirname, 'dist') },
    port: PORT,
    historyApiFallback: true,
    headers: {
      'Access-Control-Allow-Origin': '*',    // CORS for cross-origin MFE loading
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'Analytics',
      filename: 'remoteEntry.js',
      exposes: {
        './AnalyticsDashboard': './src/components/AnalyticsDashboard.jsx',
        './CustomerInsights': './src/components/CustomerInsights.jsx',
        './SalesAnalytics': './src/components/SalesAnalytics.jsx',
      },
      shared: {
        react: { singleton: true, requiredVersion: dependencies.react },
        'react-dom': { singleton: true, requiredVersion: dependencies['react-dom'] },
        'react-router-dom': { singleton: true, requiredVersion: dependencies['react-router-dom'] },
        '@myapp/store': { singleton: true, requiredVersion: '0.0.1' },
      },
    }),
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({ template: './src/index.html' }),
  ],
  optimization: {
    splitChunks: false,   // Disabled in development
  },
}

Key differences between Local and Production for a Remote MFE:

SettingLocal DevelopmentProduction / Server
modedevelopmentproduction
publicPathhttps://localhost:PORT//analytics/ (relative path)
HTTPSYes — local SSL certificatesNo — handled by reverse proxy / Nginx
CORS headersRequired for cross-origin MFE loadingNot needed — same origin behind proxy
splitChunksDisabled (faster rebuilds)Enabled with vendor chunk caching
performance hintsNot configuredmaxEntrypointSize: 512000

Choosing a Host Application

The Host (shell) app loads all remote MFEs. You have two independent choices for your Host — React Host or Next.js Host. These are not competing options to compare — they serve different use cases entirely.

When to Use a React Host

Use a React + Webpack Host when:

  • Your application is fully client-rendered (no SEO needed)
  • You're building internal tools — admin dashboards, seller portals, management panels
  • All your remote MFEs are also React + Webpack
  • You need react-router-dom for client-side routing
apps/host/ (React Host)
apps/host/
├── src/
│   ├── components/                ← Host-specific React components
│   │   ├── Layout.jsx
│   │   ├── Header.jsx
│   │   ├── Footer.jsx
│   │   └── Sidebar.jsx
│   ├── App.jsx                    ← Root component + react-router-dom
│   ├── bootstrap.js               ← Module Federation async entry
│   ├── index.js                   ← Entry point (imports bootstrap.js)
│   └── index.html                 ← HTML template
├── public/                        ← Static assets
├── docker/
│   └── nginx/                     ← Nginx config for production
├── k8s/
│   ├── deployment.yaml            ← Kubernetes Deployment
│   └── service.yaml               ← Kubernetes Service
├── package.json
├── webpack.config.js              ← ModuleFederationPlugin + remotes
├── tailwind.config.js
├── postcss.config.js
├── Dockerfile
└── .gitignore

Local development and production configs are different for the React Host too. Local uses HTTPS with localhost certificates and points remotes to https://localhost:PORT/remoteEntry.js. Production uses relative paths like /products/remoteEntry.js served behind a reverse proxy.

apps/host/webpack.config.js (Local)
// webpack.config.js — React Host (Local Development)
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const { ModuleFederationPlugin } = require('webpack').container
const dependencies = require('./package.json').dependencies

// HTTPS config for local development
const fs = require('fs')
const certPath = path.join(__dirname, '../../localhost.pem')
const keyPath = path.join(__dirname, '../../localhost-key.pem')

const httpsConfig = fs.existsSync(certPath) && fs.existsSync(keyPath)
  ? { type: 'https', options: { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) } }
  : { type: 'https', options: { requestCert: false, rejectUnauthorized: false } }

module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src', 'index.js'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    publicPath: '/',
    chunkFilename: '[name].bundle.js',
  },
  devServer: {
    server: httpsConfig,
    static: { directory: path.resolve(__dirname, 'dist') },
    port: 4000,
    historyApiFallback: true,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'Main',
      filename: 'remoteEntry.js',
      remotes: {
        // Local — each remote MFE runs on its own localhost port
        Products: 'Products@https://localhost:PORT/remoteEntry.js',
        Orders: 'Orders@https://localhost:PORT/remoteEntry.js',
        Settings: 'Settings@https://localhost:PORT/remoteEntry.js',
        Analytics: 'Analytics@https://localhost:PORT/remoteEntry.js',
        // ... all remote MFEs on localhost
      },
      shared: {
        react: { singleton: true, requiredVersion: dependencies.react },
        'react-dom': { singleton: true, requiredVersion: dependencies['react-dom'] },
        'react-router-dom': { singleton: true, requiredVersion: dependencies['react-router-dom'] },
        '@reduxjs/toolkit': { singleton: true, requiredVersion: '^2.6.0' },
        'react-redux': { singleton: true, requiredVersion: '^9.2.0' },
        '@myapp/store': { singleton: true, requiredVersion: '0.0.1' },
      },
    }),
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({ template: './src/index.html' }),
  ],
  optimization: {
    splitChunks: false,   // Disabled in development for faster rebuilds
  },
}

When to Use a Next.js Host

Use a Next.js Host when:

  • Your application is customer-facing and needs SEO (server-side rendering)
  • You need image optimization, file-based routing, and API routes
  • You're building an e-commerce storefront, content site, or public-facing product
  • You need SSR for some pages and client-rendered MFEs for others
apps/host/ (Next.js Host)
apps/host/
├── pages/                         ← Next.js file-based routing
│   ├── index.tsx                  ← Home page
│   ├── product/
│   │   └── [id]/
│   │       └── index.tsx          ← Loads Products MFE
│   ├── bag/
│   │   └── index.tsx              ← Loads Cart MFE
│   ├── payment/
│   │   └── index.tsx              ← Loads Cart/Payment MFE
│   ├── orders/
│   │   ├── index.tsx              ← Loads Orders MFE
│   │   └── [orderId]/
│   │       └── index.tsx
│   ├── login/
│   │   └── index.tsx              ← Loads Auth MFE
│   ├── profile/
│   │   └── index.tsx              ← Loads Account MFE
│   ├── _app.tsx                   ← Global layout + StoreProvider
│   ├── _document.tsx              ← HTML document (lang="en")
│   └── api/                       ← Next.js API routes
│       └── revalidate.ts
├── components/                    ← Host-specific components
│   ├── Layout.tsx
│   ├── Header.tsx
│   └── Footer.tsx
├── public/                        ← Static files (favicon, manifest)
├── next.config.js                 ← NextFederationPlugin + remotes
├── package.json
├── tsconfig.json
└── Dockerfile

The Next.js Host uses NextFederationPlugin instead of ModuleFederationPlugin. It also uses next/dynamic with ssr: false to load remote MFEs on the client side.

The Next.js Host config is completely different from the React Host config. It uses next.config.js instead of webpack.config.js, NextFederationPlugin instead of ModuleFederationPlugin, and handles SSR/chunks paths differently for server vs client bundles.

apps/host/next.config.js (Local)
// next.config.js — Next.js Host (Local Development)
const { NextFederationPlugin } = require('@module-federation/nextjs-mf')

module.exports = {
  reactStrictMode: false,

  // Transpile shared workspace packages
  transpilePackages: ['@myapp/seo', '@myapp/store', '@myapp/api'],

  webpack(config, { isServer }) {
    if (!isServer) {
      config.resolve.fallback = { ...config.resolve.fallback, fs: false, stream: false, zlib: false }
    }

    config.plugins.push(
      new NextFederationPlugin({
        name: 'Main',
        filename: 'static/chunks/remoteEntry.js',
        remotes: {
          // Local — each remote runs on its own localhost port
          Auth: 'Auth@https://localhost:PORT/remoteEntry.js',
          Account: 'Account@https://localhost:PORT/remoteEntry.js',
          Cart: 'Cart@https://localhost:PORT/remoteEntry.js',
          // Next.js remotes need SSR/chunks path
          Products: 'Products@https://localhost:PORT/_next/static/' + (isServer ? 'ssr' : 'chunks') + '/remoteEntry.js',
        },
        shared: {
          react: { singleton: true, requiredVersion: false, eager: false },
          'react-dom': { singleton: true, requiredVersion: false, eager: false },
          'react-redux': { singleton: true, requiredVersion: false, eager: false },
          '@reduxjs/toolkit': { singleton: true, requiredVersion: false, eager: false },
          '@myapp/store': { singleton: true, strictVersion: true, requiredVersion: '1.0.0' },
          '@myapp/api': { singleton: true, strictVersion: true, requiredVersion: '1.0.0' },
          '@myapp/seo': { singleton: true, strictVersion: true, requiredVersion: '1.0.0' },
        },
        extraOptions: {
          exposePages: true,
          enableImageLoaderFix: true,
          enableUrlLoaderFix: true,
          automaticAsyncBoundary: true,
        },
      })
    )
    return config
  },
}

Key differences between Local and Production for the Next.js Host:

SettingLocal DevelopmentProduction / Server
Remote URLshttps://localhost:PORT/remoteEntry.jshttps://yourdomain.com/path/remoteEntry.js
outputNot set (default)standalone (optimized for Docker)
Next.js remote pathlocalhost:PORT/_next/static/chunks/remoteEntry.jsyourdomain.com/products/_next/static/chunks/remoteEntry.js
SSR pathlocalhost:PORT/_next/static/ssr/remoteEntry.jsyourdomain.com/products/_next/static/ssr/remoteEntry.js

The packages/ Directory — Shared Code

The packages/ directory contains code that is shared across all MFEs. These are not standalone applications — they are library packages consumed by MFE apps via npm workspace linking. The same shared packages work with both React Host and Next.js Host architectures.

Shared Redux Store

packages/core/store/
packages/core/store/
├── slices/
│   ├── authSlice.js               ← User authentication state
│   ├── cartSlice.js               ← Cart items, totals
│   └── settingsSlice.js           ← App-wide settings
├── store.js                       ← configureStore({ reducer: {...} })
├── hooks.js                       ← useAppDispatch, useAppSelector
├── StoreProvider.jsx              ← <Provider store={store}>
├── index.js                       ← Re-exports everything
└── package.json                   ← { "name": "@myapp/store" }

The shared store is the most critical shared package. It contains:

  • Redux slices for global state — authSlice (user session), cartSlice (cart items), settingsSlice (app preferences)
  • Typed hooksuseAppDispatch and useAppSelector that every MFE imports instead of plain useDispatch/useSelector
  • StoreProvider — a React component that wraps the app root with <Provider store={store}>

Every MFE wraps its root component with StoreProvider. Module Federation's singleton: true ensures only one store instance exists at runtime — so when the Products MFE dispatches addToCart, the Cart MFE re-renders automatically.

Shared API Layer

packages/core/api/
packages/core/api/
├── Products-Apis/                 ← Product-related endpoints
│   └── index.js
├── Order-Apis/                    ← Order-related endpoints
│   └── index.js
├── Inventory-Apis/                ← Inventory endpoints
│   └── index.js
├── Pricing-Apis/                  ← Pricing endpoints
│   └── index.js
├── Support-Apis/                  ← Support ticket endpoints
│   └── index.js
├── index.js                       ← Shared axios instance + interceptors
└── package.json                   ← { "name": "@myapp/api" }

The shared API layer contains:

  • A centralized axios instance with request interceptors (auto-attaches auth token) and response interceptors (handles 401 token refresh)
  • Domain-specific API modules — each MFE's API calls are organized by business domain (Products, Orders, Inventory, etc.)

Any MFE can call api.get('/products') and the auth token is automatically attached — no MFE manages tokens directly.

Shared UI Components

packages/uicomponents/
packages/uicomponents/
├── src/
│   ├── button/
│   │   └── Button.jsx             ← Reusable Button component
│   ├── card/
│   │   └── Card.jsx               ← Reusable Card component
│   ├── CustomToast.jsx            ← Toast notification component
│   └── index.js                   ← Re-exports all components
└── package.json                   ← { "name": "@myapp/uicomponents" }

The shared UI library contains reusable components used across multiple MFEs — buttons, cards, toast notifications, form inputs. These enforce visual consistency across the application.

Keep the shared UI library small. Only add components that are genuinely used by 3+ MFEs. If a component is only used by one MFE, keep it inside that MFE's src/components/ directory. Over-sharing leads to a bloated package that changes frequently and requires all MFEs to update.

Shared Package JSON Files

Each shared package needs its own package.json with a scoped name that MFEs reference in their imports:

packages/ — package.json files
// packages/core/store/package.json
{
  "name": "@myapp/store",
  "version": "1.0.0",
  "private": true,
  "main": "index.js",
  "dependencies": {
    "@reduxjs/toolkit": "^2.6.0",
    "react-redux": "^9.2.0"
  }
}

// packages/core/api/package.json
{
  "name": "@myapp/api",
  "version": "1.0.0",
  "private": true,
  "main": "index.js"
}

// packages/uicomponents/package.json
{
  "name": "@myapp/uicomponents",
  "version": "1.0.0",
  "private": true,
  "main": "src/index.js"
}

How Workspace Imports Work

With npm workspaces (opens in a new tab), any MFE app can import shared packages directly by name — no npm publish step, no version management headaches:

apps/analytics/src/components/SalesAnalytics.jsx
// Any MFE can import shared packages directly — no npm publish needed
// apps/analytics/src/components/SalesAnalytics.jsx
import { useAppDispatch, useAppSelector } from '@myapp/store'
import { addToCart } from '@myapp/store/cartSlice'
import api from '@myapp/api'
import { Button, Card } from '@myapp/uicomponents'

function SalesAnalytics() {
  const dispatch = useAppDispatch()
  const user = useAppSelector((state) => state.auth.user)

  const handleAddToCart = (product) => {
    dispatch(addToCart(product))
  }

  return (
    <div>
      <h1>Sales Analytics</h1>
      <Card>
        <Button onClick={() => handleAddToCart(product)}>Add to Cart</Button>
      </Card>
    </div>
  )
}

When you run npm install at the root, npm creates symlinks from node_modules/@myapp/store to packages/core/store/. Every MFE resolves imports through these symlinks — all pointing to the same source code.

Micro Frontend workspace linking diagram showing how npm workspaces create symlinks from node_modules to packages directory so all MFEs share the same source code

Root Config Files

Two files at the monorepo root orchestrate everything: package.json and turbo.json.

Root package.json — Workspace Definition

package.json (root)
{
  "name": "my-mfe-platform",
  "version": "1.0.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*",
    "packages/*/*"
  ],
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "start": "turbo run start",
    "lint": "turbo run lint",
    "test": "turbo run test",
    "clean": "turbo run clean && rm -rf node_modules"
  },
  "devDependencies": {
    "autoprefixer": "^10.4.21",
    "postcss": "^8.5.6",
    "tailwindcss": "^3.4.17",
    "turbo": "^1.10.16"
  },
  "engines": {
    "node": ">=16.0.0"
  },
  "packageManager": "npm@10.x"
}

Key fields:

FieldPurpose
"workspaces"Tells npm which directories contain packages — apps/* and packages/*/*
"private": truePrevents accidentally publishing the root package to npm
"scripts"All commands go through Turbo — turbo run dev starts all dev servers concurrently
"devDependencies"Shared dev tools — Tailwind, PostCSS, Turborepo
"engines"Enforces minimum Node.js version across the team

Notice packages/*/* in workspaces. This nested glob is needed because shared packages are nested under packages/core/ — so the workspace pattern must reach packages/core/store/, packages/core/api/, etc. Without the double wildcard, npm won't link these nested packages.

turbo.json — Build Pipeline

turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "start": {
      "dependsOn": ["build"]
    },
    "lint": {
      "outputs": []
    }
  }
}

Turborepo (opens in a new tab) understands the dependency graph between packages and apps. Here's what each pipeline task does:

TaskBehavior
buildBuilds shared packages first (^build means "build dependencies first"), then builds all MFE apps in parallel. Outputs are cached — unchanged apps skip rebuilding.
devStarts all dev servers concurrently with one command. persistent: true keeps them running. cache: false because dev servers don't produce cacheable output.
startRuns production servers after build completes. dependsOn: ["build"] ensures nothing starts until the build finishes.

Without Turborepo, you would need to open a separate terminal tab for each MFE dev server. With turbo run dev, all dev servers start with a single command.

Infrastructure Co-location

Each MFE app contains its own Docker and Kubernetes configuration:

FilePurpose
DockerfileMulti-stage build — installs deps, builds the app, copies output to Nginx
docker/nginx/Nginx config for serving static files with correct publicPath routing
k8s/deployment.yamlKubernetes Deployment with resource limits, health probes, and rolling updates
k8s/service.yamlKubernetes Service exposing the MFE pod on a ClusterIP

Why co-locate infrastructure with code? The team that owns the Products MFE also owns its Dockerfile, its Nginx config, and its Kubernetes manifests. When they add a new route or change the build output, they update the deployment config in the same PR. No handoff to a separate DevOps team.

This pattern aligns with the principle of team autonomy — one of the key benefits of Micro Frontend architecture.

Folder Structure Anti-Patterns

Anti-PatternWhy It's BadFix
Shared code duplicated in every MFEChanges require updating 8+ copiesMove to packages/ with workspace linking
One massive src/ with all domains mixedNo clear ownership, merge conflictsSplit into separate apps/ per domain
Deep nesting (6+ levels)Hard to navigate, find filesKeep directory depth under 4 levels
infrastructure/ repo separate from codeDeployment config drifts from codeCo-locate Dockerfile, k8s/ inside each app
All MFE configs in one shared webpack.config.jsCan't customize per MFE, tight couplingEach MFE owns its own webpack config
Storing environment variables in codeSecurity risk, environment-specificUse .env files (gitignored) or K8s ConfigMaps
Using same webpack config for local and serverBroken remote URLs, wrong optimizationsMaintain separate configs for each environment

Naming Conventions

Consistent naming makes the monorepo navigable for new developers:

ElementConventionExample
App directoryPascalCase or kebab-case (pick one)ProductManagement/ or product-management/
Shared package nameScoped @org/package@myapp/store, @myapp/api
Component filesPascalCase .jsx / .tsxAnalyticsDashboard.jsx
Slice filescamelCase + Slice suffixcartSlice.js, authSlice.js
API directoriesDomain + -Apis suffixProducts-Apis/, Order-Apis/
K8s manifestsDescriptive kebab-case .yamldeployment.yaml, service.yaml

Scaling the Structure

As your project grows from 4 MFEs to 12+, the folder structure scales naturally because the two-directory pattern stays the same.

Adding a New MFE

  1. Create a new directory under apps/ (e.g., apps/inventory/)
  2. Add package.json, webpack.config.js, src/, docker/, k8s/
  3. Configure Module Federation — add exposes in the new MFE, add a remote entry in the Host
  4. Run npm install at root to link workspace packages
  5. The new MFE immediately has access to @myapp/store, @myapp/api, and @myapp/uicomponents

Adding a New Shared Package

  1. Create the package directory (e.g., packages/core/routing/)
  2. Add package.json with a scoped name (e.g., @myapp/routing)
  3. Add it to Module Federation's shared config in all MFE webpack configs
  4. Run npm install at root — all apps can now import @myapp/routing

Micro Frontend monorepo scaling diagram showing how new MFE apps and shared packages are added following the same two-directory pattern

The key insight: single MFE rebuild stays fast regardless of how many MFEs exist. Only the changed MFE rebuilds — Turborepo cache handles the rest.

What's Next?

Now that you understand how to structure a Micro Frontend monorepo, the next step is understanding how Micro Frontends differ from Microservices — and how the two architectures work together in production systems.

← Back to MFE Communication Patterns

Continue to Micro Frontend vs Microservices →


Frequently Asked Questions

What is the best folder structure for a Micro Frontend project?

The recommended structure is a monorepo with two top-level directories: apps/ for independent MFE applications (each with its own webpack config, package.json, Dockerfile, and Kubernetes manifests) and packages/ for shared code (Redux store, API layer, UI components). Use npm workspaces with Turborepo to manage builds, and keep each MFE's internal structure self-contained with src/, docker/, and k8s/ directories.

Should Micro Frontends use a monorepo or polyrepo?

A monorepo is recommended for most teams because it simplifies shared package management, enforces consistent tooling, and makes cross-MFE changes easier. Polyrepo makes sense when teams are in different organizations or need completely independent CI/CD pipelines. With monorepo + npm workspaces, shared packages like the Redux store and API layer are linked locally without publishing to npm.

How do shared packages work in a Micro Frontend monorepo?

Shared packages live in a packages/ directory and are linked via npm workspaces. Each package has its own package.json with a scoped name like @myapp/store or @myapp/api. Any MFE in apps/ can import them directly — no npm publish step needed. At runtime, Module Federation loads shared packages as singletons so only one instance exists across all MFEs.

Why does each Micro Frontend need a bootstrap.js file?

Module Federation requires shared dependencies to be resolved asynchronously before the app renders. The bootstrap.js pattern splits the entry point into two files: index.js calls import('./bootstrap.js') which triggers async loading of shared modules (React, Redux). Without this, shared singletons fail to initialize and you get runtime errors like "Shared module is not available for eager consumption."

What is the difference between Local and Production webpack config in Micro Frontends?

Local development uses HTTPS with localhost certificates, full localhost URLs for remote entries (e.g., https://localhost:PORT/remoteEntry.js), development mode, and splitChunks disabled for faster rebuilds. Production uses production mode, relative paths for remote entries behind a reverse proxy (e.g., /analytics/remoteEntry.js), deterministic moduleIds, vendor chunk splitting for caching, and performance hints configured for bundle size limits.

When should I use a React Host vs a Next.js Host for Micro Frontends?

Use a React Host (Webpack) when your application is fully client-rendered, such as internal admin dashboards, seller portals, or management panels where SEO is not needed. Use a Next.js Host when your application is customer-facing and needs server-side rendering (SSR), SEO, image optimization, and file-based routing — such as e-commerce storefronts or content-heavy websites. Both use the same monorepo structure with apps/ and packages/ directories.