Episode 2 — React Frontend Architecture NextJS / 2.1 — Introduction to React
2.1.c — Single Page Applications
In one sentence: A Single Page Application loads one HTML page and dynamically rewrites its content using JavaScript instead of requesting entire new pages from the server, creating a fluid, app-like experience in the browser.
Navigation: ← Declarative vs Imperative UI · Next → Real DOM vs Virtual DOM
1. Traditional Multi-Page Applications (MPAs)
Before SPAs existed, every website worked the same way: click a link, the browser requests a new page from the server, the server sends back a complete HTML document, and the browser renders it from scratch.
The Request-Response Cycle
User clicks "About" link
|
v
[1] Browser sends HTTP request
GET /about HTTP/1.1
Host: example.com
|
v
[2] DNS resolution (if not cached)
example.com -> 93.184.216.34
Time: 20-100ms
|
v
[3] TCP handshake (SYN, SYN-ACK, ACK)
Time: 30-100ms (depends on distance)
|
v
[4] TLS handshake (if HTTPS)
Time: 50-150ms
|
v
[5] Server receives request
- Routes to correct handler
- Queries database
- Renders HTML template
- Sends response
Time: 50-500ms
|
v
[6] Browser receives HTML response
|
v
[7] Browser DESTROYS current page
- All JavaScript state is lost
- All DOM elements are destroyed
- All event listeners are removed
- Any in-progress animations stop
- Form inputs lose their values (if not submitted)
- Scroll position resets to top
|
v
[8] Browser parses new HTML
- Tokenizer breaks HTML into tokens
- Tree builder constructs DOM
Time: 5-50ms
|
v
[9] Browser discovers and fetches external resources
- CSS files (render-blocking)
- JavaScript files
- Images, fonts
Each resource: DNS + TCP + TLS + download
|
v
[10] CSSOM construction (from CSS files)
Time: 5-20ms
|
v
[11] Render tree construction (DOM + CSSOM)
|
v
[12] Layout calculation
|
v
[13] Paint
|
v
[14] User sees the new page
Total time: 500ms - 3000ms+
User sees: white flash, then new page
What the User Experiences
MPA page navigation:
Time: 0ms | Click "About" link
Time: 0-50ms | Screen goes white (or shows loading indicator)
Time: 50ms | Nothing visible (network request in progress)
Time: 200ms | Still nothing (server processing)
Time: 500ms | HTML arrives, starts rendering
Time: 600ms | CSS loaded, layout calculated
Time: 700ms | First meaningful paint
Time: 800ms | JavaScript loaded, page becomes interactive
Time: 1000ms | Images start loading
Time: 1500ms | Fonts loaded (possible layout shift)
The user stares at a white/blank screen for 500-700ms.
On slow connections (3G): 2000-5000ms.
Server-Side Rendering in the MPA Era
Every major web framework from the 2000s used this model:
// PHP (early 2000s - still used)
<?php
$user = get_user_from_session();
$posts = get_posts_by_user($user->id);
?>
<html>
<head><title><?= $user->name ?>'s Posts</title></head>
<body>
<h1>Posts by <?= htmlspecialchars($user->name) ?></h1>
<?php foreach ($posts as $post): ?>
<div class="post">
<h2><?= htmlspecialchars($post->title) ?></h2>
<p><?= nl2br(htmlspecialchars($post->content)) ?></p>
</div>
<?php endforeach; ?>
</body>
</html>
# Django (Python, 2005)
def post_list(request):
user = request.user
posts = Post.objects.filter(author=user)
return render(request, 'posts/list.html', {
'user': user,
'posts': posts,
})
# Ruby on Rails (2004)
class PostsController < ApplicationController
def index
@user = current_user
@posts = @user.posts.order(created_at: :desc)
# Renders app/views/posts/index.html.erb by convention
end
end
<!-- Rails ERB template -->
<h1>Posts by <%= @user.name %></h1>
<% @posts.each do |post| %>
<div class="post">
<h2><%= post.title %></h2>
<p><%= post.content %></p>
</div>
<% end %>
Every template engine does the same thing: fill HTML templates with data on the server, send the complete HTML to the browser. Simple, reliable, and the browser handles everything. But every interaction that needs new data requires a full round trip.
2. What "Single Page Application" Actually Means
An SPA is a web application that:
- Loads one HTML file (typically nearly empty)
- Downloads a JavaScript bundle
- JavaScript takes over rendering entirely
- Navigating between "pages" swaps content with JavaScript — no new HTML is fetched from the server
- Data is fetched via API calls (JSON), not page loads (HTML)
The One HTML File
<!-- This is the ENTIRE HTML for a React SPA -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
That's it. One empty <div>. The JavaScript bundle (main.jsx + all its imports, bundled) does everything else. It creates the navigation bar, the sidebar, the content, the footer — everything is generated by JavaScript and injected into that <div id="root">.
How Navigation Works in an SPA
Traditional site:
/home → server sends home.html
/about → server sends about.html
/contact → server sends contact.html
(Each is a separate HTML document)
SPA:
/home → JavaScript shows <HomePage /> component
/about → JavaScript shows <AboutPage /> component
/contact → JavaScript shows <ContactPage /> component
(Same HTML document, JavaScript swaps the content)
The URL in the browser still changes (so bookmarks and back button work), but the browser never actually requests a new HTML page.
3. How SPAs Work: Client-Side Routing
Client-side routing intercepts browser navigation and handles it with JavaScript instead of making a server request.
The History API
HTML5 introduced the History API, which lets JavaScript manipulate the browser's URL and navigation history without triggering a page load.
// pushState: Add a new entry to browser history
// The browser URL changes, but NO request is made to the server
window.history.pushState(
{ page: 'about' }, // state object (can be retrieved later)
'', // title (mostly ignored by browsers)
'/about' // new URL
);
// replaceState: Replace the current history entry
window.history.replaceState(
{ page: 'about' },
'',
'/about'
);
// popstate event: Fires when user clicks Back/Forward
window.addEventListener('popstate', function (event) {
// event.state contains the state object from pushState
console.log('User navigated to:', window.location.pathname);
console.log('State:', event.state);
// Render the appropriate page content
renderPage(window.location.pathname);
});
A Minimal SPA Router
// Simplified version of what React Router does internally
const routes = {
'/': function () { return '<h1>Home</h1><p>Welcome!</p>'; },
'/about': function () { return '<h1>About</h1><p>About us.</p>'; },
'/contact': function () { return '<h1>Contact</h1><p>Get in touch.</p>'; },
};
function navigate(path) {
// Update browser URL without page reload
window.history.pushState(null, '', path);
// Render the matching route
render(path);
}
function render(path) {
const routeHandler = routes[path] || function () {
return '<h1>404</h1><p>Page not found.</p>';
};
document.getElementById('root').innerHTML = routeHandler();
}
// Handle Back/Forward buttons
window.addEventListener('popstate', function () {
render(window.location.pathname);
});
// Intercept link clicks (prevent default navigation)
document.addEventListener('click', function (e) {
// Only handle links within our app
if (e.target.tagName === 'A' && e.target.getAttribute('href').startsWith('/')) {
e.preventDefault();
navigate(e.target.getAttribute('href'));
}
});
// Initial render
render(window.location.pathname);
React Router (What You Actually Use)
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
{/* Links use the router, not browser default navigation */}
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
</nav>
{/* Routes swap which component is rendered */}
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}
When the user clicks <Link to="/about">, React Router:
- Calls
history.pushStateto update the URL - Re-renders the
<Routes>component <Routes>matches/aboutand renders<AboutPage />- The old page component unmounts, the new one mounts
- No HTTP request to the server. No page reload.
Hash Routing vs History API Routing
There are two approaches to client-side routing:
Hash routing uses the URL fragment (the part after #):
https://example.com/#/about
https://example.com/#/contact
https://example.com/#/users/123
// Hash changes don't trigger server requests
// The browser fires a 'hashchange' event instead
window.addEventListener('hashchange', function () {
const path = window.location.hash.slice(1); // Remove the '#'
renderPage(path);
});
// Navigate by changing the hash
function navigate(path) {
window.location.hash = path;
}
History API routing uses clean URLs:
https://example.com/about
https://example.com/contact
https://example.com/users/123
| Feature | Hash Routing | History API Routing |
|---|---|---|
| URLs | /#/about (ugly) | /about (clean) |
| Server config | None needed | Must serve index.html for all routes |
| SEO | Historically poor | Better |
| Browser support | All browsers, even old ones | IE10+ (all modern browsers) |
| Deployment | Works on static file servers | Needs URL rewrite rules |
| React Router | <HashRouter> | <BrowserRouter> |
Modern practice: Use History API routing (BrowserRouter). Hash routing is a fallback for environments where you cannot configure the server.
The Server Configuration Requirement
With History API routing, there's a catch: if the user directly visits https://example.com/about (types it in the URL bar or refreshes the page), the browser sends a request to the server for /about. The server must respond with the same index.html for ALL routes, so the JavaScript can load and handle the routing client-side.
Without server config:
Browser requests GET /about
Server looks for /about/index.html → 404 Not Found!
With server config (Nginx example):
location / {
try_files $uri $uri/ /index.html;
}
Browser requests GET /about
Server can't find /about/index.html
Falls back to /index.html ← SPA loads
React Router reads URL "/about"
Renders <AboutPage />
4. History of Single Page Applications
SPAs did not appear overnight. They evolved over a decade.
Timeline
1999 - Microsoft introduces XMLHttpRequest (IE5)
First AJAX capability — fetch data without page reload
2004 - Gmail launches
First widely-used SPA-like experience
AJAX-powered email client that feels like a desktop app
2005 - Google Maps launches
Drag-and-pan interface impossible with page reloads
Jesse James Garrett coins the term "AJAX"
2006 - jQuery released
Makes AJAX easy: $.ajax(), $.get(), $.post()
2008 - Chrome V8 engine released
JavaScript performance increases 10-100x
SPAs become feasible for complex apps
2010 - Backbone.js released (first popular SPA framework)
AngularJS released by Google
2011 - Ember.js released
2013 - React released by Facebook
2014 - Vue.js released by Evan You
2015 - React Native released
Redux released
2016 - Angular 2 released (complete rewrite of AngularJS)
Create React App released
2019 - Svelte 3 released (compiled SPA framework)
2020+ - Next.js, Remix, Nuxt popularize hybrid approaches
(SPAs that also do server rendering)
Gmail: The First Killer SPA (2004)
Gmail was revolutionary. Before Gmail, web email meant:
- Click "Inbox" → full page reload
- Click an email → full page reload
- Click "Reply" → full page reload
- Send the reply → full page reload
- Go back to Inbox → full page reload
Gmail loaded once and never reloaded. Clicking an email just swapped the content. Composing a reply opened an inline editor. Sending happened in the background. The entire experience felt like a desktop application — Outlook, but in a browser.
Gmail proved that web applications could match desktop applications in responsiveness and user experience. It was the proof-of-concept that made SPAs a serious architectural choice.
Google Maps: The Impossibility Proof (2005)
Google Maps was the definitive argument for SPAs. A pan-and-zoom map interface is physically impossible with page reloads. You cannot request a new HTML page every time the user drags the map a few pixels. The only way to build this experience is to handle everything client-side, fetching map tiles asynchronously as the user navigates.
5. SPA Architecture
SPA Architecture:
+----------------------------------+
| BROWSER |
| |
| +----------------------------+ |
| | SPA (JavaScript) | |
| | | |
| | +--------+ +---------+ | |
| | | Router | | State | | |
| | | | | Manager | | |
| | +---+----+ +----+----+ | |
| | | | | |
| | +---v----+ +----v----+ | |
| | | Pages/ | | API | | |
| | | Views | | Client | | |
| | +---+----+ +----+----+ | |
| | | | | |
| | +---v-----------v----+ | |
| | | Component Tree | | |
| | | (Virtual DOM) | | |
| | +---------+----------+ | |
| | | | |
| +----------------------------+ |
| | |
| +-------v-------+ |
| | Real DOM | |
| | (What user | |
| | sees) | |
| +---------------+ |
| |
+----------------------------------+
| ^
| API calls | JSON responses
v |
+----------------------------------+
| API SERVER |
| +----------+ +-------------+ |
| | REST API | | Database | |
| | Endpoints| | | |
| +----------+ +-------------+ |
+----------------------------------+
Key characteristics:
- One HTML file loaded initially
- JavaScript handles everything: rendering, routing, state
- Data via API: JSON over HTTP, not HTML pages
- Client-side state: The app state lives in JavaScript memory
- No full page reloads: Navigation is instant
6. Advantages of SPAs
Speed After Initial Load
MPA (Multi-Page App) navigation:
Click link → 500-2000ms → New page visible
Click link → 500-2000ms → New page visible
Click link → 500-2000ms → New page visible
SPA navigation:
Initial load → 1000-3000ms → App visible
Click link → 10-50ms → New content visible (instant!)
Click link → 10-50ms → New content visible (instant!)
Click link → 10-50ms → New content visible (instant!)
After the initial load, SPA navigation feels near-instant because:
- No DNS resolution
- No TCP/TLS handshake
- No HTML parsing
- No CSS re-evaluation
- No JavaScript re-download and re-execution
- Just swap some DOM elements and maybe fetch JSON data
Rich, Interactive UX
SPAs enable interactions that are impossible or extremely difficult with MPAs:
- Drag-and-drop interfaces (Trello, Notion)
- Real-time collaboration (Google Docs, Figma)
- Complex form flows (multi-step wizards with validation)
- Infinite scroll (social media feeds)
- Persistent audio/video (Spotify — music keeps playing as you navigate)
- Animated transitions (smooth page transitions, micro-interactions)
- Offline capabilities (with Service Workers)
Reduced Server Load
MPA server workload:
Every page view = server renders HTML + sends full page
1000 users x 10 pages/session = 10,000 full page renders
SPA server workload:
Initial load = one static HTML file (can be CDN-cached)
Every navigation = maybe one JSON API call
1000 users x 10 navigations = 1 HTML + 10,000 small JSON responses
JSON responses are smaller than full HTML pages.
Static HTML can be served by CDN, not your server.
The server only handles API logic, not page rendering.
Decoupled Frontend and Backend
In an SPA architecture, the frontend and backend are separate applications:
MPA:
One codebase = server + templates + routes + views
Frontend team and backend team edit the SAME files
Deploy = deploy everything together
SPA:
Frontend codebase = React app (deployed to CDN)
Backend codebase = API server (deployed independently)
Frontend team and backend team have SEPARATE repos
Different deployment pipelines
Different scaling strategies
Can use different languages (React frontend + Python backend)
Can replace either side independently
This separation enables:
- Mobile app reuse: iOS/Android apps use the same API
- Multiple frontends: Web, mobile app, CLI tool — all using one API
- Independent deployments: Ship frontend fixes without touching backend
- Team autonomy: Frontend team and backend team work independently
Mobile-Like Experience
SPAs feel like native mobile apps:
- Instant navigation
- Smooth transitions
- Pull-to-refresh
- Persistent state across "pages"
- Push notifications (with Service Workers)
- Add to home screen (PWA)
7. Disadvantages of SPAs
SPAs are not without significant trade-offs.
Initial Load Time (The "Blank Page" Problem)
What the user sees when loading an SPA for the first time:
Time: 0ms | Blank white page
Time: 100ms | Still blank (HTML downloaded, but it's just an empty <div>)
Time: 500ms | Still blank (JavaScript bundle downloading)
Time: 1000ms | Still blank (JavaScript parsing and executing)
Time: 1200ms | Spinner appears (React has loaded, but data is still fetching)
Time: 1800ms | Content appears (API data received, rendered)
vs MPA:
Time: 0ms | Blank/loading
Time: 500ms | Content appears (HTML with data arrived from server)
The SPA "Time to First Meaningful Paint" is worse because:
- Download empty HTML
- Download JavaScript bundle (can be 500KB - 5MB)
- Parse and execute JavaScript
- JavaScript makes API calls for data
- API responds with data
- JavaScript renders the UI
That's at least 2 network round trips (HTML + JS bundle, then API call) before the user sees content, vs 1 round trip for an MPA.
SEO Challenges
Search engine crawlers traditionally requested a URL and looked at the HTML response. An SPA's HTML is an empty <div> — there's no content for the crawler to index.
<!-- What Google's crawler sees when visiting an SPA -->
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<div id="root"></div>
<!-- All content is generated by JavaScript -->
<!-- Crawler may or may not execute JavaScript -->
</body>
</html>
While Google's crawler can now execute JavaScript, there are issues:
- Not all search engines execute JavaScript (Bing is inconsistent, many others don't)
- JavaScript rendering is expensive for crawlers — they may skip it
- Content that requires user interaction (scroll, click) won't be indexed
- Social media link previews (Open Graph) don't execute JavaScript
Solutions:
- Server-Side Rendering (Next.js, Remix)
- Static Site Generation (Next.js, Gatsby)
- Pre-rendering services (Prerender.io)
Complexity
SPAs move complexity from the server to the client:
MPA complexity budget:
Server: routing, data, rendering, auth (80%)
Client: sprinkle of jQuery for interactivity (20%)
SPA complexity budget:
Server: API endpoints, auth (40%)
Client: routing, rendering, state management,
caching, error handling, auth tokens,
loading states, optimistic updates,
code splitting, bundle optimization (60%)
Things you now manage in client code:
- Routing: Not just URL matching, but guards, redirects, lazy loading, scroll restoration
- State management: Local state, global state, server state, URL state
- Authentication: Token storage, refresh flows, protected routes
- Error handling: Network errors, API errors, rendering errors, offline handling
- Loading states: Skeleton screens, spinners, progress indicators
- Caching: When to re-fetch, stale data, optimistic updates
- Memory management: Prevent leaks from event listeners, subscriptions, timers
Memory Leaks
In an MPA, memory leaks are impossible in practice — the page is destroyed and recreated on every navigation. In an SPA, the page lives for the entire session. Memory leaks accumulate.
// Common SPA memory leak patterns:
// 1. Forgotten event listeners
function BadComponent() {
useEffect(() => {
window.addEventListener('resize', handleResize);
// BUG: No cleanup — listener accumulates on every mount
}, []);
}
// 2. Forgotten intervals/timeouts
function BadPollingComponent() {
useEffect(() => {
const interval = setInterval(fetchData, 5000);
// BUG: No cleanup — interval runs forever even after unmount
}, []);
}
// 3. Stale closures holding references
function BadSearchComponent() {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
// BUG: If component unmounts before fetch completes,
// setResults tries to update an unmounted component
// AND the closure holds a reference to the old component
}, [query]);
}
Browser Back/Forward Behavior
Users expect the back button to go to the previous "page." In an SPA, you have to implement this behavior explicitly:
- Scroll position restoration
- Form state preservation
- Modal/dialog state (should "back" close a modal?)
- Search filter state (should "back" undo a filter change?)
None of these come for free in an SPA. In an MPA, the browser handles all of it natively.
Accessibility Concerns
SPAs break some default browser accessibility features:
MPA accessibility (free):
- Page title updates on navigation
- Screen reader announces new page load
- Focus resets to top of page
- Browser loading indicator shows progress
SPA accessibility (must implement manually):
- Update document.title on every route change
- Announce route changes to screen readers (aria-live region)
- Manage focus on navigation (where should focus go?)
- Show loading indicators for async content
- Handle keyboard navigation for custom UI
8. SPA vs MPA: Detailed Comparison
| Aspect | SPA | MPA |
|---|---|---|
| Initial load | Slower (download JS bundle) | Faster (HTML with content) |
| Subsequent navigation | Near-instant | Full page reload (500ms+) |
| SEO | Challenging (needs SSR/SSG) | Excellent (content in HTML) |
| UX smoothness | App-like, fluid | Page flashes between navigations |
| Offline support | Possible (Service Workers) | Very limited |
| Server load | Lower (JSON APIs, CDN for static) | Higher (render HTML per request) |
| State persistence | Maintained during session | Lost on every navigation |
| Browser features | Must reimplement (back, scroll) | Native behavior |
| Complexity | High (client-side everything) | Lower (server handles most) |
| Time to Interactive | Slower (JS must load and execute) | Faster (HTML is already interactive) |
| Real-time features | Natural fit (WebSockets, SSE) | Possible but awkward |
| Mobile feel | Excellent | Poor |
| Deployment | CDN for frontend + API server | One server |
| Bundle size | Concern (all JS upfront or code-split) | Not applicable |
| Memory usage | Grows over session (leak risk) | Resets every navigation |
| Analytics | Custom implementation needed | Standard page view tracking |
| Accessibility | Manual effort required | Many features are free |
| Development speed | Faster for complex UIs | Faster for content sites |
| Team scaling | Frontend + Backend teams separate | Full-stack teams |
9. When to Use SPA vs MPA
Use an SPA When:
- Rich interactivity required — drag-and-drop, real-time updates, complex forms
- App-like experience needed — dashboards, admin panels, productivity tools
- Users have long sessions — they stay in the app for minutes/hours
- Real-time data is core — chat, collaboration, live dashboards
- Multiple platforms share an API — web + mobile app + desktop app
- Offline functionality needed — works without internet
Examples: Gmail, Slack, Trello, Figma, Spotify, Notion, VS Code (web)
Use an MPA When:
- SEO is critical — blog, e-commerce product pages, marketing sites
- Content-heavy site — news, documentation, wiki
- Simple interactions — mostly reading, some forms
- Broad device support needed — works even with JavaScript disabled
- Fast initial load matters more than navigation speed
- Small team without frontend specialists
Examples: Wikipedia, news sites, e-commerce catalogs, documentation sites, blogs
Decision Framework
SPA
^
|
Interactivity: | Dashboard SaaS App
HIGH | . .
| Trello Figma
|
| E-commerce
| checkout .
|
| Blog with
| comments .
Interactivity: |
LOW ------+------------------------->
|
Content:LOW Content:HIGH
|
| Landing E-commerce
| page . catalog .
|
| Blog . Docs site .
|
v
MPA
The Hybrid Answer
In practice, most modern applications use a hybrid approach:
Pure MPA:
Every page is server-rendered HTML
(PHP, Rails, Django — traditional approach)
Pure SPA:
One HTML file, all JavaScript
(Create React App, Vite — classic approach)
Hybrid (modern):
Server renders initial HTML (fast first load, good SEO)
Client hydrates and takes over (SPA-like interactivity)
Some routes are server-rendered, others client-rendered
(Next.js, Remix, Nuxt, SvelteKit)
10. How the History API Works Under the Hood
The History API is what makes SPAs possible. Understanding it deeply helps debug routing issues.
The History Stack
The browser maintains a stack of history entries:
Browser history stack (LIFO):
Top → /contact (current page)
/about
/products/5
/products
/
Back button: pops /contact, shows /about
Forward button: pushes /contact back, shows it
pushState In Detail
// Syntax:
history.pushState(stateObject, title, url);
// Example:
history.pushState(
{ productId: 42, scrollY: 500 }, // State: arbitrary data stored with this entry
'', // Title: mostly ignored by browsers
'/products/42' // URL: what shows in the address bar
);
// What happens:
// 1. New entry added to history stack
// 2. URL bar updates to /products/42
// 3. NO page reload
// 4. NO HTTP request
// 5. NO popstate event fired (pushState doesn't trigger popstate!)
// 6. The state object is stored with the history entry
replaceState In Detail
// replaceState modifies the CURRENT entry instead of adding a new one
history.replaceState(
{ productId: 42, scrollY: 0 },
'',
'/products/42'
);
// Useful for:
// - Correcting the URL without adding a history entry
// - Storing updated state (like scroll position) for the current page
// - Redirects (replace /old-url with /new-url)
The popstate Event
// popstate fires when user clicks Back or Forward (or calls history.back/forward)
// It does NOT fire on pushState or replaceState
window.addEventListener('popstate', function (event) {
console.log('Location:', window.location.pathname);
console.log('State:', event.state);
// event.state is the state object from the pushState/replaceState call
// that created this history entry
// If the user navigated to this page normally (not via pushState),
// event.state is null
});
// You can also navigate programmatically:
history.back(); // Same as clicking Back button
history.forward(); // Same as clicking Forward button
history.go(-2); // Go back 2 entries
history.go(3); // Go forward 3 entries
Common Routing Bugs
Bug 1: popstate doesn't fire on pushState
// WRONG: Expecting popstate to fire when you navigate
function navigate(path) {
history.pushState(null, '', path);
// popstate does NOT fire here!
// You must call your render function manually
}
// RIGHT:
function navigate(path) {
history.pushState(null, '', path);
renderPage(path); // Call render explicitly
}
Bug 2: Initial load has no state
// WRONG: Assuming event.state is always present
window.addEventListener('popstate', function (event) {
renderPage(event.state.page); // TypeError if state is null!
});
// RIGHT: Fall back to URL parsing
window.addEventListener('popstate', function (event) {
const page = event.state?.page || window.location.pathname;
renderPage(page);
});
Bug 3: Refresh loses SPA context
User flow:
1. User visits / (SPA loads)
2. User navigates to /products/42 (pushState, no reload)
3. User refreshes the page
4. Browser requests /products/42 from server
5. Server returns 404 (no such file!)
Fix: Server must return index.html for ALL routes
11. Bundle Size and Code Splitting
One of the biggest SPA challenges is bundle size — all the JavaScript must download before the app works.
The Problem
A typical SPA JavaScript bundle:
React library: ~45 KB (gzipped)
React DOM: ~130 KB (gzipped)
React Router: ~15 KB (gzipped)
State management: ~5-20 KB (gzipped)
UI component library: ~50-200 KB (gzipped)
Your application code: ~50-500 KB (gzipped)
------------------------------------------
Total: ~300 KB - 900 KB (gzipped)
On 4G (10 Mbps): ~250ms - 700ms to download
On 3G (1.5 Mbps): ~1.5s - 5s to download
On slow 3G: ~5s - 15s to download
Plus: Parse + compile + execute time
JavaScript is the most expensive resource per byte.
Code Splitting: The Solution
Instead of one giant bundle, split the code into smaller chunks that load on demand:
import { lazy, Suspense } from 'react';
// These imports don't load immediately
// They create "split points" — separate bundles that load on demand
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
</Routes>
</Suspense>
);
}
Without code splitting:
Initial load downloads: ALL pages (500 KB)
User visits /: 500 KB downloaded, 1 page displayed
With code splitting:
Initial load downloads: Core + HomePage (150 KB)
User visits /about: Downloads AboutPage chunk (30 KB)
User visits /dashboard: Downloads DashboardPage chunk (100 KB)
If user never visits /dashboard, that 100 KB is never downloaded.
Route-Based vs Component-Based Splitting
Route-based splitting (most common):
Each page/route is a separate chunk
Loads when user navigates to that route
/ → main.js (core) + home.js
/dashboard → main.js (core) + dashboard.js
/settings → main.js (core) + settings.js
Component-based splitting (for heavy components):
Heavy components within a page load on demand
Dashboard page:
- Header, sidebar, basic widgets → dashboard.js (loads immediately)
- Heavy chart library → charts.js (loads when user scrolls to charts)
- PDF export dialog → pdf-export.js (loads when user clicks "Export")
12. SPA Frameworks Comparison
| Framework | Language | Released | Approach | Bundle Size (min+gz) | Learning Curve |
|---|---|---|---|---|---|
| React | JSX | 2013 | Virtual DOM | ~45 KB (core) | Medium |
| Vue | SFC | 2014 | Virtual DOM | ~33 KB | Low-Medium |
| Angular | TypeScript | 2016 | Incremental DOM | ~65 KB | High |
| Svelte | .svelte | 2019 | Compiler (no runtime) | ~2 KB | Low |
| Solid | JSX | 2021 | Fine-grained reactivity | ~7 KB | Medium |
| Preact | JSX | 2015 | Virtual DOM (React-compatible) | ~4 KB | Low |
React
// React: JSX + hooks + Virtual DOM
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
Largest ecosystem, most jobs, most community resources. The default choice for most teams.
Vue
<!-- Vue: Single File Components -->
<template>
<button @click="count++">Count: {{ count }}</button>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
Gentler learning curve, excellent documentation, strong in Asia-Pacific market.
Angular
// Angular: TypeScript + decorators + dependency injection
@Component({
selector: 'app-counter',
template: `<button (click)="increment()">Count: {{count}}</button>`
})
export class CounterComponent {
count = 0;
increment() { this.count++; }
}
Enterprise-focused, batteries-included framework. Steeper learning curve, but consistent patterns across large teams.
Svelte
<!-- Svelte: Compiled, no virtual DOM -->
<script>
let count = 0;
</script>
<button on:click={() => count++}>
Count: {count}
</button>
Compiles components to vanilla JavaScript at build time. No runtime framework overhead. Excellent performance, clean syntax.
13. The Pendulum: SPAs to SSR to RSC
The web architecture has swung back and forth:
Timeline of web rendering approaches:
1995-2005: Server-Rendered HTML (MPA)
PHP, ASP, JSP render HTML on every request
Simple, SEO-friendly, slow navigation
2005-2015: SPAs Take Over
Gmail, Angular, React push everything to the client
Fast navigation, poor initial load, SEO problems
2016-2020: Server-Side Rendering Returns (SSR)
Next.js: "What if we render React on the server for the first load,
then hydrate it on the client for SPA-like navigation?"
Best of both worlds, but increased complexity
2020-2024: The Hybrid Era
Remix: "Lean into web standards, progressive enhancement"
Astro: "Send zero JS by default, add interactivity where needed"
React Server Components: "Some components run on the server,
some on the client, mix them freely"
The pendulum:
Server ──> Client ──> Hybrid (current)
Next.js SSR (2016+)
// Next.js: Server-side render, then hydrate on client
// pages/products/[id].js
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
return { props: { product } };
}
export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}
// What happens:
// 1. Server runs getServerSideProps (fetches data)
// 2. Server renders the component to HTML
// 3. Browser receives complete HTML (fast first paint, good SEO)
// 4. Browser downloads React bundle
// 5. React "hydrates" — attaches event listeners to existing HTML
// 6. Page is now a full SPA (client-side navigation from here)
React Server Components (2022+)
// app/products/[id]/page.tsx (Next.js App Router)
// This component runs on the SERVER only
// It can directly access the database
// Its code is NOT sent to the browser
async function ProductPage({ params }) {
const product = await db.products.findUnique({
where: { id: params.id }
});
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* This component runs on the CLIENT */}
<AddToCartButton productId={product.id} />
</div>
);
}
Server Components represent the latest evolution: the server renders what it can (data fetching, static content), and the client handles what it must (interactivity, user input). The best of both worlds — fast initial load, good SEO, and rich interactivity.
14. Key Takeaways
-
An SPA loads one HTML file and uses JavaScript to handle everything — rendering, routing, state management. The browser never navigates to a different HTML page.
-
Client-side routing uses the History API (
pushState,popstate) to change the URL without making a server request. This creates the illusion of page navigation. -
SPAs trade initial load time for navigation speed. The first load is slower (downloading the JS bundle), but every subsequent "page change" is near-instant.
-
SEO is the SPA Achilles' heel. Search engine crawlers see an empty
<div>. Solutions include SSR (Next.js), SSG, and pre-rendering. -
SPAs shift complexity to the client. Routing, state management, caching, error handling, loading states, memory management — all become your responsibility in JavaScript.
-
Code splitting is essential. Without it, users download code for every page even if they only visit one.
-
MPAs are not dead. Content-heavy, SEO-critical sites are often better served by server-rendered HTML. The right architecture depends on the use case.
-
The modern answer is hybrid. Next.js, Remix, and React Server Components combine server rendering with client-side interactivity, giving you the benefits of both approaches.
Explain-It Challenge
-
Draw the network request timeline for loading a page in an MPA vs an SPA. Include DNS, TCP, server processing, and rendering. Which is faster for the first visit? Which is faster for the tenth navigation within the same session?
-
A product manager says: "Our blog gets 80% of traffic from Google search. Should we build it as an SPA?" What is your recommendation and why? What if the same blog also needs a rich text editor for authors?
-
Explain why refreshing an SPA at
/products/42can result in a 404 error, and describe two different ways to fix this (one server-side, one client-side).
Navigation: ← Declarative vs Imperative UI · Next → Real DOM vs Virtual DOM