Loading...
Loading...
Explains Single Page Applications (SPA), Multi Page Applications (MPA), and hybrid architectures. Use when discussing app architecture decisions, comparing SPA vs MPA, explaining how traditional vs modern web apps work, or when building a new web application.
npx skill4agent add farming-labs/fm-skills web-app-architecturesUser clicks link → Browser requests new HTML → Server renders full page → Browser loads entire page| Aspect | Behavior |
|---|---|
| Navigation | Full page reload on each route change |
| Initial Load | Fast - only current page HTML |
| Subsequent Navigation | Slower - full round trip |
| State | Lost on navigation (unless stored in cookies/sessions) |
| SEO | Excellent - each page is a complete HTML document |
| Server | Handles routing and rendering |
/home → Server renders home.html
/about → Server renders about.html (full page reload)
/products/123 → Server renders product.html (full page reload)Initial: Browser loads shell HTML + JS bundle
Navigation: JS intercepts clicks → Updates URL → Renders new view (no server request for HTML)
Data: Fetch API calls for JSON data only| Aspect | Behavior |
|---|---|
| Navigation | Instant (no page reload) |
| Initial Load | Slower - must download JS bundle |
| Subsequent Navigation | Fast - only fetch data, render client-side |
| State | Preserved across navigation |
| SEO | Challenging - requires additional strategies |
| Server | API endpoints only (JSON responses) |
/home → JS renders Home component
/about → JS renders About component (no server request)
/products/123 → JS fetches product data → Renders Product componentServer renders full HTML → JS hydrates only interactive componentsFirst request: Server renders full HTML + hydrates to SPA
Subsequent: Client-side navigation (SPA behavior)Server starts sending HTML → Browser renders progressively → JS hydrates as content arrives| Requirement | Recommended |
|---|---|
| Content site, SEO critical | MPA or Hybrid with SSR |
| Dashboard, authenticated app | SPA or Hybrid |
| E-commerce (SEO + interactivity) | Hybrid with SSR |
| Minimal JS, fast initial load | MPA with Islands |
| Rich interactions, app-like UX | SPA or Hybrid |
| Limited team, simple stack | MPA |
| Offline support needed | SPA with Service Workers |
// Instead of browser navigation
window.history.pushState({}, '', '/new-route');
// App renders new view without page reloadInitial: core.js (router, framework)
/dashboard: dashboard.chunk.js (loaded when needed)
/settings: settings.chunk.js (loaded when needed)SPA: Component state survives navigation
MPA: State stored in URL params, cookies, localStorage, or server sessions1. USER ACTION: Click a link <a href="/about">
2. BROWSER BEHAVIOR:
- Stops current page execution
- Clears current DOM
- Sends HTTP GET request to server
- Shows loading indicator
3. SERVER RESPONSE:
- Server receives request for "/about"
- Server executes backend code (PHP, Ruby, Python, Node)
- Server queries database if needed
- Server generates complete HTML document
- Server sends HTML back with Content-Type: text/html
4. BROWSER RENDERING:
- Browser receives HTML
- Parses HTML, builds DOM tree
- Discovers CSS/JS, fetches them
- Renders page to screen
- Page is interactive// The core SPA trick: prevent default browser navigation
document.addEventListener('click', (event) => {
const link = event.target.closest('a');
if (link && link.href.startsWith(window.location.origin)) {
// STOP the browser from doing its normal thing
event.preventDefault();
// Instead, WE handle navigation with JavaScript
const path = new URL(link.href).pathname;
// Update the URL bar (without page reload)
window.history.pushState({}, '', path);
// Render the new "page" ourselves
renderRoute(path);
}
});DNS Lookup: ~20-100ms (if not cached)
TCP Connection: ~50-200ms (round trip)
TLS Handshake: ~50-150ms (HTTPS)
Server Processing: ~50-500ms (database, rendering)
Response Transfer: ~50-200ms (HTML size dependent)
Browser Parsing: ~50-100ms
CSS/JS Fetch: ~100-300ms (even if cached, verification)
Render: ~50-100ms
─────────────────────────────────
TOTAL: ~400-1600ms minimumJavaScript Execution: ~5-50ms (route matching, component rendering)
DOM Update: ~5-20ms (virtual DOM diff, real DOM update)
─────────────────────────────────
TOTAL: ~10-70ms
Data Fetch (if needed): +100-500ms (but can show skeleton immediately)1. Download the HTML shell (small, ~5KB)
2. Download the JavaScript bundle (often 200KB-2MB+)
3. Parse the JavaScript (CPU intensive)
4. Execute the JavaScript (initialize framework, router, stores)
5. Render the initial route
MPA First Page: ~400-1600ms to content
SPA First Page: ~800-3000ms to content (must wait for JS)// Push a new entry to browser history (URL changes, no reload)
history.pushState(stateObject, title, '/new-url');
// Replace current entry (URL changes, no reload, no new history entry)
history.replaceState(stateObject, title, '/new-url');
// Listen for back/forward button clicks
window.addEventListener('popstate', (event) => {
// event.state contains the stateObject from pushState
// Your app must now render the appropriate content
renderRoute(window.location.pathname);
});/#/about// MPA: Server returns complete HTML page
// <html><body><h1>Product: Shoes</h1><p>Price: $99</p>...</body></html>
// SPA: Server returns just data
// {"name": "Shoes", "price": 99, "description": "..."}
// SPA renders data into components
const response = await fetch('/api/products/123');
const product = await response.json();
renderProduct(product); // JavaScript creates the HTMLPage Load → JavaScript runs → State created in memory
Navigation → Page destroyed → ALL MEMORY FREED → New page loads → Fresh state
Each page is a clean slate. No memory leaks possible (page is destroyed).
State that must persist: cookies, localStorage, URL parameters, server sessions.App Load → JavaScript runs → State created in memory
Navigation → State PERSISTS → Components mount/unmount → State grows
... hours later ...
Navigation → State still in memory → Potential memory leaks
The app NEVER gets a clean slate. Memory management is YOUR responsibility.document
└── html
├── head
│ ├── title
│ └── link (CSS)
└── body
├── header
│ └── nav
├── main
│ ├── h1
│ └── p
└── footer1. Browser builds DOM from HTML
2. User interacts with page
3. Navigation → ENTIRE DOM DESTROYED
4. New DOM built from new HTML1. Browser builds initial DOM (shell only)
2. JavaScript modifies DOM to add content
3. Navigation → JavaScript MODIFIES DOM (adds/removes nodes)
4. DOM is never destroyed, only mutatedHTTP/1.1 MPA:
Request 1: HTML ────────────────►
Request 2: CSS ─────────────────► (waits or new connection)
Request 3: JS ──────────────────► (waits or new connection)
Request 4: Image ───────────────────► (max 6 parallel)HTTP/2 MPA:
Request 1: HTML ───►
Request 2: CSS ───►
Request 3: JS ───► All sent simultaneously!
Request 4: Image ───►Each request:
- Parse request
- Route to handler
- Query database
- Execute template engine
- Generate HTML string
- Send response
CPU: High (template rendering per request)
Memory: Moderate (per-request state)
Bandwidth: High (sending full HTML each time)Initial request:
- Serve static HTML file (cached by CDN)
- Serve static JS bundle (cached by CDN)
API requests:
- Parse request
- Query database
- Return JSON (smaller than HTML)
CPU: Lower (no template rendering)
Memory: Lower (stateless API)
Bandwidth: Lower (JSON smaller than HTML)Phase 1: Crawling (fast, cheap)
- HTTP request to URL
- Receive HTML response
- Extract links for further crawling
- Index the text content
Phase 2: Rendering (slow, expensive)
- Load page in headless Chrome
- Execute JavaScript
- Wait for content to appear
- Index the rendered contentGET /products/shoes → Receives complete HTML with all content
→ Indexed immediately in Phase 1GET /products/shoes → Receives: <div id="root"></div>
→ No content to index in Phase 1
→ Must wait for Phase 2 (delayed, not guaranteed)Layer 1: HTML (content, accessible to all)
↓
Layer 2: CSS (styling, enhances presentation)
↓
Layer 3: JavaScript (interactivity, enhances experience)Layer 1: JavaScript (required for anything to work)
↓
Layer 2: Content rendered by JavaScript
↓
Layer 3: Everything depends on JSImplementation Note: The patterns and code examples below represent one proven approach to building these systems. There are many valid ways to implement web architectures—the direction shown here is based on patterns used by popular frameworks like React Router, Vue Router, and Astro. Use these as a starting point and adapt based on your framework's specific requirements, constraints, and design philosophy.
// MINIMAL SPA ROUTER IMPLEMENTATION
class Router {
constructor() {
this.routes = new Map();
this.currentRoute = null;
this.outlet = null;
// Listen for browser back/forward
window.addEventListener('popstate', () => this.handleNavigation());
// Intercept link clicks
document.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (link && link.href.startsWith(location.origin)) {
e.preventDefault();
this.navigate(link.pathname);
}
});
}
// Register a route with pattern and handler
route(pattern, handler) {
// Convert /users/:id to regex with named groups
const paramNames = [];
const regexPattern = pattern.replace(/:([^/]+)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
});
this.routes.set(new RegExp(`^${regexPattern}$`), {
handler,
paramNames,
});
}
// Programmatic navigation
navigate(path, { replace = false } = {}) {
if (replace) {
history.replaceState({ path }, '', path);
} else {
history.pushState({ path }, '', path);
}
this.handleNavigation();
}
// Match current URL and render
async handleNavigation() {
const path = location.pathname;
for (const [regex, { handler, paramNames }] of this.routes) {
const match = path.match(regex);
if (match) {
// Extract route params
const params = {};
paramNames.forEach((name, i) => {
params[name] = match[i + 1];
});
// Call route handler
const content = await handler({ params, path });
// Render to outlet
if (this.outlet) {
this.outlet.innerHTML = '';
this.outlet.appendChild(content);
}
return;
}
}
// 404 handling
console.error('No route matched:', path);
}
// Set render target
mount(element) {
this.outlet = element;
this.handleNavigation();
}
}
// Usage
const router = new Router();
router.route('/', () => createElement('h1', 'Home'));
router.route('/users/:id', ({ params }) =>
createElement('h1', `User ${params.id}`)
);
router.mount(document.getElementById('app'));// FILE-BASED ROUTING IMPLEMENTATION (build tool)
import { glob } from 'glob';
import path from 'path';
function generateRoutes(pagesDir) {
// Find all page files
const files = glob.sync('**/*.{js,jsx,ts,tsx}', { cwd: pagesDir });
const routes = files.map(file => {
// Remove extension
let route = file.replace(/\.(js|jsx|ts|tsx)$/, '');
// Handle index files
route = route.replace(/\/index$/, '') || '/';
// Convert [param] to :param
route = route.replace(/\[([^\]]+)\]/g, ':$1');
// Convert [...slug] to *
route = route.replace(/\[\.\.\.([^\]]+)\]/g, '*');
return {
path: '/' + route,
component: path.join(pagesDir, file),
// Generate regex for matching
regex: pathToRegex('/' + route),
};
});
// Sort routes: static before dynamic, specific before catch-all
return routes.sort((a, b) => {
const aScore = routeScore(a.path);
const bScore = routeScore(b.path);
return bScore - aScore;
});
}
function routeScore(path) {
let score = 0;
// Static segments are worth more
const segments = path.split('/').filter(Boolean);
for (const seg of segments) {
if (seg.startsWith(':')) score += 1; // Dynamic: low
else if (seg === '*') score += 0; // Catch-all: lowest
else score += 10; // Static: high
}
return score;
}
// Generate route manifest at build time
const routes = generateRoutes('./src/pages');
writeFileSync('./dist/routes.json', JSON.stringify(routes, null, 2));// NESTED LAYOUT SYSTEM
// Layout discovery at build time
function buildLayoutTree(routePath, pagesDir) {
const segments = routePath.split('/').filter(Boolean);
const layouts = [];
// Walk up the tree finding layouts
let currentPath = pagesDir;
// Check root layout
if (existsSync(path.join(currentPath, '_layout.tsx'))) {
layouts.push(path.join(currentPath, '_layout.tsx'));
}
// Check each segment
for (const segment of segments) {
currentPath = path.join(currentPath, segment);
if (existsSync(path.join(currentPath, '_layout.tsx'))) {
layouts.push(path.join(currentPath, '_layout.tsx'));
}
}
return layouts; // Ordered from root to leaf
}
// Runtime rendering with layouts
async function renderWithLayouts(layouts, pageComponent, props) {
// Start from innermost (page) and wrap outward
let content = await pageComponent(props);
// Wrap with each layout, inside-out
for (let i = layouts.length - 1; i >= 0; i--) {
const Layout = await import(layouts[i]);
content = await Layout.default({ children: content, ...props });
}
return content;
}// STATE PRESERVATION STRATEGIES
class NavigationStateManager {
constructor() {
this.componentStates = new Map();
this.scrollPositions = new Map();
}
// Save state before navigation
saveState(routeKey, componentTree) {
// Serialize component state
const state = this.extractState(componentTree);
this.componentStates.set(routeKey, state);
// Save scroll position
this.scrollPositions.set(routeKey, {
x: window.scrollX,
y: window.scrollY,
});
}
// Restore state after navigation
restoreState(routeKey) {
const state = this.componentStates.get(routeKey);
const scroll = this.scrollPositions.get(routeKey);
return { state, scroll };
}
// Extract serializable state from component tree
extractState(tree) {
// Framework-specific: walk component tree
// Extract useState values, refs, etc.
// Must handle circular references
}
}
// Integration with router
router.beforeNavigate((from, to) => {
stateManager.saveState(from.path, currentComponentTree);
});
router.afterNavigate((to) => {
const { state, scroll } = stateManager.restoreState(to.path);
if (state) {
restoreComponentState(state);
}
if (scroll) {
window.scrollTo(scroll.x, scroll.y);
}
});// MEMORY LEAK PREVENTION
class ComponentRegistry {
constructor() {
this.mounted = new Set();
this.cleanupFns = new Map();
}
mount(component, cleanup) {
this.mounted.add(component);
if (cleanup) {
this.cleanupFns.set(component, cleanup);
}
}
unmount(component) {
// Run cleanup functions
const cleanup = this.cleanupFns.get(component);
if (cleanup) {
cleanup();
this.cleanupFns.delete(component);
}
this.mounted.delete(component);
}
// Called on route change
unmountRoute(routeComponents) {
for (const component of routeComponents) {
this.unmount(component);
}
// Force garbage collection hint
if (global.gc) global.gc();
}
}
// Event listener cleanup pattern
class EventManager {
constructor() {
this.listeners = new WeakMap();
}
addListener(element, event, handler) {
element.addEventListener(event, handler);
// Track for cleanup
if (!this.listeners.has(element)) {
this.listeners.set(element, []);
}
this.listeners.get(element).push({ event, handler });
}
removeAllListeners(element) {
const handlers = this.listeners.get(element);
if (handlers) {
for (const { event, handler } of handlers) {
element.removeEventListener(event, handler);
}
this.listeners.delete(element);
}
}
}// PARTIAL HYDRATION IMPLEMENTATION
// 1. Mark interactive components at build time
// <Button client:load>Click me</Button>
// 2. Extract islands during SSR
function extractIslands(html, components) {
const islands = [];
// Find island markers in HTML
const regex = /<island-(\w+) props="([^"]+)">/g;
let match;
while ((match = regex.exec(html)) !== null) {
islands.push({
id: match[1],
props: JSON.parse(decodeURIComponent(match[2])),
component: components[match[1]],
});
}
return islands;
}
// 3. Hydrate only islands on client
function hydrateIslands(islands) {
for (const island of islands) {
const element = document.querySelector(`[data-island="${island.id}"]`);
if (element) {
// Load component code
const Component = await import(island.component);
// Hydrate this specific element
hydrateRoot(element, <Component {...island.props} />);
}
}
}
// 4. Island web component wrapper
class IslandElement extends HTMLElement {
async connectedCallback() {
// Defer hydration based on strategy
const strategy = this.getAttribute('client');
switch (strategy) {
case 'load':
await this.hydrate();
break;
case 'idle':
requestIdleCallback(() => this.hydrate());
break;
case 'visible':
const observer = new IntersectionObserver(async ([entry]) => {
if (entry.isIntersecting) {
observer.disconnect();
await this.hydrate();
}
});
observer.observe(this);
break;
}
}
async hydrate() {
const component = this.getAttribute('component');
const props = JSON.parse(this.getAttribute('props') || '{}');
const Component = await import(`/components/${component}.js`);
hydrateRoot(this, createElement(Component.default, props));
}
}
customElements.define('island-component', IslandElement);