Building Micro-Frontends with Vue and Laravel as a Backend
Master micro-frontends with Vue.js and Laravel backend. Complete guide to module federation, deployment strategies, and cross-MFE communication.
Micro-frontends have transformed how teams build and scale frontend applications. Companies like Netflix, Spotify, and IKEA use this architecture to enable independent teams to ship features faster without stepping on each other's toes. But micro-frontends introduce significant complexity, and choosing the wrong approach can leave you worse off than a well-structured monolith.
After building large-scale applications with Vue.js and Laravel over the past 14 years, I have learned that micro-frontends solve organizational problems more than technical ones. This guide covers when this architecture makes sense, how to implement it with Vue 3 and Webpack Module Federation, and how Laravel serves as the ideal unified backend API.
What Are Micro-Frontends?
Micro-frontends extend the microservices philosophy to the frontend. Instead of a single monolithic frontend application, you decompose the user interface into smaller, independently deployable applications that work together seamlessly.
Conceptual Architecture:
┌─────────────────────────────────────────────────────────────┐
│ Container/Shell App │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Header │ │ Navigation │ │ Footer │ │
│ │ (Shared) │ │ (Shared) │ │ (Shared) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ Products MFE │ │ Cart MFE │ │ User MFE │ │
│ │ (Team Alpha) │ │ (Team Beta) │ │ (Team Core) │ │
│ │ Vue 3 + Vite │ │ Vue 3 + Vite │ │ Vue 3 + Vite│ │
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
│ │ │ │ │
└───────────┼────────────────────┼───────────────────┼─────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Laravel API Gateway │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /api/v1/ │ │ /api/v1/ │ │ /api/v1/ │ │
│ │ products │ │ cart │ │ users │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
Each micro-frontend (MFE) is owned by an independent team, can be built using different technology stacks, deployed independently, and even rendered separately on different schedules.
Why Companies Like Netflix Use Micro-Frontends
Netflix developed Lattice, an internal framework that provides an abstraction layer for React applications to leverage micro-frontends and remote federated modules. Their Revenue and Growth Tools team identified common design patterns scattered across various tools that were all duplicating efforts. They needed a way to consolidate these tools while maintaining the agility of independent deployment.
The key benefits that drive enterprise adoption include:
Rapid Feature Delivery: Teams ship new features without modifying unrelated modules. When the checkout team pushes an update, the product catalog remains unchanged.
Technology Evolution: You can upgrade libraries within a single module in isolation. One team can migrate to Vue 3.5 while others remain on 3.4 until they are ready.
Targeted Experiments: Run A/B tests per module without affecting the entire application.
Lean Downloads: Users load only the features they need. If someone never visits the admin dashboard, they never download that code.
Team Autonomy: Smaller, autonomous teams work on individual components, reducing complexity and inter-team dependencies.
When Micro-Frontends Make Sense (And When They Do Not)
Micro-frontends solve organizational problems at scale. Before adopting this architecture, honestly assess whether your situation warrants the complexity.
Micro-Frontends Are Worth It When:
- Multiple teams (3+) work on the same frontend: Each team can own their domain end-to-end
- Different parts evolve at different speeds: The marketing site updates weekly while the admin panel changes monthly
- You need technology flexibility: Some teams prefer Vue, others React, and you want to accommodate both
- Independent deployment is critical: Teams cannot wait for coordinated releases
- The application is genuinely large: 50+ routes, dozens of features, multiple domains
Micro-Frontends Are Overkill When:
- You have one small team: The coordination overhead exceeds the benefits
- The application is not that large: Under 30 routes, manageable complexity
- Teams are not actually independent: Everyone coordinates anyway
- You are hoping to solve technical debt: Micro-frontends do not fix bad architecture, they distribute it
- Performance is already a challenge: MFEs add overhead; start with optimization
My recommendation: If you are uncertain, start with a well-structured monolith using domain-driven design principles. You can extract micro-frontends later when organizational pressure demands it. Premature adoption creates unnecessary complexity.
Architecture Patterns for Micro-Frontends
Three primary patterns dominate the micro-frontend landscape. Each has tradeoffs that make them suitable for different scenarios.
1. Module Federation (Recommended)
Webpack 5's Module Federation allows applications to share code and dependencies at runtime without bundling everything into one huge app. This is the most elegant solution for Vue applications.
Advantages:
- True runtime integration
- Shared dependencies reduce bundle size
- Framework-agnostic capability
- Independent deployment per remote
- No server-side routing complexity
Disadvantages:
- Requires Webpack 5 or compatible bundler
- More complex configuration
- Shared dependency version management
- Debugging across remotes is challenging
2. Iframe-Based Integration
The simplest approach: embed each micro-frontend in an iframe.
Advantages:
- Complete isolation (CSS, JavaScript, dependencies)
- Any technology can be used
- Simple to implement
- No shared state concerns
Disadvantages:
- Performance overhead
- Poor SEO
- Navigation and deep linking challenges
- Communication requires postMessage
- Styling inconsistency across iframes
3. Web Components
Package each micro-frontend as a custom element.
Advantages:
- Framework-agnostic
- Native browser support
- Shadow DOM provides style isolation
- Standard-compliant
Disadvantages:
- Limited Vue ecosystem integration
- Complex state management
- Larger bundle sizes
- SSR challenges
For Vue 3 applications with Laravel backends, Module Federation provides the best balance of flexibility, performance, and developer experience.
Setting Up Webpack Module Federation with Vue 3
Let us build a practical micro-frontend architecture with a shell application (container) and two remote applications (products and cart).
Project Structure
micro-frontend-demo/
├── shell/ # Container application
│ ├── src/
│ │ ├── App.vue
│ │ ├── main.js
│ │ ├── bootstrap.js # Async boundary
│ │ └── router/
│ ├── webpack.config.js
│ └── package.json
├── products/ # Products micro-frontend
│ ├── src/
│ │ ├── App.vue
│ │ ├── main.js
│ │ ├── bootstrap.js
│ │ └── components/
│ ├── webpack.config.js
│ └── package.json
├── cart/ # Cart micro-frontend
│ ├── src/
│ │ ├── App.vue
│ │ ├── main.js
│ │ ├── bootstrap.js
│ │ └── components/
│ ├── webpack.config.js
│ └── package.json
└── shared/ # Shared utilities
├── src/
│ ├── auth/
│ ├── api/
│ └── components/
└── package.json
Shell Application Configuration
The shell (container) application orchestrates the micro-frontends and provides shared services.
// shell/webpack.config.js
const { VueLoaderPlugin } = require('vue-loader');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
publicPath: 'http://localhost:3000/',
},
devServer: {
port: 3000,
historyApiFallback: true,
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new ModuleFederationPlugin({
name: 'shell',
remotes: {
// Dynamic remote URLs for production flexibility
products: `products@${process.env.PRODUCTS_URL || 'http://localhost:3001'}/remoteEntry.js`,
cart: `cart@${process.env.CART_URL || 'http://localhost:3002'}/remoteEntry.js`,
},
shared: {
vue: {
singleton: true,
requiredVersion: '^3.4.0',
eager: true,
},
'vue-router': {
singleton: true,
requiredVersion: '^4.0.0',
},
pinia: {
singleton: true,
requiredVersion: '^2.0.0',
},
axios: {
singleton: true,
requiredVersion: '^1.0.0',
},
},
}),
],
};
The Bootstrap Pattern (Critical for Module Federation)
Module Federation requires an async boundary to load federated modules correctly. Create a bootstrap.js file that contains your application initialization code.
// shell/src/main.js
// This file should only import bootstrap asynchronously
import('./bootstrap');
// shell/src/bootstrap.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { setupAuth } from './auth';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
// Initialize authentication before mounting
setupAuth().then(() => {
app.mount('#app');
});
Remote Application Configuration (Products)
Each remote exposes components that the shell can consume.
// products/webpack.config.js
const { VueLoaderPlugin } = require('vue-loader');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
publicPath: 'http://localhost:3001/',
},
devServer: {
port: 3001,
historyApiFallback: true,
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader',
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: {
// Expose the product list component
'./ProductList': './src/components/ProductList.vue',
// Expose the product detail view
'./ProductDetail': './src/components/ProductDetail.vue',
// Expose routes for dynamic routing
'./routes': './src/router/routes.js',
},
shared: {
vue: {
singleton: true,
requiredVersion: '^3.4.0',
},
'vue-router': {
singleton: true,
requiredVersion: '^4.0.0',
},
pinia: {
singleton: true,
requiredVersion: '^2.0.0',
},
axios: {
singleton: true,
requiredVersion: '^1.0.0',
},
},
}),
],
};
Loading Remote Components in the Shell
<!-- shell/src/App.vue -->
<template>
<div id="app">
<AppHeader />
<nav class="main-nav">
<router-link to="/">Home</router-link>
<router-link to="/products">Products</router-link>
<router-link to="/cart">Cart</router-link>
</nav>
<main class="content">
<Suspense>
<template #default>
<router-view />
</template>
<template #fallback>
<LoadingSpinner message="Loading module..." />
</template>
</Suspense>
</main>
<AppFooter />
</div>
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
import AppHeader from './components/AppHeader.vue';
import AppFooter from './components/AppFooter.vue';
import LoadingSpinner from './components/LoadingSpinner.vue';
</script>
// shell/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { defineAsyncComponent } from 'vue';
// Error boundary component for failed remote loads
const RemoteLoadError = {
template: `
<div class="remote-error">
<h2>Module Unavailable</h2>
<p>The requested feature is temporarily unavailable.</p>
<button @click="retry">Retry</button>
</div>
`,
methods: {
retry() {
window.location.reload();
},
},
};
// Wrapper for loading remote components with error handling
function loadRemoteComponent(scope, module) {
return defineAsyncComponent({
loader: () => import(/* webpackIgnore: true */ `${scope}/${module}`),
loadingComponent: () => import('./components/LoadingSpinner.vue'),
errorComponent: RemoteLoadError,
delay: 200,
timeout: 10000,
});
}
const routes = [
{
path: '/',
component: () => import('./views/Home.vue'),
},
{
path: '/products',
component: loadRemoteComponent('products', 'ProductList'),
},
{
path: '/products/:id',
component: loadRemoteComponent('products', 'ProductDetail'),
},
{
path: '/cart',
component: loadRemoteComponent('cart', 'CartView'),
},
];
export default createRouter({
history: createWebHistory(),
routes,
});
Laravel as the Unified Backend API
Laravel excels as the unified backend for micro-frontend architectures. Its robust API capabilities, authentication options, and ecosystem make it ideal for serving multiple frontend applications from a single, well-organized backend.
API Architecture for Micro-Frontends
<?php
// routes/api.php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\ProductController;
use App\Http\Controllers\Api\V1\CartController;
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\AuthController;
// API versioning for backward compatibility
Route::prefix('v1')->group(function () {
// Public routes
Route::post('/auth/login', [AuthController::class, 'login']);
Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/refresh', [AuthController::class, 'refresh']);
// Products - consumed by products MFE
Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{product}', [ProductController::class, 'show']);
Route::get('/products/{product}/related', [ProductController::class, 'related']);
Route::get('/categories', [ProductController::class, 'categories']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
// User routes - consumed by user MFE
Route::get('/user', [UserController::class, 'current']);
Route::put('/user', [UserController::class, 'update']);
Route::get('/user/orders', [UserController::class, 'orders']);
// Cart routes - consumed by cart MFE
Route::get('/cart', [CartController::class, 'show']);
Route::post('/cart/items', [CartController::class, 'addItem']);
Route::put('/cart/items/{item}', [CartController::class, 'updateItem']);
Route::delete('/cart/items/{item}', [CartController::class, 'removeItem']);
Route::post('/cart/checkout', [CartController::class, 'checkout']);
// Admin routes (if applicable)
Route::middleware('role:admin')->prefix('admin')->group(function () {
Route::apiResource('products', ProductController::class)
->only(['store', 'update', 'destroy']);
});
});
});
For comprehensive guidance on structuring your Laravel API, see my article on Laravel API Development Best Practices. For type-safe API integration, check out my guide on TypeScript, Vue 3, and Laravel end-to-end type safety.
CORS Configuration for Multiple Origins
When micro-frontends run on different domains or ports during development, proper CORS configuration is essential.
<?php
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
// Allow all MFE origins
'allowed_origins' => array_filter([
env('SHELL_URL', 'http://localhost:3000'),
env('PRODUCTS_MFE_URL', 'http://localhost:3001'),
env('CART_MFE_URL', 'http://localhost:3002'),
env('USER_MFE_URL', 'http://localhost:3003'),
// Production origins
env('PRODUCTION_URL'),
]),
'allowed_origins_patterns' => [
// Match all subdomains in production
'#^https://.*\.yourdomain\.com$#',
],
'allowed_headers' => ['*'],
'exposed_headers' => [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-Request-Id',
],
'max_age' => 86400,
// Required for cookie-based auth
'supports_credentials' => true,
];
Centralized API Client for Micro-Frontends
Create a shared API client that all micro-frontends consume.
// shared/src/api/client.js
import axios from 'axios';
import { useAuthStore } from '../auth/store';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1',
withCredentials: true, // Required for Sanctum cookie auth
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
// Request interceptor for auth tokens
apiClient.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
// Add CSRF token for state-changing requests
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
if (csrfToken && ['post', 'put', 'patch', 'delete'].includes(config.method)) {
config.headers['X-CSRF-TOKEN'] = csrfToken;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const authStore = useAuthStore();
if (error.response?.status === 401) {
// Try to refresh the token
try {
await authStore.refreshToken();
// Retry the original request
return apiClient.request(error.config);
} catch (refreshError) {
// Redirect to login
authStore.logout();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default apiClient;
Authentication and Session Sharing Across Micro-Frontends
Authentication in micro-frontend architectures requires careful planning. The challenge is maintaining a single authentication state across independently deployed applications.
Strategy 1: Laravel Sanctum with Shared Cookies (Recommended)
Sanctum's cookie-based authentication works seamlessly when all micro-frontends share the same parent domain.
<?php
// app/Http/Controllers/Api/V1/AuthController.php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* SPA authentication using Sanctum cookies
*/
public function login(LoginRequest $request): JsonResponse
{
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
// Regenerate session to prevent fixation attacks
$request->session()->regenerate();
return response()->json([
'user' => $user->only(['id', 'name', 'email', 'avatar']),
'permissions' => $user->permissions->pluck('name'),
]);
}
/**
* Get current authenticated user
*/
public function user(Request $request): JsonResponse
{
return response()->json([
'user' => $request->user()->only(['id', 'name', 'email', 'avatar']),
'permissions' => $request->user()->permissions->pluck('name'),
]);
}
/**
* Logout and invalidate session
*/
public function logout(Request $request): JsonResponse
{
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json(['message' => 'Logged out successfully']);
}
}
Strategy 2: JWT with Central Auth Service
For micro-frontends on different domains, use JWT with a centralized authentication service.
// shared/src/auth/store.js
import { defineStore } from 'pinia';
import apiClient from '../api/client';
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('auth_token'),
refreshToken: localStorage.getItem('refresh_token'),
permissions: [],
isAuthenticated: false,
}),
getters: {
hasPermission: (state) => (permission) => {
return state.permissions.includes(permission);
},
},
actions: {
async login(credentials) {
try {
const response = await apiClient.post('/auth/login', credentials);
this.token = response.data.token;
this.refreshToken = response.data.refresh_token;
this.user = response.data.user;
this.permissions = response.data.permissions;
this.isAuthenticated = true;
localStorage.setItem('auth_token', this.token);
localStorage.setItem('refresh_token', this.refreshToken);
// Notify other micro-frontends of auth change
this.broadcastAuthChange();
return response.data;
} catch (error) {
this.clearAuth();
throw error;
}
},
async refreshToken() {
try {
const response = await apiClient.post('/auth/refresh', {
refresh_token: this.refreshToken,
});
this.token = response.data.token;
localStorage.setItem('auth_token', this.token);
return response.data;
} catch (error) {
this.clearAuth();
throw error;
}
},
logout() {
apiClient.post('/auth/logout').catch(() => {});
this.clearAuth();
this.broadcastAuthChange();
},
clearAuth() {
this.user = null;
this.token = null;
this.refreshToken = null;
this.permissions = [];
this.isAuthenticated = false;
localStorage.removeItem('auth_token');
localStorage.removeItem('refresh_token');
},
// Broadcast auth state changes to other micro-frontends
broadcastAuthChange() {
const event = new CustomEvent('auth:changed', {
detail: {
isAuthenticated: this.isAuthenticated,
user: this.user,
},
});
window.dispatchEvent(event);
// Also use localStorage event for cross-tab sync
localStorage.setItem('auth_event', JSON.stringify({
type: this.isAuthenticated ? 'login' : 'logout',
timestamp: Date.now(),
}));
},
// Listen for auth changes from other micro-frontends
initAuthListener() {
window.addEventListener('auth:changed', (event) => {
if (event.detail.isAuthenticated !== this.isAuthenticated) {
if (event.detail.isAuthenticated) {
this.user = event.detail.user;
this.isAuthenticated = true;
} else {
this.clearAuth();
}
}
});
// Cross-tab synchronization
window.addEventListener('storage', (event) => {
if (event.key === 'auth_event') {
const data = JSON.parse(event.newValue);
if (data.type === 'logout' && this.isAuthenticated) {
this.clearAuth();
window.location.href = '/login';
}
}
});
},
},
});
CSRF Protection for Stateful Authentication
When using Sanctum's cookie-based authentication, CSRF protection is essential.
// shared/src/auth/csrf.js
import apiClient from '../api/client';
/**
* Initialize CSRF cookie before making authenticated requests
* Required for Sanctum SPA authentication
*/
export async function initializeCsrf() {
await apiClient.get('/sanctum/csrf-cookie', {
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
});
}
/**
* Setup auth for the application
*/
export async function setupAuth() {
// Initialize CSRF token
await initializeCsrf();
// Check if user is already authenticated
const authStore = useAuthStore();
try {
const response = await apiClient.get('/user');
authStore.user = response.data.user;
authStore.permissions = response.data.permissions;
authStore.isAuthenticated = true;
} catch (error) {
// User is not authenticated, which is fine
authStore.clearAuth();
}
// Initialize listeners for auth changes
authStore.initAuthListener();
}
Communication Between Micro-Frontends
Micro-frontends need to communicate without creating tight coupling. Several patterns enable this while maintaining independence.
Pattern 1: Custom Events (Recommended for Simple Cases)
Custom events provide loose coupling through the browser's native event system.
// shared/src/events/eventBus.js
/**
* Micro-frontend event bus using CustomEvents
* Framework-agnostic communication layer
*/
class EventBus {
constructor() {
this.events = new Map();
}
/**
* Subscribe to an event
*/
on(eventName, callback) {
if (!this.events.has(eventName)) {
this.events.set(eventName, new Set());
}
this.events.get(eventName).add(callback);
// Also listen for CustomEvents for cross-MFE communication
const handler = (e) => callback(e.detail);
window.addEventListener(`mfe:${eventName}`, handler);
// Return unsubscribe function
return () => {
this.events.get(eventName)?.delete(callback);
window.removeEventListener(`mfe:${eventName}`, handler);
};
}
/**
* Emit an event
*/
emit(eventName, data) {
// Notify local subscribers
this.events.get(eventName)?.forEach((callback) => callback(data));
// Dispatch CustomEvent for other MFEs
window.dispatchEvent(
new CustomEvent(`mfe:${eventName}`, { detail: data })
);
}
/**
* Emit once and remove listener after first trigger
*/
once(eventName, callback) {
const unsubscribe = this.on(eventName, (data) => {
callback(data);
unsubscribe();
});
}
}
export const eventBus = new EventBus();
// Usage in Products MFE
import { eventBus } from '@shared/events/eventBus';
// When user adds item to cart
function addToCart(product, quantity) {
eventBus.emit('cart:item-added', {
productId: product.id,
name: product.name,
price: product.price,
quantity,
});
}
// Usage in Cart MFE
import { eventBus } from '@shared/events/eventBus';
import { useCartStore } from './stores/cart';
// Listen for items added from other MFEs
const cartStore = useCartStore();
eventBus.on('cart:item-added', (item) => {
cartStore.addItem(item);
});
// Clean up on unmount
onUnmounted(() => {
eventBus.off('cart:item-added');
});
Pattern 2: Shared State with Pinia
For complex state that needs to be synchronized, expose a shared Pinia store through Module Federation.
// shell/webpack.config.js - Add shared store to exposes
new ModuleFederationPlugin({
name: 'shell',
filename: 'remoteEntry.js',
exposes: {
'./stores/cart': './src/stores/cart.js',
'./stores/auth': './src/stores/auth.js',
},
// ... rest of config
});
// shared/src/stores/cart.js
import { defineStore } from 'pinia';
import apiClient from '../api/client';
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
isLoading: false,
lastSynced: null,
}),
getters: {
itemCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
total: (state) => state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
),
},
actions: {
async fetchCart() {
this.isLoading = true;
try {
const response = await apiClient.get('/cart');
this.items = response.data.items;
this.lastSynced = new Date();
} finally {
this.isLoading = false;
}
},
async addItem(product, quantity = 1) {
const existingItem = this.items.find((i) => i.productId === product.id);
if (existingItem) {
existingItem.quantity += quantity;
await this.updateItem(existingItem);
} else {
const response = await apiClient.post('/cart/items', {
product_id: product.id,
quantity,
});
this.items.push(response.data.item);
}
// Broadcast change to other MFEs
window.dispatchEvent(new CustomEvent('cart:updated', {
detail: { itemCount: this.itemCount, total: this.total },
}));
},
async updateItem(item) {
await apiClient.put(`/cart/items/${item.id}`, {
quantity: item.quantity,
});
},
async removeItem(itemId) {
await apiClient.delete(`/cart/items/${itemId}`);
this.items = this.items.filter((i) => i.id !== itemId);
window.dispatchEvent(new CustomEvent('cart:updated', {
detail: { itemCount: this.itemCount, total: this.total },
}));
},
},
});
Pattern 3: URL-Based Communication
For navigation and deep linking, use URL parameters and the router.
// Products MFE - Navigate with context
function viewProductDetails(product) {
// Use URL parameters for cross-MFE navigation
router.push({
path: `/products/${product.id}`,
query: {
from: 'cart',
returnUrl: router.currentRoute.value.fullPath,
},
});
}
Deployment Strategies
Micro-frontend deployment requires careful orchestration. Choose between independent and coordinated deployment based on your team structure and release cadence.
Independent Deployment (Recommended)
Each micro-frontend deploys independently with its own CI/CD pipeline.
Deployment Architecture:
┌─────────────────────────────────────────────────────────────┐
│ CDN / Edge │
│ ┌────────────────┐ ┌────────────────┐ ┌──────────────┐ │
│ │ shell.cdn.com │ │products.cdn.com│ │ cart.cdn.com │ │
│ └───────┬────────┘ └───────┬────────┘ └──────┬───────┘ │
│ │ │ │ │
└──────────┼───────────────────┼───────────────────┼──────────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ S3/Cloud │ │ S3/Cloud │ │ S3/Cloud │
│ Storage │ │ Storage │ │ Storage │
│ (Shell) │ │ (Products)│ │ (Cart) │
└───────────┘ └───────────┘ └───────────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ CI/CD │ │ CI/CD │ │ CI/CD │
│ Pipeline A│ │ Pipeline B│ │ Pipeline C│
└───────────┘ └───────────┘ └───────────┘
GitHub Actions Workflow for Independent Deployment:
# .github/workflows/deploy-products-mfe.yml
name: Deploy Products MFE
on:
push:
branches: [main]
paths:
- 'products/**'
- '.github/workflows/deploy-products-mfe.yml'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: products/package-lock.json
- name: Install dependencies
working-directory: products
run: npm ci
- name: Run tests
working-directory: products
run: npm test
- name: Build
working-directory: products
env:
VITE_API_URL: ${{ secrets.API_URL }}
NODE_ENV: production
run: npm run build
- name: Deploy to S3
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Sync to S3
working-directory: products
run: |
aws s3 sync dist/ s3://mfe-products-bucket/ \
--delete \
--cache-control "public, max-age=31536000, immutable" \
--exclude "remoteEntry.js"
# remoteEntry.js should not be cached
aws s3 cp dist/remoteEntry.js s3://mfe-products-bucket/remoteEntry.js \
--cache-control "public, max-age=60"
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.PRODUCTS_CF_DIST_ID }} \
--paths "/remoteEntry.js"
Dynamic Remote URL Configuration
Enable runtime configuration of remote URLs for flexible deployment.
// shell/src/config/remotes.js
/**
* Runtime configuration for remote MFE URLs
* Can be injected at deploy time or fetched from config API
*/
export async function getRemoteUrls() {
// Option 1: Environment variables (build time)
if (import.meta.env.PROD) {
return {
products: import.meta.env.VITE_PRODUCTS_URL,
cart: import.meta.env.VITE_CART_URL,
user: import.meta.env.VITE_USER_URL,
};
}
// Option 2: Fetch from config API (runtime)
try {
const response = await fetch('/config/remotes.json');
return response.json();
} catch {
// Fallback to defaults
return {
products: 'http://localhost:3001',
cart: 'http://localhost:3002',
user: 'http://localhost:3003',
};
}
}
Coordinated Deployment
For applications requiring synchronized releases, use a monorepo approach.
# .github/workflows/deploy-all.yml
name: Coordinated Deployment
on:
push:
tags:
- 'v*'
jobs:
build-all:
runs-on: ubuntu-latest
strategy:
matrix:
app: [shell, products, cart, user]
steps:
- uses: actions/checkout@v4
- name: Build ${{ matrix.app }}
working-directory: ${{ matrix.app }}
run: |
npm ci
npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.app }}-dist
path: ${{ matrix.app }}/dist
deploy-all:
needs: build-all
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Deploy all MFEs atomically
run: |
# Deploy to staging first
./scripts/deploy-to-staging.sh
# Run E2E tests
npm run test:e2e
# Blue-green deployment to production
./scripts/blue-green-deploy.sh
Shared Design System and Styling Consistency
Maintaining visual consistency across independently developed micro-frontends requires a shared design system. While our approach with Vue differs from React-based systems, similar theming principles apply to implementing dark mode in Tailwind CSS where consistent design tokens and color schemes are essential.
Shared Component Library
Create a package consumed by all micro-frontends.
// shared/src/components/index.js
export { default as Button } from './Button.vue';
export { default as Input } from './Input.vue';
export { default as Modal } from './Modal.vue';
export { default as Card } from './Card.vue';
export { default as LoadingSpinner } from './LoadingSpinner.vue';
export { default as Toast } from './Toast.vue';
// Design tokens
export { colors, spacing, typography, breakpoints } from './tokens';
<!-- shared/src/components/Button.vue -->
<template>
<button
:class="[
'mfe-button',
`mfe-button--${variant}`,
`mfe-button--${size}`,
{ 'mfe-button--loading': loading },
]"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<LoadingSpinner v-if="loading" size="sm" />
<slot v-else />
</button>
</template>
<script setup>
defineProps({
variant: {
type: String,
default: 'primary',
validator: (v) => ['primary', 'secondary', 'ghost', 'danger'].includes(v),
},
size: {
type: String,
default: 'md',
validator: (v) => ['sm', 'md', 'lg'].includes(v),
},
disabled: Boolean,
loading: Boolean,
});
defineEmits(['click']);
</script>
<style>
/* CSS custom properties for theming */
.mfe-button {
--button-padding: var(--spacing-md);
--button-radius: var(--radius-md);
--button-font-size: var(--font-size-base);
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--button-padding);
border-radius: var(--button-radius);
font-size: var(--button-font-size);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.mfe-button--primary {
background-color: var(--color-primary);
color: var(--color-white);
}
.mfe-button--primary:hover:not(:disabled) {
background-color: var(--color-primary-dark);
}
</style>
Design Tokens
Share design tokens through CSS custom properties for runtime theming.
/* shared/src/styles/tokens.css */
:root {
/* Colors */
--color-primary: #3b82f6;
--color-primary-dark: #2563eb;
--color-secondary: #64748b;
--color-success: #22c55e;
--color-danger: #ef4444;
--color-warning: #f59e0b;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Typography */
--font-family: 'Inter', system-ui, sans-serif;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
/* Borders */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}
/* Dark mode tokens */
.dark {
--color-primary: #60a5fa;
--color-primary-dark: #3b82f6;
/* ... other dark mode overrides */
}
Preventing CSS Conflicts
Use CSS namespacing to prevent style collisions between micro-frontends.
/* products/src/styles/main.css */
/* Namespace all styles with MFE prefix */
.mfe-products {
/* All product MFE styles are scoped here */
}
.mfe-products .product-card {
/* Scoped to products MFE */
}
<!-- products/src/App.vue -->
<template>
<div class="mfe-products">
<router-view />
</div>
</template>
Common Pitfalls and How to Avoid Them
After implementing micro-frontend architectures across multiple projects, these are the pitfalls that catch teams most often.
Pitfall 1: Shared Dependency Version Conflicts
Problem: Different micro-frontends use incompatible versions of Vue or other libraries.
Solution: Define a shared dependency policy with version ranges and enforce compatibility at the CI level.
// webpack.config.js
new ModuleFederationPlugin({
shared: {
vue: {
singleton: true,
requiredVersion: '^3.4.0',
strictVersion: true, // Fail if versions are incompatible
},
},
});
# .github/workflows/check-deps.yml
- name: Check shared dependency versions
run: |
SHELL_VUE=$(jq -r '.dependencies.vue' shell/package.json)
PRODUCTS_VUE=$(jq -r '.dependencies.vue' products/package.json)
if [ "$SHELL_VUE" != "$PRODUCTS_VUE" ]; then
echo "Vue version mismatch: shell=$SHELL_VUE, products=$PRODUCTS_VUE"
exit 1
fi
Pitfall 2: Overly Chatty Inter-MFE Communication
Problem: Micro-frontends become tightly coupled through excessive event passing.
Solution: Limit communication to essential domain events. If two MFEs communicate constantly, they probably should be one MFE.
// GOOD: High-level domain events
eventBus.emit('cart:checkout-completed', { orderId: '123' });
// BAD: Low-level implementation details
eventBus.emit('product:mouse-entered', { x: 100, y: 200 });
eventBus.emit('product:scroll-position', { top: 500 });
Pitfall 3: Ignoring Error Boundaries
Problem: One failing micro-frontend crashes the entire application.
Solution: Implement error boundaries around each remote component.
<!-- shell/src/components/RemoteWrapper.vue -->
<template>
<ErrorBoundary @error="handleError">
<Suspense>
<template #default>
<component :is="remoteComponent" v-bind="$attrs" />
</template>
<template #fallback>
<LoadingState />
</template>
</Suspense>
</ErrorBoundary>
</template>
<script setup>
import { computed, onErrorCaptured } from 'vue';
import ErrorBoundary from './ErrorBoundary.vue';
import LoadingState from './LoadingState.vue';
import FallbackComponent from './FallbackComponent.vue';
const props = defineProps({
remoteName: String,
moduleName: String,
});
const remoteComponent = computed(() => {
try {
return defineAsyncComponent({
loader: () => import(`${props.remoteName}/${props.moduleName}`),
errorComponent: FallbackComponent,
timeout: 10000,
});
} catch (error) {
console.error(`Failed to load remote: ${props.remoteName}/${props.moduleName}`, error);
return FallbackComponent;
}
});
function handleError(error) {
// Log to monitoring service
console.error('Remote component error:', error);
// Optionally notify users
toast.error('A feature is temporarily unavailable');
}
</script>
Pitfall 4: Performance Degradation from Multiple Bundles
Problem: Users download duplicate dependencies across micro-frontends.
Solution: Configure shared dependencies properly and monitor bundle sizes.
// Analyze bundle composition
// npm run build -- --stats
// Upload stats.json to webpack bundle analyzer
// In webpack.config.js
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
});
Pitfall 5: Authentication State Desynchronization
Problem: User logs out in one MFE but remains logged in to others.
Solution: Implement centralized auth state with cross-MFE synchronization as shown in the authentication section above.
Testing Micro-Frontend Architectures
Testing micro-frontends requires strategies that span individual MFEs and their integration.
Unit Testing Individual MFEs
// products/tests/ProductList.spec.js
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import ProductList from '@/components/ProductList.vue';
import { mockApiClient } from '@tests/mocks/api';
describe('ProductList', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('renders products from API', async () => {
mockApiClient.get.mockResolvedValue({
data: {
products: [
{ id: 1, name: 'Product 1', price: 99.99 },
{ id: 2, name: 'Product 2', price: 149.99 },
],
},
});
const wrapper = mount(ProductList);
await flushPromises();
expect(wrapper.findAll('.product-card')).toHaveLength(2);
});
it('emits add-to-cart event', async () => {
const wrapper = mount(ProductList);
await wrapper.find('.add-to-cart-btn').trigger('click');
expect(wrapper.emitted('add-to-cart')).toBeTruthy();
});
});
Integration Testing with Playwright
// e2e/tests/cross-mfe.spec.js
import { test, expect } from '@playwright/test';
test.describe('Cross-MFE Integration', () => {
test('adding product to cart updates cart badge', async ({ page }) => {
await page.goto('/products');
// Wait for products MFE to load
await page.waitForSelector('[data-testid="product-card"]');
// Get initial cart count
const initialCount = await page.textContent('[data-testid="cart-badge"]');
expect(initialCount).toBe('0');
// Add item to cart
await page.click('[data-testid="add-to-cart-btn"]');
// Verify cart badge updated (cross-MFE communication)
await expect(page.locator('[data-testid="cart-badge"]')).toHaveText('1');
});
test('authentication persists across MFE navigation', async ({ page }) => {
// Login via auth MFE
await page.goto('/login');
await page.fill('[data-testid="email"]', '[email protected]');
await page.fill('[data-testid="password"]', 'password');
await page.click('[data-testid="login-btn"]');
// Verify logged in on shell
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
// Navigate to products MFE
await page.goto('/products');
// Verify still logged in (auth state shared)
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
// Navigate to cart MFE
await page.goto('/cart');
// Verify still logged in
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
});
});
Key Takeaways
Building micro-frontends with Vue and Laravel requires careful planning and disciplined execution. These principles guide successful implementations:
- Start with the problem, not the solution: Micro-frontends solve organizational scaling problems. If you have a small team, you probably do not need them.
- Module Federation provides the best DX for Vue: Runtime integration with proper shared dependency management beats alternatives.
- Laravel excels as the unified backend: Sanctum authentication, API resources, and CORS handling make it ideal for multi-frontend architectures.
- Communication should be minimal and domain-focused: Excessive inter-MFE communication signals poor domain boundaries.
- Independent deployment requires independent testing: Each MFE needs its own CI/CD pipeline with integration tests.
- Shared design systems prevent visual fragmentation: Invest in component libraries and design tokens early.
- Error boundaries are not optional: Isolate failures to prevent cascading crashes.
- Monitor bundle sizes obsessively: Duplicate dependencies kill performance gains.
Micro-frontends are not the right choice for every project. When they are the right choice, they enable teams to move fast independently while delivering a cohesive user experience. The investment in architecture pays dividends as teams and applications scale.
Building a large-scale Vue application with Laravel? I specialize in architecting micro-frontend systems that scale with your team. With 14+ years of experience building enterprise applications with PHP, Laravel, and Vue.js, I can help you design an architecture that fits your organization. Schedule a free consultation to discuss your project requirements.
Related Reading:
- Laravel API Development Best Practices
- TypeScript & Vue 3: End-to-End Type Safety with Laravel
- Contact Form Security Best Practices
- Building a Modern Portfolio with Next.js 15
External Resources:

Richard Joseph Porter
Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.
Need Help Upgrading Your Laravel App?
I specialize in modernizing legacy Laravel applications with zero downtime. Get a free codebase audit and upgrade roadmap.
Related Articles
TypeScript & Vue 3: End-to-End Type Safety with Laravel
Learn how to achieve full-stack type safety by connecting Laravel APIs to Vue 3 frontends using TypeScript, generated types, and modern tooling.
AI-Assisted Code Review: Claude for Laravel Quality
Use Claude for Laravel code reviews. Catch security flaws, N+1 queries, and anti-patterns with proven prompts and CI/CD integration. Boost code quality.
Laravel Octane: Achieving Sub-50ms Response Times
Master Laravel Octane with Swoole, RoadRunner, and FrankenPHP. Learn memory leak prevention, production deployment patterns, and benchmarks for high-performance apps.