SPA vs MPA Routing: Architecture, Performance & Trade-offs
There's a fundamental question that every developer building a web application with React must answer: should this be a single-page application (SPA) or a multi-page application (MPA)? It's a decision that cascades through your entire architecture—affecting routing, bundling, SEO, performance, and even your team's tooling choices.
But here's the twist: this isn't a binary choice anymore. Modern frameworks blur the lines. Some applications are hybrid (partial hydration, server components, islands architecture). Others are MPA on the frontend but SPA-like in experience. Understanding the mechanics of both helps you make intelligent decisions for your specific constraints.
Let's explore what these architectures actually are, how routing differs, and when each excels.
Table of Contents
- Defining SPA and MPA
- Routing Mechanisms Compared
- Initial Load and Time to Interactive
- Navigation Performance
- SEO and Crawlability
- Bundle Size and Code Splitting
- State Management Across Navigation
- Browser History and Back Button
- Server-Side Considerations
- Real-World Hybrid Approaches
- Decision Matrix: Choosing Your Architecture
- FAQ
Defining SPA and MPA {#defining-spa-mpa}
Single-Page Application (SPA)
A single-page application loads a single HTML document once. All subsequent navigation happens in the browser via JavaScript—the URL changes, but no new HTML file is fetched from the server. The application is essentially a giant JavaScript bundle that handles all routing and rendering client-side.
Example: Gmail, Figma, Discord, most web apps you interact with daily.
Key characteristics:
- One HTML file served
- JavaScript handles all routing
- Navigation is instant (no server round-trip)
- Full client-side state management
- Requires JavaScript to function
Multi-Page Application (MPA)
A multi-page application serves separate HTML files for each page. When you navigate, the browser requests a new HTML file from the server, which includes its own CSS, JavaScript, and content. Each page is somewhat independent.
Example: Wikipedia, news sites, traditional websites, most server-rendered applications.
Key characteristics:
- Multiple HTML files served
- Server handles routing
- Each page is a separate document
- Server manages state
- Works without JavaScript
The Technical Reality
In practice:
- SPA rendered with React Router - One HTML, routing in JavaScript
- MPA rendered with Next.js/Remix - Multiple HTML pages, routing on the server
- Hybrid (React Server Components, Islands) - Server renders, client hydrates, some routes work without JS
Routing Mechanisms Compared {#routing-mechanisms}
The way routing works is fundamentally different between SPAs and MPAs.
SPA Routing: Client-Side (Browser)
In an SPA, the browser itself handles routing. When the URL changes, JavaScript intercepts the change, figures out which component to render, and updates the DOM.
TypeScript Version: SPA Router Implementation
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { useState, useEffect } from 'react';
// SPA: Routing happens entirely in the browser
interface Route {
path: string;
component: React.ComponentType;
}
interface SPARouterProps {
routes: Route[];
}
export function SPARouter({ routes }: SPARouterProps) {
const [currentPath, setCurrentPath] = useState(window.location.pathname);
useEffect(() => {
// Listen for browser navigation
const handlePopState = () => {
setCurrentPath(window.location.pathname);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Find matching route
const matchedRoute = routes.find(route =>
window.location.pathname.startsWith(route.path)
);
const Component = matchedRoute?.component;
if (!Component) {
return <div>404 - Not Found</div>;
}
return <Component />;
}
// Usage with React Router (the standard way)
const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
{
path: '/products/:id',
element: <ProductPage />,
},
{
path: '*',
element: <NotFoundPage />,
},
]);
export function App() {
return <RouterProvider router={router} />;
}
JavaScript Version
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { useState, useEffect } from 'react';
export function SPARouter({ routes }) {
const [currentPath, setCurrentPath] = useState(window.location.pathname);
useEffect(() => {
const handlePopState = () => {
setCurrentPath(window.location.pathname);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const matchedRoute = routes.find(route =>
window.location.pathname.startsWith(route.path)
);
const Component = matchedRoute?.component;
if (!Component) {
return <div>404 - Not Found</div>;
}
return <Component />;
}
const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
{
path: '/products/:id',
element: <ProductPage />,
},
{
path: '*',
element: <NotFoundPage />,
},
]);
export function App() {
return <RouterProvider router={router} />;
}
MPA Routing: Server-Side (File System or Router)
In an MPA, the server handles routing. When you navigate, the browser requests a new page from the server. The server processes the request, determines which page to serve, and sends back a complete HTML document.
TypeScript Version: MPA Server Routing
// This is a Next.js example (server-side routing via file system)
// File: app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
interface Product {
id: string;
name: string;
price: number;
}
interface PageProps {
params: Promise<{ id: string }>;
}
// This function runs on the server
export default async function ProductPage({ params }: PageProps) {
const { id } = await params;
try {
// Fetch data on server
const response = await fetch(`/api/products/${id}`);
if (!response.ok) notFound();
const product: Product = await response.json();
return (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
</div>
);
} catch (error) {
notFound();
}
}
// Server-side routing happens through file system
// /app/page.tsx → /
// /app/products/[id]/page.tsx → /products/123
// /app/about/page.tsx → /about
JavaScript Version
// Next.js example
// File: app/products/[id]/page.js
import { notFound } from 'next/navigation';
export default async function ProductPage({ params }) {
const { id } = await params;
try {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) notFound();
const product = await response.json();
return (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
</div>
);
} catch (error) {
notFound();
}
}
Key difference: MPA routing happens before the page reaches the browser. The server decides what to send. SPA routing happens after the page loads—JavaScript decides what to render.
Initial Load and Time to Interactive {#tti}
This is where the architectural differences show up in real metrics.
SPA: Slower Initial Load, Faster Navigation
An SPA must download, parse, and execute the entire JavaScript bundle before anything interactive appears. For modern React applications, this can be 200KB-1MB+ of JavaScript.
TypeScript Version: SPA Loading Timeline
// Conceptual timeline for an SPA
// 1. Browser requests index.html (small, ~5KB)
// 2. Browser downloads main.js (500KB)
// 3. Browser parses and executes main.js (~500ms on mid-range device)
// 4. React mounts, hydrates, renders first component
// 5. Application is interactive (~2-3 seconds on 4G)
// Meanwhile, MPA:
// 1. Browser requests /products/123 (complete HTML, 50KB)
// 2. Browser parses HTML, renders, starts CSS/JS downloads
// 3. Application is partially interactive (~1 second)
// 4. JavaScript loads and interactive features work (~2 seconds)
interface SPAMetrics {
htmlSize: number; // 5KB
jsSize: number; // 500KB
ttf: number; // Time to First Paint: 2s
tti: number; // Time to Interactive: 3s
fcp: number; // First Contentful Paint: 2s
}
interface MPAMetrics {
htmlSize: number; // 50KB (includes content!)
jsSize: number; // 20KB per page
ttf: number; // Time to First Paint: 0.5s
tti: number; // Time to Interactive: 1.5s
fcp: number; // First Contentful Paint: 0.5s
}
export function MetricsComparison() {
const spaMetrics: SPAMetrics = {
htmlSize: 5,
jsSize: 500,
ttf: 2000,
tti: 3000,
fcp: 2000,
};
const mpaMetrics: MPAMetrics = {
htmlSize: 50,
jsSize: 20,
ttf: 500,
tti: 1500,
fcp: 500,
};
return (
<div>
<h3>Initial Load Performance</h3>
<p>SPA Time to Interactive: {spaMetrics.tti}ms</p>
<p>MPA Time to Interactive: {mpaMetrics.tti}ms</p>
<p>
MPA is {spaMetrics.tti / mpaMetrics.tti}x faster to interactive
</p>
</div>
);
}
JavaScript Version
export function MetricsComparison() {
const spaMetrics = {
htmlSize: 5,
jsSize: 500,
ttf: 2000,
tti: 3000,
fcp: 2000,
};
const mpaMetrics = {
htmlSize: 50,
jsSize: 20,
ttf: 500,
tti: 1500,
fcp: 500,
};
return (
<div>
<h3>Initial Load Performance</h3>
<p>SPA Time to Interactive: {spaMetrics.tti}ms</p>
<p>MPA Time to Interactive: {mpaMetrics.tti}ms</p>
<p>
MPA is {(spaMetrics.tti / mpaMetrics.tti).toFixed(1)}x faster to interactive
</p>
</div>
);
}
However: Once an SPA loads, subsequent navigation is instant. You don't wait for server requests or HTML parsing—just JavaScript execution.
Navigation Performance {#navigation-performance}
SPA Navigation: Instant (No Server Round-Trip)
Once a SPA is loaded, navigating between routes is instant because it's all client-side. No network request, no server processing, no new HTML parsing.
TypeScript Version: SPA vs MPA Navigation
// SPA Navigation
function SPANavigation() {
const navigate = useNavigate();
const handleClick = async () => {
// 1. Change URL (instant)
navigate('/products/123');
// 2. Component re-renders (instant)
// 3. Effect fetches data (async, but UI already updated)
// Total time: ~10-50ms (just JavaScript execution)
};
return <button onClick={handleClick}>View Product</button>;
}
// MPA Navigation (e.g., Next.js)
// File: app/page.tsx
import Link from 'next/link';
export function HomePageMPA() {
return (
<div>
{/* Clicking this triggers a server request */}
<Link href="/products/123">View Product</Link>
{/* 1. Browser requests /products/123 from server (~200-500ms over network)
2. Server generates HTML (~50-100ms)
3. Browser receives HTML (~200-500ms depending on connection)
4. Browser parses HTML and renders (instant)
5. Browser loads and executes page JS
Total time: ~500ms-2s depending on network and server
*/}
</div>
);
}
interface NavigationMetrics {
spaNavigationTime: number; // 10-50ms
mpaNavigationTime: number; // 500-2000ms
}
JavaScript Version
import { useNavigate } from 'react-router-dom';
function SPANavigation() {
const navigate = useNavigate();
const handleClick = async () => {
navigate('/products/123');
// Total time: ~10-50ms
};
return <button onClick={handleClick}>View Product</button>;
}
// MPA with Next.js
import Link from 'next/link';
export function HomePageMPA() {
return (
<div>
<Link href="/products/123">View Product</Link>
{/* Total time: ~500ms-2s */}
</div>
);
}
MPA Navigation: Each navigation is a full page request. Network latency dominates. Even with fast servers and networks, you're looking at 300-1000ms minimum.
SPA Navigation: After the initial load, navigation is nearly instant because it's just JavaScript changing the DOM.
SEO and Crawlability {#seo}
This is where SPAs have traditionally struggled.
SPA SEO Challenge
Search engines like Google run JavaScript, but they have limitations:
- They may not wait for all data to load
- Dynamically injected content might be missed
- Meta tags set in JavaScript might not be detected correctly
- Structured data from JavaScript might not be indexed
TypeScript Version: SEO Approaches
// SPA SEO Problem
function ProductPageSPA() {
const { id } = useParams();
const [product, setProduct] = useState(null);
useEffect(() => {
// Data fetched AFTER component mounts
// Search engines might not wait for this
fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct);
}, [id]);
if (!product) return <div>Loading...</div>;
return (
<div>
{/* Meta tags are set in JavaScript */}
<Helmet>
<title>{product.name}</title>
<meta name="description" content={product.description} />
</Helmet>
<h1>{product.name}</h1>
</div>
);
}
// MPA SEO Solution (Next.js Server Components)
// The HTML sent to the browser already contains the product data!
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps) {
const { id } = await params;
const product = await fetch(`/api/products/${id}`).then(r => r.json());
return {
title: product.name,
description: product.description,
};
}
export default async function ProductPageMPA({ params }: PageProps) {
const { id } = await params;
const product = await fetch(`/api/products/${id}`).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
// In MPA, the HTML sent to the browser includes:
// <title>Product Name</title>
// <meta name="description" content="Product description">
// <h1>Product Name</h1>
// <p>Product description</p>
// Search engines see this immediately without executing JavaScript
JavaScript Version
// SPA with Helmet
function ProductPageSPA() {
const { id } = useParams();
const [product, setProduct] = useState(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(r => r.json())
.then(setProduct);
}, [id]);
if (!product) return <div>Loading...</div>;
return (
<div>
<Helmet>
<title>{product.name}</title>
<meta name="description" content={product.description} />
</Helmet>
<h1>{product.name}</h1>
</div>
);
}
// MPA (Next.js)
export async function generateMetadata({ params }) {
const { id } = await params;
const product = await fetch(`/api/products/${id}`).then(r => r.json());
return {
title: product.name,
description: product.description,
};
}
export default async function ProductPageMPA({ params }) {
const { id } = await params;
const product = await fetch(`/api/products/${id}`).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
| Aspect | SPA | MPA |
|---|---|---|
| Meta Tags | Set in JavaScript (may be missed) | Set in HTML (guaranteed indexed) |
| Dynamic Content | Fetched client-side (engines may timeout) | Rendered server-side (included in HTML) |
| Crawlability | Requires JS execution | Works without JavaScript |
| Core Web Vitals | Often worse (large bundle) | Often better (server-side rendering) |
| Structured Data | JavaScript-generated (risky) | HTML-generated (reliable) |
Winner: MPA for SEO. However, modern SPAs using Helmet, React Query for data fetching, and proper code splitting can rank well. It's not impossible, just requires more work.
Bundle Size and Code Splitting {#bundle-size}
SPA: One Large Bundle
A typical SPA React application ships the entire routing logic, state management, and component library in one JavaScript bundle. This can easily be 300KB-1MB+.
MPA: Multiple Smaller Bundles
Each page in an MPA only ships the JavaScript it needs. A home page might be 20KB, a product page 30KB, an admin page 50KB. Users only download code for the pages they visit.
TypeScript Version: Bundle Size Impact
// SPA Bundle Structure
// main.js (500KB total)
// ├── React (40KB)
// ├── React Router (10KB)
// ├── Redux (8KB)
// ├── All page components (200KB)
// ├── All utility functions (50KB)
// └── All data fetching logic (192KB)
// User downloads 500KB for every pageview
// Even if they only visit the home page
// MPA Bundle Structure (Next.js)
// /page.js (home, 25KB)
// ├── React (10KB - shared)
// ├── Homepage component (8KB)
// └── Homepage utils (7KB)
// /products/page.js (products list, 35KB)
// ├── React (10KB - shared)
// ├── Products component (15KB)
// └── Product utils (10KB)
// /products/[id]/page.js (product detail, 45KB)
// ├── React (10KB - shared)
// ├── Product detail component (20KB)
// └── Product utils (15KB)
// User visiting only home downloads: 25KB
// User visiting product page downloads: 35KB
// Even if they visit both, much smaller total
interface BundleSizeComparison {
spa: {
initialLoad: number; // 500KB
navigation: number; // 0KB (cached)
total: number; // 500KB
};
mpa: {
initialLoad: number; // 25KB
navigation: number; // 35KB (new page)
total: number; // 60KB
};
}
export function BundleComparison() {
const comparison: BundleSizeComparison = {
spa: {
initialLoad: 500,
navigation: 0,
total: 500,
},
mpa: {
initialLoad: 25,
navigation: 35,
total: 60,
},
};
return (
<div>
<p>SPA total: {comparison.spa.total}KB</p>
<p>MPA total: {comparison.mpa.total}KB</p>
<p>MPA is {(comparison.spa.total / comparison.mpa.total).toFixed(1)}x smaller</p>
</div>
);
}
JavaScript Version
export function BundleComparison() {
const comparison = {
spa: {
initialLoad: 500,
navigation: 0,
total: 500,
},
mpa: {
initialLoad: 25,
navigation: 35,
total: 60,
},
};
return (
<div>
<p>SPA total: {comparison.spa.total}KB</p>
<p>MPA total: {comparison.mpa.total}KB</p>
<p>MPA is {(comparison.spa.total / comparison.mpa.total).toFixed(1)}x smaller</p>
</div>
);
}
However: SPAs can use code splitting and lazy loading to mitigate this. If you lazy-load routes, you might only download 100KB initially and load additional bundles on demand.
State Management Across Navigation {#state-management}
This is one of the biggest practical differences.
SPA: State Persists Naturally
In a SPA, your entire state tree stays in memory as you navigate. You can navigate back and forth without losing state.
TypeScript Version: State Across Navigation
// SPA: State persists naturally
export function SPAStateExample() {
const [filters, setFilters] = useState({
category: 'electronics',
minPrice: 100,
maxPrice: 500,
});
return (
<Router>
<Routes>
<Route
path="/products"
element={<ProductsList filters={filters} onFilterChange={setFilters} />}
/>
<Route
path="/products/:id"
element={<ProductDetail />}
/>
</Routes>
</Router>
);
}
// When you navigate from /products to /products/123 and back,
// `filters` state is preserved. You don't lose your selections.
// MPA: State is lost on navigation
// File: app/products/page.tsx
'use client'; // Next.js client component
import { useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
export default function ProductsPage() {
// State is recreated on every page load
const [filters, setFilters] = useState({
category: 'electronics',
minPrice: 100,
maxPrice: 500,
});
const searchParams = useSearchParams();
// Must manually restore filters from URL
useEffect(() => {
const category = searchParams.get('category');
if (category) {
setFilters(prev => ({ ...prev, category }));
}
}, [searchParams]);
return (
<div>
{/* Filters UI */}
</div>
);
}
// To preserve state in MPA, you must:
// 1. Store state in URL search params
// 2. Store state in cookies
// 3. Store state in local storage
// 4. Pass state through URL navigation
// This is more work and more error-prone
JavaScript Version
import { useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
export default function ProductsPage() {
const [filters, setFilters] = useState({
category: 'electronics',
minPrice: 100,
maxPrice: 500,
});
const searchParams = useSearchParams();
useEffect(() => {
const category = searchParams.get('category');
if (category) {
setFilters(prev => ({ ...prev, category }));
}
}, [searchParams]);
return (
<div>
{/* Filters UI */}
</div>
);
}
SPA advantage: State management is easier. You keep everything in React state or Redux.
MPA advantage: Forcing state into the URL makes it shareable and bookmarkable. It's a feature, not a bug.
Browser History and Back Button {#browser-history}
SPA: JavaScript Manages History
In a SPA, React Router manages the browser's history API. Back/forward buttons work, but they're implemented in JavaScript.
TypeScript Version: History Management
import { useNavigate, useLocation } from 'react-router-dom';
export function HistoryExample() {
const navigate = useNavigate();
const location = useLocation();
return (
<div>
<p>Current path: {location.pathname}</p>
<button onClick={() => navigate(-1)}>Back</button>
<button onClick={() => navigate(1)}>Forward</button>
<button onClick={() => navigate('/products')}>Products</button>
</div>
);
}
// SPA History Stack:
// 1. User navigates to /
// 2. User navigates to /products
// 3. User navigates to /products/123
// Back button: goes to /products (no server request)
// Back button: goes to / (no server request)
JavaScript Version
import { useNavigate, useLocation } from 'react-router-dom';
export function HistoryExample() {
const navigate = useNavigate();
const location = useLocation();
return (
<div>
<p>Current path: {location.pathname}</p>
<button onClick={() => navigate(-1)}>Back</button>
<button onClick={() => navigate(1)}>Forward</button>
<button onClick={() => navigate('/products')}>Products</button>
</div>
);
}
MPA: Browser Handles History Naturally
In an MPA, the browser's native back button works automatically. You don't need to implement anything.
// MPA History Stack:
// 1. User navigates to / (HTML page 1)
// 2. User navigates to /products (HTML page 2)
// 3. User navigates to /products/123 (HTML page 3)
// Back button: browser loads HTML page 2 from history
// Back button: browser loads HTML page 1 from history
Both work fine once you understand them. SPA requires implementation (React Router handles this), MPA gets it for free.
Server-Side Considerations {#server-side}
SPA Server Requirements
A SPA only needs to serve static files and API endpoints:
// Simple Node.js server for SPA
import express from 'express';
import path from 'path';
const app = express();
// Serve static files
app.use(express.static('dist'));
// API endpoints
app.get('/api/products/:id', async (req, res) => {
const product = await fetchProduct(req.params.id);
res.json(product);
});
// Fallback: serve index.html for all routes (SPA requirement)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist/index.html'));
});
app.listen(3000);
MPA Server Requirements
An MPA needs to run server-side logic for each route:
// Next.js example (MPA)
// The framework handles routing automatically
// app/page.tsx
export default async function Home() {
const products = await fetch('/api/products').then(r => r.json());
return <div>{products.map(p => <p key={p.id}>{p.name}</p>)}</div>;
}
// app/products/[id]/page.tsx
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await fetch(`/api/products/${id}`).then(r => r.json());
return <div>{product.name}</div>;
}
// Each route can fetch different data and render different HTML
SPA: Server is simpler but must serve the same HTML file for all routes. All logic is client-side.
MPA: Server is more complex but can customize each response. Logic can run server-side.
Real-World Hybrid Approaches {#hybrid-approaches}
Modern frameworks are blurring the lines with hybrid approaches:
TypeScript Version: Hybrid Architectures
// 1. SPA with Code Splitting (Partial SPA)
// React Router with lazy-loaded route bundles
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
const HomePage = lazy(() => import('./pages/HomePage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
const router = createBrowserRouter([
{
path: '/',
element: <Suspense fallback={<div>Loading...</div>}><HomePage /></Suspense>,
},
{
path: '/admin',
element: <Suspense fallback={<div>Loading...</div>}><AdminPage /></Suspense>,
},
]);
// 2. MPA with Client-Side Interactivity (Partial MPA)
// Next.js with client components
// app/layout.tsx (server component)
export default async function RootLayout({ children }) {
const navigation = await fetch('/api/navigation').then(r => r.json());
return (
<html>
<body>
<Navigation items={navigation} /> {/* Client component */}
{children}
</body>
</html>
);
}
// 3. Islands Architecture (Progressive Enhancement)
// Serve HTML with islands of interactivity
// server.tsx (Astro example)
---
const products = await fetch('/api/products').then(r => r.json());
---
<html>
<body>
<h1>Products</h1>
<ProductFilter client:load /> {/* Only this is interactive */}
{products.map(p => (
<div>{p.name}</div>
))}
</body>
</html>
// 4. React Server Components + Client Components (Streaming)
// Best of both worlds with Next.js App Router
interface RootLayoutProps {
children: React.ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
// Server component: can fetch data
const data = async () => {
return fetch('/api/data').then(r => r.json());
};
return (
<html>
<body>
<Header /> {/* Client component: interactive */}
{children} {/* Can be either */}
</body>
</html>
);
}
JavaScript Version
// 1. SPA with Code Splitting
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
const HomePage = lazy(() => import('./pages/HomePage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
const router = createBrowserRouter([
{
path: '/',
element: <Suspense fallback={<div>Loading...</div>}><HomePage /></Suspense>,
},
{
path: '/admin',
element: <Suspense fallback={<div>Loading...</div>}><AdminPage /></Suspense>,
},
]);
// 2. MPA with Client Components (Next.js)
export default async function RootLayout({ children }) {
const navigation = await fetch('/api/navigation').then(r => r.json());
return (
<html>
<body>
<Navigation items={navigation} />
{children}
</body>
</html>
);
}
// 3. Islands Architecture (Astro)
export default function Page() {
const products = await fetch('/api/products').then(r => r.json());
return (
<html>
<body>
<h1>Products</h1>
<ProductFilter client:load />
{products.map(p => (
<div>{p.name}</div>
))}
</body>
</html>
);
}
These hybrid approaches combine the benefits of both SPAs and MPAs.
Decision Matrix: Choosing Your Architecture {#decision-matrix}
| Factor | SPA | MPA |
|---|---|---|
| Initial Load Speed | ❌ Slow (large bundle) | ✅ Fast (small HTML) |
| Navigation Speed | ✅ Instant | ❌ Slow (network) |
| SEO | ⚠️ Complex | ✅ Simple |
| Bundle Size | ❌ Large | ✅ Small |
| State Persistence | ✅ Natural | ❌ Manual work |
| Offline Capability | ✅ Possible | ❌ Hard |
| Development Complexity | ⚠️ Medium | ⚠️ Medium (different) |
| Server Complexity | ✅ Simple | ⚠️ More complex |
| Works without JS | ❌ No | ✅ Yes |
| SEO Optimization | ⚠️ Must work hard | ✅ Comes for free |
Choose SPA when:
- Navigation speed is critical (internal tools, dashboards, collaborative apps)
- You have significant client-side state (form builders, design tools, code editors)
- Offline functionality is required
- Your content is mostly dynamic (Gmail, Slack)
Choose MPA when:
- SEO is critical (e-commerce, content sites, blogs)
- Initial load speed matters more than navigation speed (public websites)
- You want simple server-side rendering
- Content is relatively static with dynamic sections
- You want progressive enhancement (works without JS)
Choose Hybrid when:
- You want both fast initial loads AND instant navigation
- You have both static and dynamic content
- You can invest in infrastructure (Next.js, Remix)
Practical Application: Building a hybrid solution with Next.js
Here's how to combine the benefits of both architectures using modern tooling:
TypeScript Version
// Next.js App Router: Server-first, client-enhanced
// app/layout.tsx (Server Component)
import { ReactNode } from 'react';
interface RootLayoutProps {
children: ReactNode;
}
export const metadata = {
title: 'E-commerce Store',
description: 'Products and shopping',
};
export default function RootLayout({ children }: RootLayoutProps) {
// This runs on the server for every request
return (
<html>
<body>
<Header /> {/* Client component */}
{children}
<Footer />
</body>
</html>
);
}
// app/products/page.tsx (Server Component by default)
interface Product {
id: string;
name: string;
price: number;
}
export default async function ProductsPage() {
// Fetches on server before rendering
const products = await fetch(
`${process.env.API_URL}/products`
).then(r => r.json()) as Product[];
// HTML sent to browser already contains products
// No loading spinner needed
return (
<div>
<h1>Products</h1>
<ProductsGrid products={products} />
</div>
);
}
// app/products/[id]/page.tsx (Server Component)
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps) {
const { id } = await params;
const product = await fetch(
`${process.env.API_URL}/products/${id}`
).then(r => r.json()) as Product;
return {
title: product.name,
description: `Buy ${product.name} for $${product.price}`,
};
}
export default async function ProductPage({ params }: PageProps) {
const { id } = await params;
const product = await fetch(
`${process.env.API_URL}/products/${id}`
).then(r => r.json()) as Product;
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
{/* Client component for cart button */}
<AddToCartButton productId={product.id} />
</div>
);
}
// components/Header.tsx (Client Component)
'use client'; // Explicit client component
import { useState } from 'react';
import Link from 'next/link';
export function Header() {
const [isOpen, setIsOpen] = useState(false);
return (
<header>
<nav>
<Link href="/">Home</Link>
<Link href="/products">Products</Link>
<button onClick={() => setIsOpen(!isOpen)}>
Menu
</button>
</nav>
</header>
);
}
// Result:
// - Initial load: Fast (HTML with content, minimal JS)
// - Navigation: Natural (can be instant with navigation cache)
// - SEO: Perfect (server-rendered HTML)
// - Interactivity: Where needed (client components)
// - Bundle size: Optimized (only client components sent as JS)
JavaScript Version
// app/layout.js (Server Component)
export const metadata = {
title: 'E-commerce Store',
description: 'Products and shopping',
};
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/products/page.js (Server Component)
export default async function ProductsPage() {
const products = await fetch(
`${process.env.API_URL}/products`
).then(r => r.json());
return (
<div>
<h1>Products</h1>
<ProductsGrid products={products} />
</div>
);
}
// app/products/[id]/page.js
export async function generateMetadata({ params }) {
const { id } = await params;
const product = await fetch(
`${process.env.API_URL}/products/${id}`
).then(r => r.json());
return {
title: product.name,
description: `Buy ${product.name} for $${product.price}`,
};
}
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await fetch(
`${process.env.API_URL}/products/${id}`
).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<AddToCartButton productId={product.id} />
</div>
);
}
// components/Header.js
'use client';
import { useState } from 'react';
import Link from 'next/link';
export function Header() {
const [isOpen, setIsOpen] = useState(false);
return (
<header>
<nav>
<Link href="/">Home</Link>
<Link href="/products">Products</Link>
<button onClick={() => setIsOpen(!isOpen)}>
Menu
</button>
</nav>
</header>
);
}
Performance note: This hybrid approach gets you:
- SPA benefits: Instant navigation (thanks to Next.js routing optimization and client components)
- MPA benefits: Fast initial load (server renders HTML), perfect SEO (content in HTML), small bundles (only interactive JS sent)
FAQ
Q: Is SPA routing dead?
A: No. SPAs are excellent for certain use cases (dashboards, internal tools, offline apps). But for public-facing websites where SEO and initial load speed matter, MPAs or hybrid approaches are often better. Choose based on your requirements, not trends.
Q: Can SPAs be SEO-friendly?
A: Yes, but with more work:
- Use Helmet to set meta tags
- Prerender critical routes
- Use server-side rendering for index pages
- Ensure data fetching completes before rendering
- Consider SSG (Static Site Generation)
Google can handle SPAs, but it's not automatic like with MPAs.
Q: What about Next.js—is it SPA or MPA?
A: Both. Next.js with App Router is primarily MPA (server-rendered) with islands of client interactivity. It gives you MPA benefits (SEO, performance, server logic) with SPA benefits (client interactivity, client-side state). It's the best of both worlds if you can handle the complexity.
Q: If I use React Router, am I locked into SPA architecture?
A: React Router is designed for SPAs. If you want MPA benefits with React, use Next.js or Remix, which provide both client routing and server routing capabilities.
Q: How much does the initial load speed difference actually matter?
A: Very much. Studies show that each 1-second delay in page load can reduce conversions by 7%. For e-commerce sites, initial load speed is critical. For internal dashboards, less so.
Q: Can I make an SPA work offline?
A: Yes, with a Service Worker:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// This allows your SPA to work offline after first load
MPAs typically don't work offline unless you implement caching.
Which architecture are you using, and how's it working for you? Share your routing architecture choices and pain points in the comments!
Google AdSense Placeholder
CONTENT SLOT