SPA (Single Page Application)
Definition
SPA (Single Page Application) is a web application that loads a single HTML page and dynamically updates content as the user interacts with the app, without full page reloads.
Think of it as "a desktop app in your browser" - smooth, fast, and responsive like Gmail, Twitter, or Facebook.
What Problem Does It Solve?
The Problem: Traditional Multi-Page Apps are Slow
Traditional Multi-Page App (MPA):
Problems:
- ❌ Full page reload on every navigation (white flash)
- ❌ Re-download same CSS/JS on every page
- ❌ Lose JavaScript state (form data, scroll position)
- ❌ Slow, clunky user experience
Single Page App (SPA):
Benefits:
- ✅ Fast, smooth navigation (no white flash)
- ✅ Only fetch data (JSON), not full pages
- ✅ Maintain state across navigation
- ✅ Desktop-like user experience
How It Works
SPA Architecture
SPA Lifecycle
Core Concepts
1. Client-Side Routing
Handle navigation without server requests:
// React Router example
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/products">Products</Link>
<Link to="/about">About</Link>
</nav>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</BrowserRouter>
);
}
2. Dynamic Content Loading
Fetch data without page reloads:
function ProductsPage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch data via API (no page reload)
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) return <div>Loading products...</div>;
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
3. State Management
Maintain application state across navigation:
// Global state persists across page changes
const [cart, setCart] = useState([]);
function addToCart(product) {
setCart([...cart, product]);
// Cart state maintained even when navigating to other pages!
}
Real-World Examples
Example 1: Basic SPA
import React, { useState } from 'react';
import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';
function App() {
const [user, setUser] = useState(null);
return (
<BrowserRouter>
<Header user={user} />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/product/:id" element={<ProductDetailPage />} />
<Route path="/cart" element={<CartPage user={user} />} />
<Route path="/login" element={<LoginPage onLogin={setUser} />} />
</Routes>
</BrowserRouter>
);
}
function ProductsPage() {
const navigate = useNavigate();
const viewProduct = (id) => {
// Navigate without page reload
navigate(`/product/${id}`);
};
return (
<div>
<h1>Products</h1>
{products.map(product => (
<button key={product.id} onClick={() => viewProduct(product.id)}>
{product.name}
</button>
))}
</div>
);
}
Example 2: SPA with API Integration
function Dashboard() {
const [data, setData] = useState(null);
const [refreshing, setRefreshing] = useState(false);
// Initial load
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
setRefreshing(true);
const response = await fetch('/api/dashboard');
const json = await response.json();
setData(json);
setRefreshing(false);
};
const handleRefresh = () => {
// Refresh data without page reload
fetchDashboardData();
};
return (
<div>
<button onClick={handleRefresh} disabled={refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
{data && (
<>
<Stats data={data.stats} />
<Charts data={data.charts} />
<RecentActivity data={data.activity} />
</>
)}
</div>
);
}
Example 3: SPA with Authentication
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Check if user is logged in (on app load)
useEffect(() => {
fetch('/api/me')
.then(res => res.json())
.then(user => {
setUser(user);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, []);
if (loading) return <div>Loading app...</div>;
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage onLogin={setUser} />} />
<Route
path="/*"
element={
user ? <AuthenticatedApp user={user} /> : <Navigate to="/login" />
}
/>
</Routes>
</BrowserRouter>
);
}
SPA vs Traditional Multi-Page App
Comparison Table
| Aspect | Multi-Page App (MPA) | Single Page App (SPA) |
|---|---|---|
| Navigation | Full page reload | Client-side routing (no reload) |
| Initial Load | Fast (one page) | Slower (download JS bundle) |
| Subsequent Navigation | Slow (reload) | Fast (no reload) |
| SEO | ✅ Excellent (server HTML) | ❌ Poor (client-only) |
| User Experience | ❌ Clunky (white flash) | ✅ Smooth (desktop-like) |
| Browser Back Button | ✅ Works natively | ⚠️ Needs router setup |
| State Management | ❌ Lost on reload | ✅ Maintained |
| Offline Support | ❌ None | ✅ Possible (service workers) |
| Best For | Blogs, documentation | Dashboards, apps |
How SpecWeave Uses SPAs
1. SPA Project Structure
my-spa-app/
├── .specweave/
│ ├── increments/
│ │ └── 0001-spa-setup/
│ │ ├── spec.md
│ │ ├── plan.md
│ │ └── tasks.md
│ └── docs/
│ └── internal/
│ └── architecture/
│ ├── routing-strategy.md
│ └── adr/
│ └── 0001-react-router-choice.md
├── src/
│ ├── pages/ # Page components
│ ├── components/ # Reusable components
│ ├── api/ # API client
│ ├── router/ # Routing config
│ └── App.tsx
└── package.json
2. Planning SPA Features
Increment spec:
# Increment 0009: User Dashboard SPA
## User Stories
**US-001**: Client-side routing
- [ ] AC-US1-01: Navigate between pages without reload
- [ ] AC-US1-02: Browser back/forward buttons work
- [ ] AC-US1-03: URL updates reflect current page
**US-002**: Data fetching
- [ ] AC-US2-01: Fetch dashboard data on mount
- [ ] AC-US2-02: Show loading state
- [ ] AC-US2-03: Handle errors gracefully
**US-003**: State management
- [ ] AC-US3-01: Cart state persists across navigation
- [ ] AC-US3-02: User auth state global
- [ ] AC-US3-03: Optimistic UI updates
## Architecture
**Routing**: React Router v6
**State Management**: React Context + useState
**API Client**: Fetch API with error handling
**Performance**: Code splitting per route
3. SPA + SpecWeave Workflow
SPA Challenges & Solutions
Challenge 1: SEO (Search Engine Optimization)
Problem: SPAs render content client-side, so search engines can't index it.
Solutions:
- Server-Side Rendering (SSR) - Use Next.js
// Next.js SSR
export async function getServerSideProps() {
const posts = await fetchPosts();
return { props: { posts } };
}
- Static Site Generation (SSG) - Pre-render at build time
// Next.js SSG
export async function getStaticProps() {
const posts = await fetchPosts();
return { props: { posts } };
}
- Pre-rendering Services - Use Prerender.io, Rendertron
Challenge 2: Initial Load Time
Problem: Large JavaScript bundle = slow first load.
Solutions:
- Code Splitting - Split bundle per route
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Products = lazy(() => import('./pages/Products'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
</Routes>
</Suspense>
);
}
- Tree Shaking - Remove unused code
// webpack.config.js
module.exports = {
optimization: {
usedExports: true
}
};
- Lazy Load Images - Load images on demand
import { LazyLoadImage } from 'react-lazy-load-image-component';
<LazyLoadImage src="/hero.jpg" alt="Hero" />
Challenge 3: Browser History
Problem: SPA navigation doesn't update URL by default.
Solution: Use HTML5 History API (built into routers)
// React Router handles this automatically
import { BrowserRouter } from 'react-router-dom';
// URLs update: /home → /products → /cart
SPA Best Practices
✅ Do
- Use client-side router (React Router, Vue Router)
- Implement code splitting (per route)
- Show loading states (for async operations)
- Handle errors gracefully (network failures)
- Optimize bundle size (tree shaking, lazy loading)
- Use SSR/SSG for SEO (if public-facing)
- Implement service workers (offline support)
❌ Don't
- Don't load entire app upfront (use code splitting)
- Don't forget SEO (use SSR/SSG if needed)
- Don't ignore browser back button (use router)
- Don't fetch all data upfront (lazy load)
- Don't forget error handling (network failures)
Common Patterns
1. Protected Routes
function PrivateRoute({ children }) {
const { user } = useAuth();
return user ? children : <Navigate to="/login" />;
}
// Usage
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
2. Data Prefetching
function ProductsPage() {
const [products, setProducts] = useState([]);
useEffect(() => {
// Prefetch data when component mounts
fetchProducts().then(setProducts);
}, []);
// Prefetch next page data on hover
const handleProductHover = (id) => {
prefetchProduct(id); // Load in background
};
return (
<div>
{products.map(product => (
<Link
key={product.id}
to={`/product/${product.id}`}
onMouseEnter={() => handleProductHover(product.id)}
>
{product.name}
</Link>
))}
</div>
);
}
3. Optimistic UI Updates
function LikeButton({ postId, initialLiked }) {
const [liked, setLiked] = useState(initialLiked);
const handleLike = async () => {
// Update UI immediately (optimistic)
setLiked(!liked);
try {
// Send request to server
await api.toggleLike(postId);
} catch (error) {
// Revert on error
setLiked(liked);
alert('Failed to like post');
}
};
return (
<button onClick={handleLike}>
{liked ? '❤️ Liked' : '🤍 Like'}
</button>
);
}
Common Mistakes
❌ Mistake 1: Not Handling Loading States
// ❌ WRONG - No loading state
function Products() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts().then(setProducts);
}, []);
return products.map(p => <div>{p.name}</div>); // Renders nothing initially!
}
// ✅ CORRECT - Show loading state
function Products() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProducts().then(data => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
return products.map(p => <div>{p.name}</div>);
}
❌ Mistake 2: Not Using Router
// ❌ WRONG - Manual navigation (breaks back button)
function Navbar() {
return (
<button onClick={() => window.location.href = '/products'}>
Products
</button>
);
}
// ✅ CORRECT - Use router
import { Link } from 'react-router-dom';
function Navbar() {
return <Link to="/products">Products</Link>;
}
❌ Mistake 3: Large Bundle Size
// ❌ WRONG - Import everything upfront
import Dashboard from './Dashboard';
import Products from './Products';
import About from './About';
// ✅ CORRECT - Code split
const Dashboard = lazy(() => import('./Dashboard'));
const Products = lazy(() => import('./Products'));
const About = lazy(() => import('./About'));
Related Terms
- React - Popular SPA framework
- Next.js - React with SSR/SSG (hybrid approach)
- SSR - Server-side rendering (solves SPA SEO)
- SSG - Static site generation (alternative to SPA)
- Frontend - Client-side development
Learn More
- SPA Architecture Guide - Deep dive into SPAs
- React Router Tutorial - Client-side routing
- Next.js Guide - Hybrid approach (SPA + SSR)
- SpecWeave Frontend Plugin - SPA-specific features
Category: Frontend Development
Tags: #spa #single-page-app #react #routing #frontend #client-side