# Panduan Lengkap Membangun Micro Frontend dengan ...
Created on: May 8, 2025
Created on: May 8, 2025
Berikut adalah panduan langkah demi langkah untuk membuat aplikasi micro frontend yang membandingkan performa antara Next.js dan Nuxt.js menggunakan Module Federation, dengan Tailwind CSS untuk styling.
bash# Buat direktori utama mkdir micro-frontend-comparison cd micro-frontend-comparison # Buat subdirektori untuk setiap aplikasi dan aset mkdir host-shell remote-nex remote-nux shared mkdir -p shared/assets/logos
bash# Masuk ke direktori host shell cd host-shell # Inisialisasi proyek React dengan TypeScript npx create-react-app . --template typescript # Install dependensi webpack dan modul federasi npm install webpack webpack-cli webpack-dev-server html-webpack-plugin @pmmmwh/react-refresh-webpack-plugin webpack-merge -D # Install babel dependencies npm install @babel/core @babel/preset-react @babel/preset-typescript babel-loader -D # Install UI dan utility libraries npm install tailwindcss postcss autoprefixer -D npm install framer-motion react-router-dom nats.ws mitt
bash# Inisialisasi Tailwind CSS npx tailwindcss init -p
Edit file tailwind.config.js
:
host-shell/tailwind.config.js
js/** @type {import('tailwindcss').Config} */ module.exports = { content: ["./src/**/*.{js,jsx,ts,tsx}"], theme: { extend: { colors: { primary: "#002B46", }, }, }, plugins: [], };
Pastikan file postcss.config.js
berisi:
jsmodule.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, };
Edit file host-shell/src/index.css
:
css@tailwind utilities; body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
Buat file webpack.config.js
di root direktori host-shell:
host-shell/webpack.config.js
jsconst HtmlWebpackPlugin = require("html-webpack-plugin"); const { ModuleFederationPlugin } = require("webpack").container; const path = require("path"); const deps = require("./package.json").dependencies; module.exports = { entry: "./src/index", mode: "development", devServer: { static: { directory: path.join(__dirname, "dist"), }, port: 3000, historyApiFallback: true, }, output: { publicPath: "auto", }, resolve: { extensions: [".ts", ".tsx", ".js", ".jsx"], }, module: { rules: [ { test: /\.tsx?$/, loader: "babel-loader", exclude: /node_modules/, options: { presets: ["@babel/preset-react", "@babel/preset-typescript"], }, }, { test: /\.css$/i, include: path.resolve(__dirname, "src"), use: ["style-loader", "css-loader", "postcss-loader"], }, { test: /\.(png|svg|jpg|jpeg|gif)$/i, type: "asset/resource", }, ], }, plugins: [ new ModuleFederationPlugin({ name: "host", remotes: { remoteNex: "remoteNex@http://localhost:3001/remoteEntry.js", remoteNux: "remoteNux@http://localhost:3002/remoteEntry.js", }, shared: { ...deps, react: { singleton: true, requiredVersion: deps.react, }, "react-dom": { singleton: true, requiredVersion: deps["react-dom"], }, }, }), new HtmlWebpackPlugin({ template: "./public/index.html", }), ], };
Buat direktori utils:
bashmkdir -p src/utils
Buat file src/utils/eventBus.ts
:
typescript// host-shell/src/utils/eventBus.ts import mitt from 'mitt'; import * as nats from 'nats.ws'; type Events = { 'component:loaded': { name: string, timestamp: number }; }; // Create mitt instance as fallback const emitter = mitt<Events>(); // NATS connection let nc: nats.NatsConnection | null = null; // Connect to NATS async function connectToNats() { try { nc = await nats.connect({ servers: 'ws://localhost:8080' }); console.log('Connected to NATS'); // Subscribe to all events const sub = nc.subscribe('events.>'); (async () => { for await (const msg of sub) { const subject = msg.subject.replace('events.', ''); const data = JSON.parse(new TextDecoder().decode(msg.data)); emitter.emit(subject as keyof Events, data); } })(); return true; } catch (err) { console.error('Failed to connect to NATS:', err); return false; } } // Publish event async function publish<K extends keyof Events>(event: K, data: Events[K]) { // Try NATS first if (nc) { try { const encodedData = new TextEncoder().encode(JSON.stringify(data)); await nc.publish(`events.${event}`, encodedData); return; } catch (err) { console.warn('NATS publish failed, falling back to mitt', err); } } // Fallback to mitt emitter.emit(event, data); } // Subscribe to event function subscribe<K extends keyof Events>(event: K, callback: (data: Events[K]) => void) { emitter.on(event, callback as any); return () => emitter.off(event, callback as any); } // Initialize connectToNats().catch(console.error); export const eventBus = { publish, subscribe, };
Buat file src/utils/performanceMetrics.ts
:
typescript// src/utils/performanceMetrics.ts type FrameworkType = 'next' | 'nuxt'; interface PerformanceMetric { fcp: number; // First Contentful Paint lcp: number; // Largest Contentful Paint fid: number; // First Input Delay cls: number; // Cumulative Layout Shift loadTime: number; // Component Load Time } interface PerformanceData { next?: PerformanceMetric; nuxt?: PerformanceMetric; } // Extend PerformanceEntry types for Web Vitals interface PerformanceEntryWithFID extends PerformanceEntry { processingStart: number; } interface LayoutShiftEntry extends PerformanceEntry { hadRecentInput: boolean; value: number; } // Save metrics to localStorage export const saveMetrics = (framework: FrameworkType, metrics: PerformanceMetric) => { try { const existingData = localStorage.getItem('performanceMetrics'); const data: PerformanceData = existingData ? JSON.parse(existingData) : {}; data[framework] = metrics; localStorage.setItem('performanceMetrics', JSON.stringify(data)); return true; } catch (err) { console.error('Failed to save metrics:', err); return false; } }; // Get metrics from localStorage export const getMetrics = (): PerformanceData => { try { const data = localStorage.getItem('performanceMetrics'); return data ? JSON.parse(data) : {}; } catch (err) { console.error('Failed to get metrics:', err); return {}; } }; // Collect metrics for a framework export const collectMetrics = async (framework: FrameworkType): Promise<PerformanceMetric> => { return new Promise((resolve) => { // Use Performance Observer to collect metrics const metrics: Partial<PerformanceMetric> = { loadTime: performance.now(), }; // Use PerformanceObserver to collect web vitals const fcpObserver = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); if (entries.length > 0) { metrics.fcp = entries[0].startTime; fcpObserver.disconnect(); } }); const lcpObserver = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); if (entries.length > 0) { metrics.lcp = entries[entries.length - 1].startTime; lcpObserver.disconnect(); } }); const fidObserver = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); if (entries.length > 0) { // Use type assertion to access FID-specific properties const fidEntry = entries[0] as PerformanceEntryWithFID; metrics.fid = fidEntry.processingStart - fidEntry.startTime; fidObserver.disconnect(); } }); const clsObserver = new PerformanceObserver((entryList) => { let clsValue = 0; const entries = entryList.getEntries(); entries.forEach((entry) => { // Use type assertion to access Layout Shift specific properties const layoutShiftEntry = entry as LayoutShiftEntry; if (!layoutShiftEntry.hadRecentInput) { clsValue += layoutShiftEntry.value; } }); metrics.cls = clsValue; }); // Start observing fcpObserver.observe({ type: 'paint', buffered: true }); lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }); fidObserver.observe({ type: 'first-input', buffered: true }); clsObserver.observe({ type: 'layout-shift', buffered: true }); // Resolve after 5 seconds to ensure we've collected most metrics setTimeout(() => { clsObserver.disconnect(); resolve({ fcp: metrics.fcp || 0, lcp: metrics.lcp || 0, fid: metrics.fid || 0, cls: metrics.cls || 0, loadTime: metrics.loadTime || 0, }); }, 5000); }); };
Buat file src/utils/vueComponentWrapper.tsx
:
tsx// host-shell/src/utils/vueComponentWrapper.tsx import React, { useRef } from 'react'; // A simple wrapper that doesn't actually use Vue directly export function wrapVueComponent(VueComponent: any) { return function VueWrapper(props: any) { const containerRef = useRef<HTMLDivElement>(null); // In a real implementation, this would use Vue to mount the component // But for now, we'll just render a placeholder return ( <div ref={containerRef} className="vue-component-container" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '20px', background: '#002B46', color: 'white', height: '100vh' }} > <div> <h2 style={{ fontSize: '24px', marginBottom: '10px' }}>Vue Component Placeholder</h2> <p>This is a placeholder for a Vue component that would be mounted here.</p> <pre style={{ marginTop: '20px', padding: '10px', background: 'rgba(255,255,255,0.1)' }}> {JSON.stringify(props, null, 2)} </pre> </div> </div> ); }; }
Buat direktori komponen:
bashmkdir -p src/components src/pages
Buat file src/components/RemoteLoader.tsx
:
tsx// host-shell/src/components/RemoteLoader.tsx import React, { useEffect, useState, useRef } from 'react'; import { motion } from 'framer-motion'; import { eventBus } from '../utils/eventBus'; import { collectMetrics, saveMetrics } from '../utils/performanceMetrics'; // Define a type for Vue component interface VueComponent { setup?: Function; render?: Function; template?: string; [key: string]: any; } interface RemoteLoaderProps { framework: 'next' | 'nuxt'; fallback?: React.ReactNode; } const RemoteLoader: React.FC<RemoteLoaderProps> = ({ framework, fallback }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null); const [Component, setComponent] = useState<React.ComponentType | null>(null); const startTimeRef = useRef(performance.now()); useEffect(() => { const loadComponent = async () => { try { startTimeRef.current = performance.now(); setLoading(true); setError(null); let RemoteComponent; if (framework === 'next') { // Load Next.js component // @ts-ignore - Ignore TypeScript error for dynamic import const remoteNex = await import('remoteNex/LandingPage'); RemoteComponent = remoteNex.default; } else { // Load Nuxt.js component - requires wrapper for Vue components // @ts-ignore - Ignore TypeScript error for dynamic import const remoteNux = await import('remoteNux/LandingPage'); // Use type assertion to safely check Vue component properties const component = remoteNux.default as any; // Check if it's a React component (has __esModule property) if (component.__esModule) { RemoteComponent = component; } else { // For Vue components, use the provided wrapper const { wrapVueComponent } = await import('../utils/vueComponentWrapper'); RemoteComponent = wrapVueComponent(component); } } setComponent(() => RemoteComponent); // Collect performance metrics const metrics = await collectMetrics(framework); saveMetrics(framework, metrics); // Publish event that component is loaded eventBus.publish('component:loaded', { name: framework, timestamp: performance.now() }); setLoading(false); } catch (err) { console.error(`Error loading ${framework} component:`, err); setError(err instanceof Error ? err : new Error(String(err))); setLoading(false); } }; loadComponent(); }, [framework]); if (loading) { return ( <div className="flex items-center justify-center h-screen bg-primary"> <motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1, ease: "linear" }} className="w-16 h-16 border-t-4 border-blue-500 rounded-full" /> </div> ); } if (error) { return ( <div className="flex flex-col items-center justify-center h-screen bg-primary text-white"> <h2 className="text-2xl font-bold mb-4">Error Loading Component</h2> <p>{error.message}</p> {fallback} </div> ); } if (!Component) { return fallback || null; } return <Component />; }; export default RemoteLoader;
Buat file src/components/Navbar.tsx
:
tsx// host-shell/src/components/Navbar.tsx import React from 'react'; import { Link, useLocation } from 'react-router-dom'; import { motion } from 'framer-motion'; const Navbar: React.FC = () => { const location = useLocation(); return ( <nav className="fixed top-0 w-full bg-white shadow-md z-50"> <div className="container mx-auto px-6 py-3"> <div className="flex justify-between items-center"> <Link to="/" className="font-bold text-xl text-primary"> Micro Frontend Comparison </Link> <div className="flex space-x-6"> <NavLink to="/" current={location.pathname === "/"}> Home </NavLink> <NavLink to="/comparison" current={location.pathname === "/comparison"}> Comparison </NavLink> </div> </div> </div> </nav> ); }; interface NavLinkProps { to: string; current: boolean; children: React.ReactNode; } const NavLink: React.FC<NavLinkProps> = ({ to, current, children }) => { return ( <Link to={to} className="relative"> <span className={`text-lg ${current ? 'text-blue-600' : 'text-gray-600'}`}> {children} </span> {current && ( <motion.div layoutId="underline" className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600" initial={false} /> )} </Link> ); }; export default Navbar;
Buat file src/components/Footer.tsx
:
tsx// host-shell/src/components/Footer.tsx import React from 'react'; const Footer: React.FC = () => { return ( <footer className="bg-primary text-white py-8"> <div className="container mx-auto px-6"> <div className="flex flex-col md:flex-row justify-between items-center"> <div className="mb-4 md:mb-0"> <h3 className="text-xl font-bold">Micro Frontend Comparison</h3> <p className="mt-2">Next.js vs Nuxt.js Performance Analysis</p> </div> <div className="flex space-x-4"> <a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-blue-300"> GitHub </a> <a href="https://nextjs.org" target="_blank" rel="noopener noreferrer" className="hover:text-blue-300"> Next.js </a> <a href="https://nuxtjs.org" target="_blank" rel="noopener noreferrer" className="hover:text-blue-300"> Nuxt.js </a> </div> </div> <div className="mt-8 text-center text-sm"> <p>© {new Date().getFullYear()} Micro Frontend Demo. All rights reserved.</p> </div> </div> </footer> ); }; export default Footer;
Buat file src/pages/HomePage.tsx
:
tsx// host-shell/src/pages/HomePage.tsx import React, { useState } from 'react'; import { motion } from 'framer-motion'; import RemoteLoader from '../components/RemoteLoader'; const HomePage: React.FC = () => { const [activeFramework, setActiveFramework] = useState<'next' | 'nuxt'>('next'); return ( <div className="min-h-screen bg-gray-50"> <div className="container mx-auto px-6 py-24"> <div className="flex justify-center mb-10"> <div className="flex p-1 bg-gray-200 rounded-lg"> <FrameworkToggle framework="next" active={activeFramework === 'next'} onClick={() => setActiveFramework('next')} /> <FrameworkToggle framework="nuxt" active={activeFramework === 'nuxt'} onClick={() => setActiveFramework('nuxt')} /> </div> </div> <div className="bg-white rounded-lg shadow-lg overflow-hidden"> <RemoteLoader framework={activeFramework} fallback={ <div className="flex flex-col items-center justify-center h-screen bg-primary text-white"> <h2 className="text-2xl font-bold mb-4">Loading {activeFramework.toUpperCase()}</h2> <p>Please wait while we load the component...</p> </div> } /> </div> </div> </div> ); }; interface FrameworkToggleProps { framework: 'next' | 'nuxt'; active: boolean; onClick: () => void; } const FrameworkToggle: React.FC<FrameworkToggleProps> = ({ framework, active, onClick }) => { return ( <button onClick={onClick} className={`relative px-6 py-2 rounded-lg ${active ? 'text-white' : 'text-gray-700'}`} > {active && ( <motion.div layoutId="activeTab" className="absolute inset-0 bg-blue-600 rounded-lg" initial={false} transition={{ type: 'spring', duration: 0.6 }} /> )} <span className="relative z-10"> {framework === 'next' ? 'Next.js' : 'Nuxt.js'} </span> </button> ); }; export default HomePage;
Buat file src/pages/ComparisonPage.tsx
:
tsx// host-shell/src/pages/ComparisonPage.tsx import React, { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; import { getMetrics } from '../utils/performanceMetrics'; interface PerformanceMetric { fcp: number; lcp: number; fid: number; cls: number; loadTime: number; } interface PerformanceData { next?: PerformanceMetric; nuxt?: PerformanceMetric; } const ComparisonPage: React.FC = () => { const [metrics, setMetrics] = useState<PerformanceData>({}); const [loading, setLoading] = useState(true); useEffect(() => { // Get metrics from localStorage const data = getMetrics(); setMetrics(data); setLoading(false); }, []); if (loading) { return ( <div className="flex items-center justify-center min-h-screen bg-gray-50"> <motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1, ease: "linear" }} className="w-16 h-16 border-t-4 border-blue-500 rounded-full" /> </div> ); } const hasData = metrics.next || metrics.nuxt; if (!hasData) { return ( <div className="container mx-auto px-6 py-24 text-center"> <h1 className="text-3xl font-bold mb-6">No Performance Data Available</h1> <p className="text-xl mb-8"> Please visit the Home page and load both Next.js and Nuxt.js components to collect performance data. </p> <a href="/" className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" > Go to Home Page </a> </div> ); } return ( <div className="container mx-auto px-6 py-24"> <h1 className="text-3xl font-bold mb-10 text-center"> Performance Comparison: Next.js vs Nuxt.js </h1> <div className="grid grid-cols-1 md:grid-cols-2 gap-10"> <MetricCard title="First Contentful Paint (FCP)" nextValue={metrics.next?.fcp} nuxtValue={metrics.nuxt?.fcp} unit="ms" description="Time until the first content is painted on screen" lowerIsBetter /> <MetricCard title="Largest Contentful Paint (LCP)" nextValue={metrics.next?.lcp} nuxtValue={metrics.nuxt?.lcp} unit="ms" description="Time until the largest content element is rendered" lowerIsBetter /> <MetricCard title="First Input Delay (FID)" nextValue={metrics.next?.fid} nuxtValue={metrics.nuxt?.fid} unit="ms" description="Time from first user interaction to response" lowerIsBetter /> <MetricCard title="Cumulative Layout Shift (CLS)" nextValue={metrics.next?.cls} nuxtValue={metrics.nuxt?.cls} unit="" description="Measure of visual stability (lower is better)" lowerIsBetter /> <MetricCard title="Component Load Time" nextValue={metrics.next?.loadTime} nuxtValue={metrics.nuxt?.loadTime} unit="ms" description="Time taken to load and render the component" lowerIsBetter className="md:col-span-2" /> </div> <div className="mt-12 p-6 bg-gray-100 rounded-lg"> <h2 className="text-2xl font-bold mb-4">Interpretation</h2> <p className="mb-4"> The metrics above show the performance characteristics of Next.js and Nuxt.js components when loaded via Module Federation. Lower values are better for all metrics. </p> <ul className="list-disc pl-6 space-y-2"> <li><strong>FCP:</strong> First visual feedback to the user</li> <li><strong>LCP:</strong> When the main content is likely loaded</li> <li><strong>FID:</strong> Responsiveness to user interactions</li> <li><strong>CLS:</strong> Visual stability during page load (0-1 scale)</li> <li><strong>Load Time:</strong> Total time to load the remote component</li> </ul> </div> </div> ); }; interface MetricCardProps { title: string; nextValue?: number; nuxtValue?: number; unit: string; description: string; lowerIsBetter?: boolean; className?: string; } const MetricCard: React.FC<MetricCardProps> = ({ title, nextValue, nuxtValue, unit, description, lowerIsBetter = true, className = "" }) => { // Determine which framework performed better let nextIsBetter = false; let nuxtIsBetter = false; if (nextValue !== undefined && nuxtValue !== undefined) { if (lowerIsBetter) { nextIsBetter = nextValue < nuxtValue; nuxtIsBetter = nuxtValue < nextValue; } else { nextIsBetter = nextValue > nuxtValue; nuxtIsBetter = nuxtValue > nextValue; } } return ( <div className={`bg-white p-6 rounded-lg shadow-md ${className}`}> <h3 className="text-xl font-bold mb-2">{title}</h3> <p className="text-gray-600 mb-6">{description}</p> <div className="grid grid-cols-2 gap-4"> <div className={`p-4 rounded-lg ${nextIsBetter ? 'bg-green-100' : 'bg-gray-100'}`}> <h4 className="font-bold mb-2">Next.js</h4> <p className="text-2xl font-mono"> {nextValue !== undefined ? `${nextValue.toFixed(2)}${unit}` : 'N/A'} </p> {nextIsBetter && <span className="text-green-600 text-sm">Better</span>} </div> <div className={`p-4 rounded-lg ${nuxtIsBetter ? 'bg-green-100' : 'bg-gray-100'}`}> <h4 className="font-bold mb-2">Nuxt.js</h4> <p className="text-2xl font-mono"> {nuxtValue !== undefined ? `${nuxtValue.toFixed(2)}${unit}` : 'N/A'} </p> {nuxtIsBetter && <span className="text-green-600 text-sm">Better</span>} </div> </div> </div> ); }; export default ComparisonPage;
Modifikasi file src/App.tsx
:
tsx// host-shell/src/App.tsx import React from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import Navbar from './components/Navbar'; import Footer from './components/Footer'; import HomePage from './pages/HomePage'; import ComparisonPage from './pages/ComparisonPage'; import './index.css'; const App: React.FC = () => { return ( <Router> <div className="flex flex-col min-h-screen"> <Navbar /> <main className="flex-grow pt-16"> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/comparison" element={<ComparisonPage />} /> </Routes> </main> <Footer /> </div> </Router> ); }; export default App;
Modifikasi package.json
untuk menambahkan script:
json"scripts": { "start": "webpack serve", "build": "webpack --mode production", "test": "react-scripts test", "eject": "react-scripts eject" }
bash# Masuk ke direktori remote-nex cd ../remote-nex # Inisialisasi proyek Next.js npx create-next-app . --typescript # Install dependensi yang diperlukan npm install @module-federation/nextjs-mf npm install -D tailwindcss postcss autoprefixer npm install nats.ws mitt
bash# Inisialisasi Tailwind CSS npx tailwindcss init -p
Edit file tailwind.config.js
:
remote-nex/tailwind.config.js
js/** @type {import('tailwindcss').Config} */ module.exports = { content: [ './pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}', ], theme: { extend: { colors: { primary: '#002B46', }, }, }, plugins: [], };
Modifikasi file styles/globals.css
:
remote-nex/styles/globals.css
css@import "tailwindcss"; @tailwind utilities; :root { --background: #ffffff; --foreground: #171717; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; --foreground: #ededed; } } body { background: var(--background); color: var(--foreground); font-family: Arial, Helvetica, sans-serif; }
Buat file next.config.Ts
:
ts// remote-nex/next.config.ts import { NextConfig } from 'next'; import { NextFederationPlugin } from '@module-federation/nextjs-mf'; import webpack from 'webpack'; const nextConfig: NextConfig = { reactStrictMode: true, webpack(config: webpack.Configuration) { // Destructuring is commented out as isServer is not used // const { isServer } = options; config.plugins = config.plugins || []; config.plugins.push( new NextFederationPlugin({ name: 'remoteNex', filename: 'static/chunks/remoteEntry.js', exposes: { './LandingPage': './components/LandingPage.tsx', }, shared: { react: { singleton: true, requiredVersion: false, }, 'react-dom': { singleton: true, requiredVersion: false, }, }, extraOptions: { exposePages: false, }, }) ); return config; }, }; export default nextConfig;
Buat direktori utils:
bashmkdir -p utils
Buat file utils/eventBus.ts
dengan konten yang sama seperti di host-shell.
bashmkdir -p components
Buat file components/LandingPage.tsx
:
tsx// remote-nex/components/LandingPage.tsx import React, { useState, useEffect } from 'react'; import { eventBus } from '../utils/eventBus'; interface Logo { src: string; alt: string; } const logos: Logo[] = [ { src: '/logos/docker.svg', alt: 'Docker' }, { src: '/logos/kubernetes.svg', alt: 'Kubernetes' }, { src: '/logos/terraform.svg', alt: 'Terraform' }, { src: '/logos/ansible.svg', alt: 'Ansible' }, { src: '/logos/aws.svg', alt: 'AWS' }, { src: '/logos/gcp.svg', alt: 'Google Cloud' }, { src: '/logos/azure.svg', alt: 'Microsoft Azure' }, { src: '/logos/prometheus.svg', alt: 'Prometheus' }, { src: '/logos/grafana.svg', alt: 'Grafana' }, { src: '/logos/elastic.svg', alt: 'Elastic' }, { src: '/logos/nginx.svg', alt: 'NGINX' }, { src: '/logos/redis.svg', alt: 'Redis' }, { src: '/logos/mongodb.svg', alt: 'MongoDB' }, { src: '/logos/postgresql.svg', alt: 'PostgreSQL' }, { src: '/logos/mysql.svg', alt: 'MySQL' }, { src: '/logos/jenkins.svg', alt: 'Jenkins' }, { src: '/logos/github.svg', alt: 'GitHub' }, { src: '/logos/gitlab.svg', alt: 'GitLab' }, ]; const LandingPage: React.FC = () => { const [isMobile, setIsMobile] = useState(false); const [isTablet, setIsTablet] = useState(false); useEffect(() => { // Notify that component is loaded via event bus eventBus.publish('component:loaded', { name: 'next', timestamp: performance.now() }); // Handle responsive design const checkSize = () => { setIsMobile(window.innerWidth < 768); setIsTablet(window.innerWidth >= 768 && window.innerWidth < 1024); }; checkSize(); window.addEventListener('resize', checkSize); return () => window.removeEventListener('resize', checkSize); }, []); // Filter logos based on screen size const displayedLogos = isMobile ? logos.slice(0, 4) : isTablet ? logos.slice(0, 6) : logos; return ( <section className="flex flex-col md:flex-row w-full h-screen items-center px-6 md:px-16 lg:px-24 pt-40 md:pt-0 bg-[#002B46]"> <div className="w-full md:w-1/2 flex flex-col justify-center text-center md:text-left"> <h1 className="text-white text-4xl font-bold leading-tight"> Empowering IT with <br /> Open-Source Excellence </h1> <p className="text-white mt-4 text-lg leading-relaxed"> Building secure, scalable, and high-performance infrastructure with <br /> industry-leading open-source technologies. </p> <span className="text-white mt-6 text-lg"> 11 years of delivering the right technology for business </span> </div> <div className={`w-full md:w-1/2 grid p-6 items-stretch ${ isMobile ? "grid-cols-2 gap-6" : isTablet ? "grid-cols-3 gap-6" : "grid-cols-6 grid-rows-7 gap-4" }`} > {displayedLogos.map((logo, index) => ( <div key={index} className="flex items-center justify-center"> <img src={logo.src} alt={logo.alt} className="w-32 md:w-full h-auto max-h-full" loading="lazy" /> </div> ))} </div> </section> ); }; export default LandingPage;
Modifikasi file pages/index.tsx
:
tsx// remote-nex/pages/index.tsx import type { NextPage } from 'next'; import LandingPage from '../components/LandingPage'; const Home: NextPage = () => { return <LandingPage />; }; export default Home;
bash# Masuk ke direktori remote-nux cd ../remote-nux # Inisialisasi proyek Nuxt.js npx nuxi init . # Install dependensi yang diperlukan npm install -D tailwindcss postcss autoprefixer npm install nats.ws mitt @module-federation/node @originjs/vite-plugin-federation
bash# Inisialisasi Tailwind CSS npx tailwindcss init -p
Edit file tailwind.config.js
:
remote-nux\tailwind.config.js
js/** @type {import('tailwindcss').Config} */ module.exports = { content: [ './components/**/*.{vue,js}', './layouts/**/*.vue', './pages/**/*.vue', './plugins/**/*.{js,ts}' ], theme: { extend: { colors: { primary: '#002B46', }, }, }, plugins: [], };
Buat direktori assets:
bashmkdir -p assets/css
Buat file assets/css/tailwind.css
:
remote-nux/assets/css/tailwind.css
css@tailwind base; @tailwind components; @tailwind utilities;
Modifikasi file nuxt.config.ts
:
ts// remote-nux/nuxt.config.ts import { defineNuxtConfig } from 'nuxt/config'; import federation from '@originjs/vite-plugin-federation'; export default defineNuxtConfig({ ssr: false, css: ['~/assets/css/tailwind.css'], postcss: { plugins: { tailwindcss: {}, autoprefixer: {}, }, }, vite: { plugins: [ federation({ name: 'remoteNux', filename: 'remoteEntry.js', exposes: { './LandingPage': './components/LandingPage.vue' }, shared: ['vue'] }) ], build: { target: 'esnext', minify: false, cssCodeSplit: false } }, app: { baseURL: process.env.NODE_ENV === 'production' ? '/remote-nux/' : '/', buildAssetsDir: '/static/' }, devServer: { port: 3002 } });
Buat direktori utils:
bashmkdir -p utils
Buat file utils/eventBus.ts
dengan konten yang sama seperti di host-shell.
bashmkdir -p components pages
Buat file components/LandingPage.vue
:
vue<!-- remote-nux\components\LandingPage.vue --> <template> <section class="flex flex-col md:flex-row w-full h-screen items-center px-6 md:px-16 lg:px-24 pt-40 md:pt-0 bg-[#002B46]"> <div class="w-full md:w-1/2 flex flex-col justify-center text-center md:text-left"> <h1 class="text-white text-4xl font-bold leading-tight"> Empowering IT with <br /> Open-Source Excellence </h1> <p class="text-white mt-4 text-lg leading-relaxed"> Building secure, scalable, and high-performance infrastructure with <br /> industry-leading open-source technologies. </p> <span class="text-white mt-6 text-lg"> 11 years of delivering the right technology for business </span> </div> <div :class="[isMobile ? 'grid-cols-2 gap-6' : isTablet ? 'grid-cols-3 gap-6' : 'grid-cols-6 grid-rows-7 gap-4']" class="w-full md:w-1/2 grid p-6 items-stretch" > <div v-for="(logo, index) in displayedLogos" :key="index" class="flex items-center justify-center"> <img :src="logo.src" :alt="logo.alt" class="w-32 md:w-full h-auto max-h-full" loading="lazy" /> </div> </div> </section> </template> <script setup> import { ref, onMounted, onUnmounted, computed } from 'vue'; import { eventBus } from '../utils/eventBus'; const isMobile = ref(false); const isTablet = ref(false); const logos = [ { src: '/logos/docker.svg', alt: 'Docker' }, { src: '/logos/kubernetes.svg', alt: 'Kubernetes' }, { src: '/logos/terraform.svg', alt: 'Terraform' }, { src: '/logos/ansible.svg', alt: 'Ansible' }, { src: '/logos/aws.svg', alt: 'AWS' }, { src: '/logos/gcp.svg', alt: 'Google Cloud' }, { src: '/logos/azure.svg', alt: 'Microsoft Azure' }, { src: '/logos/prometheus.svg', alt: 'Prometheus' }, { src: '/logos/grafana.svg', alt: 'Grafana' }, { src: '/logos/elastic.svg', alt: 'Elastic' }, { src: '/logos/nginx.svg', alt: 'NGINX' }, { src: '/logos/redis.svg', alt: 'Redis' }, { src: '/logos/mongodb.svg', alt: 'MongoDB' }, { src: '/logos/postgresql.svg', alt: 'PostgreSQL' }, { src: '/logos/mysql.svg', alt: 'MySQL' }, { src: '/logos/jenkins.svg', alt: 'Jenkins' }, { src: '/logos/github.svg', alt: 'GitHub' }, { src: '/logos/gitlab.svg', alt: 'GitLab' }, ]; const displayedLogos = computed(() => { if (isMobile.value) return logos.slice(0, 4); if (isTablet.value) return logos.slice(0, 6); return logos; }); const checkSize = () => { isMobile.value = window.innerWidth < 768; isTablet.value = window.innerWidth >= 768 && window.innerWidth < 1024; }; onMounted(() => { // Notify that component is loaded via event bus eventBus.publish('component:loaded', { name: 'nuxt', timestamp: performance.now() }); checkSize(); window.addEventListener('resize', checkSize); }); onUnmounted(() => { window.removeEventListener('resize', checkSize); }); </script>
Buat file app.vue
:
remote-nux/app.vue
vue<template> <div> <NuxtPage /> </div> </template>
Buat file pages/index.vue
:
vue<!-- remote-nux/pages/index.vue --> <template> <LandingPage /> </template> <script setup> import LandingPage from '../components/LandingPage.vue'; </script>
Buat direktori logos di public folder untuk kedua remote:
bash# Buat direktori public/logos untuk Next.js mkdir -p ../remote-nex/public/logos # Buat direktori public/logos untuk Nuxt.js mkdir -p ../remote-nux/public/logos
Download beberapa logo open-source ke direktori shared/assets/logos:
bashcd ../shared/assets/logos # Gunakan curl, wget, atau download manual logo-logo berikut: # - docker.svg # - kubernetes.svg # - terraform.svg # - ansible.svg # - aws.svg # - gcp.svg # - azure.svg # - prometheus.svg # - grafana.svg # - elastic.svg # - nginx.svg # - redis.svg # - mongodb.svg # - postgresql.svg # - mysql.svg # - jenkins.svg # - github.svg # - gitlab.svg
bash# Salin logo ke Next.js cp -r * ../../../remote-nex/public/logos/ # Salin logo ke Nuxt.js cp -r * ../../../remote-nux/public/logos/
Buat file ../host-shell/Dockerfile
:
DockerfileFROM node:16-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["npm", "start"]
Buat file ../remote-nex/Dockerfile
:
DockerfileFROM node:16-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3001 CMD ["npm", "run", "dev"]
Buat file ../remote-nux/Dockerfile
:
DockerfileFROM node:16-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3002 CMD ["npm", "run", "dev"]
Buat file ../docker-compose.yml
:
yamlversion: '3' services: nats: image: nats:latest ports: - "4222:4222" - "8222:8222" - "8080:8080" command: -js -m 8222 --websocket_port 8080 host-shell: build: context: ./host-shell ports: - "3000:3000" depends_on: - nats environment: - NATS_URL=ws://nats:8080 remote-nex: build: context: ./remote-nex ports: - "3001:3001" depends_on: - nats environment: - NATS_URL=ws://nats:8080 remote-nux: build: context: ./remote-nux ports: - "3002:3002" depends_on: - nats environment: - NATS_URL=ws://nats:8080 asset-server: image: nginx:alpine ports: - "8000:80" volumes: - ./shared/assets:/usr/share/nginx/html/assets - ./shared/assets/logos:/usr/share/nginx/html/logos
bash# Kembali ke direktori root cd ../.. # Jalankan semua layanan docker-compose up -d
bash# Jalankan NATS Server menggunakan Docker docker run -p 4222:4222 -p 8222:8222 -p 8080:8080 nats:latest -js -m 8222 --websocket_port 8080
bashcd remote-nex npm run dev
bashcd remote-nux npm run dev
bashcd host-shell npm start
http://localhost:3000
http://localhost:3000/comparison
untuk melihat perbandingan performaJika ingin mereset metrik performa, buka konsol browser dan jalankan:
javascriptlocalStorage.removeItem('performanceMetrics');
Solusi:
Solusi:
@tailwind base; @tailwind components; @tailwind utilities;
) diimpor di entrypointtailwind.config.js
menentukan jalur konten yang benarSolusi:
Solusi:
Solusi:
Sekarang Anda memiliki aplikasi micro frontend yang membandingkan performa Next.js dan Nuxt.js menggunakan Module Federation. Aplikasi ini menampilkan metrik performa penting seperti FCP, LCP, FID, CLS, dan Load Time untuk kedua framework.
Pertanyaan kenapa docker compose up tidak ada port yang menampilkan page
Intinya: kontainer‑kontainernya memang jalan, tetapi server dev‑nya tidak “kelihatan” di luar kontainer karena:
webpack‑dev‑server
pada host‑shell hanya mendengarkan 127.0.0.1
di dalam kontainer.127.0.0.1
.Berikut patch tercepat agar docker compose up
langsung bisa di‑akses dari host OS:
host-shell/webpack.config.js
diffdevServer: { static: { directory: path.join(__dirname, 'dist'), }, port: 3000, + host: '0.0.0.0', // <— Buka ke semua interface + allowedHosts: 'all', // (opsional) hindari “Invalid Host header” historyApiFallback: true, },
package.json
diff"scripts": { - "dev": "next dev", + "dev": "next dev -H 0.0.0.0 -p 3001", "build": "next build", "start": "next start" }
Kenapa? Port 3001 yang Anda publish di
docker-compose.yml
harus sama dengan port di dalam kontainer.
nuxt.config.ts
diffdevServer: { - port: 3002 + port: 3002, + host: '0.0.0.0' }
(Port 3002 sudah cocok; kita hanya membuka ke semua interface.)
docker-compose.yml
Jika Anda lebih suka tanpa menyentuh kode:
yamlservices: host-shell: environment: - HOST=0.0.0.0 # untuk webpack-dev-server ≥ 5.0 remote-nex: command: npm run dev -- -H 0.0.0.0 -p 3001 remote-nux: command: nuxi dev -H 0.0.0.0 -p 3002
bashdocker compose down -v # matikan & buang volume lama docker compose up --build
Logs
docker compose logs -f host-shell
Harus muncul Project running at: http://0.0.0.0:3000
.
Port mapping
docker compose ps
Kolom “Ports” harus menampilkan:
text0.0.0.0:3000->3000/tcp 0.0.0.0:3001->3001/tcp 0.0.0.0:3002->3002/tcp
Akses di browser
Jika masih kosong, periksa firewall/antivirus host atau pesan error di docker compose logs
.
0.0.0.0
di dalam kontainer.docker compose ps
& docker compose logs
untuk memverifikasi.Sesudah tiga perubahan di atas, halaman akan muncul begitu docker compose up
selesai membangun layanan. Selamat mencoba!