| name | spa |
| description | Single Page Application architecture โ client-side routing, state management, data fetching, bundle optimization, and the SPA vs MPA tradeoff. Covers React, Vue, Angular, Svelte, and Solid ecosystems.
USE FOR: SPA architecture, client-side routing, state management patterns, data fetching strategies, bundle optimization, code splitting, lazy loading
DO NOT USE FOR: server-side rendering (use ssr), progressive web apps (use pwa), micro-frontend composition (use micro-frontends)
|
| license | MIT |
| metadata | {"displayName":"Single Page Applications","author":"Tyler-R-Kendrick"} |
| compatibility | claude, copilot, cursor |
| references | [{"title":"MDN Web Docs โ Single-Page Applications","url":"https://developer.mozilla.org/en-US/docs/Glossary/SPA"},{"title":"Single-Page Application โ Wikipedia","url":"https://en.wikipedia.org/wiki/Single-page_application"}] |
Single Page Applications (SPA)
Overview
A Single Page Application loads a single HTML shell, then uses JavaScript to dynamically render content and handle navigation entirely in the browser. The server provides JSON APIs; the client handles routing, rendering, and state. SPAs deliver rich, app-like experiences โ think Gmail, Figma, or Notion โ but come with tradeoffs in SEO, initial load performance, and complexity.
SPA Architecture
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Browser (Client) โ
โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Router โ โ State โ โ Component Tree โ โ
โ โ (URL โ โ โ Store โ โ (Virtual DOM / โ โ
โ โ View) โ โ โ โ Reactive Updates) โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ โ
โ โโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโผโโโโโโโโ โ
โ โ Data Fetching โ โ
โ โ (API Client) โ โ
โ โโโโโโโโโฌโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ HTTP/WebSocket
โโโโโโโโโโผโโโโโโโโโ
โ Backend API โ
โ (REST/GraphQL) โ
โโโโโโโโโโโโโโโโโโโ
How It Works
- Browser requests
index.html โ a minimal shell with a <div id="root"> and a <script> tag
- JavaScript bundle loads, initializes the router, and renders the initial view
- User clicks a link โ the router intercepts it, updates the URL (History API), and renders the new view without a page reload
- Data is fetched asynchronously from APIs and rendered into the component tree
- All subsequent navigation happens client-side โ the server is never contacted for HTML again
SPA vs MPA Decision
| Factor | SPA | MPA (Multi-Page App) |
|---|
| Navigation | Instant (client-side) | Full page reload |
| Initial Load | Slower (large JS bundle) | Faster (server HTML) |
| SEO | Challenging (needs prerendering or SSR) | Native |
| Interactivity | Rich, app-like | Page-based, simpler |
| Offline | Possible (with Service Workers) | Difficult |
| State Persistence | Survives navigation | Lost on page reload |
| Complexity | Higher (routing, state, hydration) | Lower |
| Best For | Dashboards, SaaS, tools | Content sites, blogs, e-commerce |
Rule of thumb: If your app feels like a document, use an MPA or SSR. If it feels like an application, use a SPA.
Client-Side Routing
React Router (v6+)
import { BrowserRouter, Routes, Route, Link, Outlet } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/dashboard">Dashboard</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<Overview />} />
<Route path="settings" element={<Settings />} />
</>
} />
);
}
() {
(
);
}
Vue Router
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('./views/Home.vue') },
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'),
children: [
{ path: '', component: () => import('./views/Overview.vue') },
{ path: 'settings', component: () => import('./views/Settings.vue') },
],
},
],
});
Angular Router
const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'dashboard',
component: DashboardComponent,
children: [
{ path: '', component: OverviewComponent },
{ path: 'settings', component: SettingsComponent },
],
canActivate: [AuthGuard],
},
{ path: '**', component: NotFoundComponent },
];
State Management
State Management Landscape
| Library | Ecosystem | Philosophy |
|---|
| Redux Toolkit | React | Single store, immutable, actions + reducers |
| Zustand | React | Minimal, hook-based, no boilerplate |
| Jotai | React | Atomic state, bottom-up, derived atoms |
| Valtio | React | Proxy-based, mutable API, reactive |
| Pinia | Vue | Composition API-friendly, modular stores |
| NgRx | Angular | RxJS-based Redux for Angular, effects |
| Angular Signals | Angular | Fine-grained reactivity, no RxJS needed |
| Svelte Stores | Svelte | Built-in writable/readable/derived stores |
Zustand (Modern React State)
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface AuthStore {
user: User | null;
token: string | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const useAuthStore = create<AuthStore>()(
devtools(
persist(
(set) => ({
user: null,
token: null,
login: async (credentials) => {
const { user, token } = await api.login(credentials);
set({ user, token });
},
logout: () => set({ user: null, token: null }),
}),
{ name: 'auth-storage' }
)
)
);
function Profile() {
user = ( state.);
logout = ( state.);
}
Pinia (Vue State)
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([]);
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
);
function addItem(product: Product) {
const existing = items.value.find((i) => i.id === product.id);
if (existing) existing.qty++;
else items.value.push({ ...product, qty: 1 });
}
function removeItem(id: string) {
items.value = items.value.filter((i) => i.id !== id);
}
return { items, total, addItem, removeItem };
});
Data Fetching
The Server State Problem
Server data is not the same as client state. Server data is:
- Asynchronous โ fetched over the network
- Shared โ multiple components may need the same data
- Stale โ can be outdated the moment it arrives
- Cacheable โ often the same data is requested repeatedly
Libraries like TanStack Query and SWR solve these problems with caching, deduplication, background refetching, and optimistic updates.
TanStack Query (React Query)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function TodoList() {
const queryClient = useQueryClient();
const { data: todos, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: () => api.getTodos(),
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});
const addTodo = useMutation({
mutationFn: (newTodo: NewTodo) => api.createTodo(newTodo),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
queryClient.([], [
...old,
{ ...newTodo, : },
]);
{ previous };
},
: {
queryClient.([], context?.);
},
});
(isLoading) ;
(error) ;
}
SWR (Vercel)
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((r) => r.json());
function Profile() {
const { data, error, isLoading, mutate } = useSWR('/api/user', fetcher, {
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 2000,
});
}
Apollo Client (GraphQL)
import { useQuery, gql } from '@apollo/client';
const GET_TODOS = gql`
query GetTodos($status: Status) {
todos(status: $status) {
id
title
completed
}
}
`;
function TodoList({ status }: { status: Status }) {
const { loading, error, data } = useQuery(GET_TODOS, {
variables: { status },
pollInterval: 30000,
});
}
Code Splitting and Lazy Loading
Route-Based Splitting (React)
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</>
);
}
Component-Level Splitting
const HeavyChart = lazy(() => import('./components/HeavyChart'));
function AnalyticsPage() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
Prefetching on Hover
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
const prefetch = () => {
if (to === '/analytics') import('./pages/Analytics');
if (to === '/settings') import('./pages/Settings');
};
return (
<Link to={to} onMouseEnter={prefetch}>
{children}
</Link>
);
}
Bundle Optimization
Vite Configuration
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
query: ['@tanstack/react-query'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
},
},
},
chunkSizeWarningLimit: 500,
sourcemap: true,
},
});
Tree Shaking Best Practices
- Use ES module imports (
import { map } from 'lodash-es' not import _ from 'lodash')
- Mark packages as
sideEffects: false in package.json when safe
- Avoid barrel files (
index.ts re-exports) in large libraries โ they defeat tree shaking
- Use
import() for anything not needed on initial render
SEO Challenges and Solutions
| Challenge | Solution |
|---|
| Empty HTML (JS-rendered content) | Prerendering at build time (react-snap, prerender-spa-plugin) |
| Search engines can't crawl SPA routes | SSG fallback for public pages |
| No meta tags until JS loads | React Helmet, Vue Meta, or SSR |
| Slow FCP hurts rankings | Code splitting + skeleton screens |
| Dynamic content not indexed | Server-side rendering for critical pages |
Hybrid Approach
Many modern apps use a hybrid: SSR or SSG for public-facing pages (marketing, docs, blog) and SPA for authenticated dashboard/application pages. Frameworks like Next.js make this easy with per-page rendering strategies.
Performance Considerations
| Metric | Target | Why It Matters |
|---|
| First Contentful Paint (FCP) | < 1.8s | User perceives the page is loading |
| Time to Interactive (TTI) | < 3.5s | User can actually interact with the page |
| Total Blocking Time (TBT) | < 200ms | Main thread responsiveness |
| Bundle Size (initial, gzipped) | < 150KB | Directly impacts FCP and TTI |
| Largest Contentful Paint (LCP) | < 2.5s | Core Web Vital โ primary content visible |
Performance Checklist
Best Practices
- Treat server data as a cache, not as state โ use TanStack Query or SWR instead of putting API responses in Redux.
- Split state by concern: URL state (router), server state (query library), UI state (local component state), global UI state (theme, auth โ Zustand/Context).
- Code-split at the route level at minimum; split large components and heavy libraries on demand.
- Prefetch likely next routes on hover or viewport proximity to make navigation feel instant.
- Measure bundle size in CI โ set budgets and fail the build if they are exceeded.
- Consider SSR or SSG for any pages that need SEO โ a pure SPA is almost never the right choice for public content.