SPA #

A Single Page Application is an architecture where the entire app runs in one HTML document — navigation between “pages” doesn’t reload from the server; instead, JavaScript replaces content in the DOM. This provides a native-app-like experience: instant transitions, no flash of blank screen, state preserved when switching pages. But SPAs have unique challenges that don’t exist in traditional MPAs (Multi-Page Applications): complex state management, undetected memory leaks, browser history problems, accessibility, and heavy initial loads. This article covers how SPAs work in depth, the specific challenges to handle, and best practices for building robust SPAs.

SPA vs MPA — Fundamental Differences #

To understand why SPAs need a different approach, you first need to understand their fundamental differences from traditional web applications.

flowchart LR
    subgraph MPA["MPA — Multi-Page Application"]
        MUser["User clicks a link"]
        MReq["Browser sends a request to the server"]
        MServer["Server generates new HTML"]
        MRender["Browser renders the new page\n(old state lost, scroll reset)"]
        MUser --> MReq --> MServer --> MRender
    end

    subgraph SPA["SPA — Single Page Application"]
        SUser["User clicks a link"]
        SRouter["Client-side router intercepts"]
        SDOM["JavaScript updates the DOM\n(state preserved, no flash)"]
        SFetch["Fetch data from the API if needed"]
        SUser --> SRouter --> SDOM
        SRouter --> SFetch --> SDOM
    end
The differences users feel most:

MPA:
  Click a link → brief blank/white → new page appears
  State lost (filled forms, scroll position, active tabs)
  Every page is a "fresh start"

SPA:
  Click a link → content changes instantly (no blank)
  State preserved (sidebar stays expanded, music keeps playing)
  Navigation feels like a native app

The differences developers feel most:

MPA:
  Simple state — every request is a "clean slate"
  No need to manage the History API
  Browsers handle back/forward naturally
  Memory doesn't accumulate across pages

SPA:
  Complex state — data from page A still exists on page B
  Must manage the History API manually
  Memory can leak if event listeners aren't cleaned up
  Need state management libraries for large apps

Client-Side Routing — The Heart of an SPA #

Routing is the mechanism letting SPAs “pretend” to have many pages. When users click a link, the router intercepts that event, updates the URL using the History API, and renders the matching component — all without a server request.

// How the History API works — the foundation of every SPA router
// (Go equivalent: a router that intercepts navigation and updates the URL)

// When the user clicks an internal link:
func onDocumentClick(e *ClickEvent) {
    link := e.target.closest("a")
    if link == nil || !isInternalLink(link.href) {
        return
    }
    e.preventDefault() // stop the browser from reloading the page

    url := parseURL(link.href)

    // Update the URL in the address bar without reloading
    pushState(map[string]string{"path": url.Path}, "", url.Path)

    // Render the component matching this path
    renderRoute(url.Path)
}

// Handle the back/forward buttons
func onPopState() {
    renderRoute(currentPath())
}
Two routing modes in SPAs:

1. Hash-based routing: example.com/#/products/123
   → The part after # is never sent to the server
   → No server configuration needed
   → Less clean URLs, not indexed well by search engines
   → Good for: prototypes, internal tools, apps without SEO requirements

2. History API routing: example.com/products/123
   → Clean URLs, indexed by search engines
   → The server must be configured: all paths serve index.html
   → If not configured: refreshing at /products/123 = 404!
   → Good for: production apps, especially those needing SEO or shareable URLs

// Nginx configuration for History API routing:
location / {
  try_files $uri $uri/ /index.html;
  // If the file doesn't exist, serve index.html — let the SPA handle routing
}

Deep Linking and 404-on-Refresh Problems #

This is one of the most common “gotchas” when first deploying an SPA. Users open app.com/dashboard/reports directly (not through SPA navigation) and get a 404.

Why it happens:

The user navigates to: app.com/dashboard/reports
The browser sends a request: GET /dashboard/reports to the server
The server looks for a file /dashboard/reports — it doesn't exist!
The server returns: 404 Not Found

This happens because:
→ In an SPA, /dashboard/reports only exists in the JavaScript router
→ The server doesn't know about this route
→ The server only has one file: index.html

Solutions per platform:

Nginx:
  location / {
    try_files $uri $uri/ /index.html;
  }

Apache (.htaccess):
  Options -MultiViews
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteRule ^ index.html [QSA,L]

Vercel (vercel.json):
  {
    "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
  }

Netlify (_redirects):
  /*  /index.html  200

AWS CloudFront:
  Custom error response: 404 → /index.html with HTTP 200
Don’t forget to configure server rewrites before deploying an SPA to production. This is a problem that will almost certainly happen if unconfigured, and it makes all direct navigation (from emails, bookmarks, or shared links) produce 404s. It also prevents search engines from crawling deep SPA pages.

State Management — The SPA’s Biggest Challenge #

In an MPA, every page is a fresh start — no state needs coordination between pages. In an SPA, all pages live in one app instance and can share state, which can quickly become complex.

flowchart TD
    subgraph Local["Local State — useState, ref"]
        L["Good for:\nTemporary form inputs\nUI toggles (open/close)\nLocal animations\nData that doesn't need sharing"]
    end

    subgraph Lifted["Lifted State — Props / Context"]
        LI["Good for:\nData shared by 2-3 components\nTheme, language preferences\nAuth state\nModal/drawer state"]
    end

    subgraph Server["Server State — React Query / SWR"]
        S["Good for:\nData from APIs\nCache needing invalidation\nBackground refetching\nOptimistic updates"]
    end

    subgraph Global["Global State — Redux / Zustand / Pinia"]
        G["Good for:\nComplex globally shared data\nUndo/redo\nMulti-step wizard state\nReal-time collaboration state"]
    end

    Q1{"Does the state need\nsharing across components?"}
    Q2{"Is the data from an API?"}
    Q3{"How wide is\nthe sharing scope?"}

    Q1 -->|"No"| Local
    Q1 -->|"Yes"| Q2
    Q2 -->|"Yes"| Server
    Q2 -->|"No"| Q3
    Q3 -->|"A few components"| Lifted
    Q3 -->|"The entire app"| Global
// Common mistake: putting all state into the global store
// ANTI-PATTERN
store.dispatch(setIsModalOpen(true))        // UI state that's local enough
store.dispatch(setFormInput("email", value)) // very local form state
store.dispatch(setHoveredItemId(id))        // very local hover state

// The global store should be for truly global data
// CORRECT: separate by scope
type ProductModal struct {
    isOpen   bool            // local → component state
    formData map[string]any  // local → component state

    cart Cart // server state → query layer (React Query)
    user User // global state → Zustand/Redux
}

Memory Leaks — The Invisible Problem #

In an MPA, memory is freed every time a new page loads. In an SPA, everything runs in one browser session — memory leaks accumulate and can make the app feel increasingly slower over time.

// The most common memory leak sources in SPAs:
// (Go equivalent: resources must always be released — defer cleanup)

// 1. Event listeners not cleaned up
// ANTI-PATTERN
func SearchComponent() {
    window.addEvent("keydown", handleKeyDown) // registered
    // No cleanup! The event listener stays active even after the component unmounts
}

// CORRECT: Always clean up event listeners
func SearchComponent() {
    listener := window.addEvent("keydown", handleKeyDown)
    defer listener.remove() // cleanup on unmount
}

// 2. Timers and intervals not cleaned up
// ANTI-PATTERN
func LiveClock() {
    ticker := time.NewTicker(time.Second) // the interval runs forever!
}

// CORRECT
func LiveClock() {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop() // cleanup on unmount
}

// 3. Subscriptions not cleaned up
// ANTI-PATTERN
func NotificationBell() {
    unsub := notificationService.subscribe(setCount)
    // Forgot to call unsubscribe!
}

// CORRECT
func NotificationBell() {
    unsub := notificationService.subscribe(setCount)
    defer unsub() // cleanup
}

// 4. Async operations updating state after unmount
// ANTI-PATTERN
func UserProfile(userID string) {
    user, _ := fetchUser(userID) // if the component unmounts before the fetch finishes
    setUser(user)                // → Error: "Can't update state on unmounted component"
}

// CORRECT: AbortController to cancel fetches
func UserProfile(userID string) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // cancel the fetch on unmount
    user, _ := fetchUser(ctx, userID)
    setUser(user)
}

Scroll Management and Navigation #

In an MPA, browsers automatically scroll to the top on every page navigation. In an SPA, scrolling must be managed manually.

// Common scroll problems in SPAs:
// (Go equivalent: scroll reset on route change + saved positions in a map)

// 1. Scroll not reset when navigating to a new page
// The user scrolls down on /products → navigates to /about → still at the bottom!

// Solution with a router hook:
func scrollToTop() {
    window.scrollTo(0, 0)
}

// Add it to the root router: called on every path change
router.onRouteChanged(scrollToTop)

// 2. Scroll position not preserved on back navigation
// The user scrolls on /products → clicks a product → presses back → scrolled to the top
// This is wrong behavior — it should return to the same position

// Solution: save and restore scroll positions
var scrollPositions = map[string]int{}

// Save when leaving
func onPopState() {
    scrollPositions[window.location.path] = window.scrollY()
}

// Restore when arriving
func restoreScroll(path string) {
    if saved, ok := scrollPositions[path]; ok {
        window.scrollTo(0, saved)
    }
}

Accessibility in SPAs #

Accessibility (a11y) in SPAs needs extra attention because screen readers and assistive technologies are designed for the MPA navigation model.

Accessibility problems specific to SPAs:

1. Focus transitions during page navigation
   In MPAs: focus automatically moves to the page start on new page load
   In SPAs: focus stays on the clicked link → screen readers don't know content changed

   Solution:
   // When the route changes, move focus to the main page heading
   function PageTransition() {
     const { pathname } = useLocation()
     const headingRef = useRef(null)
     
     useEffect(() => {
       headingRef.current?.focus()  // move focus to the heading
     }, [pathname])
     
     return <h1 ref={headingRef} tabIndex="-1">...</h1>
     // tabIndex="-1" lets it be focused via JS but keeps it out of the tab order
   }

2. Page titles not updated
   In MPAs: <title> changes automatically per page
   In SPAs: <title> must be updated manually during navigation
   
   // React Helmet or Next.js Head component:
   <title>{currentPageTitle} | App Name</title>

3. Announcements for screen readers
   Screen readers need to be told that page content has changed
   
   // Live region for announcements
   <div aria-live="polite" aria-atomic="true">
     {navigationAnnouncement}
   </div>
   
   // During navigation:
   setNavigationAnnouncement(`The ${pageTitle} page has loaded`)

Optimizing SPA Initial Loads #

The initial load is an SPA’s biggest weakness. Several strategies to reduce this time:

1. Aggressive bundle splitting
   Separate vendors (React, etc.) from app code
   → Vendors rarely change → cached longer in browsers
   
   // vite.config.js
   build: {
     rollupOptions: {
       output: {
         manualChunks: {
           vendor: ['react', 'react-dom'],
           router: ['react-router-dom'],
           charts: ['recharts'],  // large libraries split separately
         }
       }
     }
   }

2. Preload critical assets
   Tell the browser to start downloading before the HTML finishes parsing
   
   <link rel="preload" href="/main.chunk.js" as="script">
   <link rel="preload" href="/app.css" as="style">

3. Prefetch routes likely visited next
   When the user hovers a link, start downloading that route's chunk
   
   // React Router future-visits optimization
   <Link
     to="/dashboard"
     onMouseEnter={() => {
       // Prefetch the dashboard chunk
       import('./pages/Dashboard')
     }}
   >
     Dashboard
   </Link>

4. App Shell Pattern
   Load the app shell (navbar, sidebar) immediately
   Content data loads afterwards
   → Users see the app structure immediately, content follows
   
   <div id="app">
     <Navbar />     {/* Loaded from the local bundle, instant */}
     <Sidebar />    {/* Loaded from the local bundle, instant */}
     <main>
       <Suspense fallback={<ContentSkeleton />}>
         <PageContent />  {/* Loaded via lazy + fetch */}
       </Suspense>
     </main>
   </div>

SPA Anti-Patterns to Avoid #

Storing All State in URL Hash Params #

// ✗ Anti-pattern: filters and pagination in the hash
// example.com/#page=3&filter=electronics&sort=price_asc&view=grid
// URL hashes aren't indexed by search engines and can't be shared well

// ✓ Solution: Use query strings for shareable state
// example.com/products?page=3&filter=electronics&sort=price_asc&view=grid

func ProductList() {
    // read from the query string
    page := queryParam("page", "1")
    filter := queryParam("filter", "")

    // Update the URL when the filter changes
    setQueryParam("filter", newFilter) // keeps the other params intact
}

Not Handling Loading and Error States #

// ✗ Anti-pattern: no feedback during loading or errors
func UserList() {
    users, _ := fetchUsers()
    // No loading state, no error handling
    // Users see an empty list and don't know why
    render(users)
}

// ✓ Solution: Handle all states with meaningful UI
func UserList() {
    result := useQuery("users", fetchUsers)

    if result.loading {
        render(UserListSkeleton{})
        return
    }
    if result.err != nil {
        render(ErrorState{message: "Failed to load the user list", onRetry: result.refetch})
        return
    }
    if len(result.data) == 0 {
        render(EmptyState{message: "No users yet"})
        return
    }
    render(result.data)
}

Page Titles Not Updated #

// ✗ Anti-pattern: page titles don't change during navigation
// Users in another tab see "App" without knowing which page is active

// ✓ Solution: Update document.title on every route change
func ProductDetailPage(product Product) {
    document.title = product.Name + " | Online Store"
    defer func() {
        document.title = "Online Store" // reset when leaving
    }()
    return ProductDetail(product)
}

When SPA Is Right and When It Isn’t #

SPA is very appropriate for:
  ✓ Highly interactive apps with frequent navigation
     (Trello, Figma, Google Docs, complex admin panels)
  ✓ Apps needing state preserved across "pages"
     (music that keeps playing, chats that don't disconnect)
  ✓ Apps resembling desktop software
     (design tools, spreadsheets, code editors)
  ✓ Dashboards and internal tools not needing SEO

SPA is less appropriate for:
  ✗ Websites whose content is more read than interacted with
     (blogs, documentation, landing pages)
  ✗ E-commerce needing strong SEO for product pages
  ✗ Apps whose target users have low-end devices or slow connections
  ✗ Small teams not yet familiar with SPA state management complexity

Consider meta-frameworks (Next.js, Nuxt, SvelteKit) if:
  ✓ You need a combination: some SSR/SSG pages (for SEO) + some SPA pages (for interactivity)
  ✓ You want the SPA experience but with faster initial loads
  ✓ The team needs the convenience of structured routing

SPA Checklist #

ROUTING:
  □ Server configured to serve index.html for all paths (rewrite rules)
  □ Scroll positions reset when navigating to new pages
  □ Scroll positions restored on back navigation
  □ Deep linking works (refreshing at /any/path doesn't produce 404)

PERFORMANCE:
  □ Route-based code splitting implemented
  □ Vendor chunks separated from app code
  □ Critical assets preloaded
  □ Bundle sizes monitored per deployment

STATE MANAGEMENT:
  □ Local state uses useState/ref (not everything in the global store)
  □ Server state uses React Query/SWR
  □ Global stores only for truly global state
  □ Shareable state uses query params in URLs

MEMORY:
  □ Event listeners cleaned up in useEffect returns
  □ Timers and intervals cleaned up in useEffect returns
  □ Service subscriptions cleaned up in useEffect returns
  □ Fetch operations cancelled with AbortController on unmount

ACCESSIBILITY:
  □ Focus moved to the main heading during page navigation
  □ Page titles (document.title) updated on every route change
  □ ARIA live regions announce content changes to screen readers
  □ Keyboard navigation works without a mouse

ERROR HANDLING:
  □ Loading states exist for every data fetch
  □ Error states exist with retry options
  □ Empty states exist for empty lists
  □ Global error boundaries catch unexpected JavaScript errors

SEO (if there's public content):
  □ Meta tags updated per route
  □ Canonical URLs configured
  □ Pre-rendering or hybrid SSR for SEO-needing pages

Summary #

  • SPA isn’t the same as CSR — SPA is an architecture (one HTML document, client-side routing), CSR is a rendering technique. SPAs almost always use CSR, but can also combine with SSR (like the Next.js App Router).
  • Server rewrite rules are mandatory for History API routing — without them, refreshing at any URL other than the root produces a 404. This configuration is often forgotten on the first deploy.
  • Memory leaks accumulate in SPAs — always clean up event listeners, timers, subscriptions, and async operations in useEffect return functions. Untreated memory leaks make apps increasingly slower over time.
  • State management must match scope — use local state for local state, lifted state for a few shared components, React Query/SWR for server state, and global stores only for truly global state.
  • Scroll management must be manual — browsers don’t auto-scroll to the top on SPA navigation. Scroll to top on route changes, but restore positions on back navigation.
  • Accessibility needs extra attention — screen readers get no automatic signal that page content changed. Move focus to the main heading, update page titles, and use ARIA live regions.
  • Query strings for shareable state — filters, pagination, and sort preferences meant to be bookmarked or shared should live in URLs as query strings, not just in memory.
  • The App Shell Pattern reduces perceived loading time — render the shell (navbar, sidebar) instantly from the local bundle, let content follow via lazy loading.
  • Prefetch likely-visited routes — when users hover links, start downloading that route’s chunk so transitions feel instant.
  • Meta-frameworks when you need more flexibility — Next.js, Nuxt, and SvelteKit give the SPA experience for interactive pages but SSR/SSG for SEO-needing pages, all in one integrated app.
#

← Previous: SSR   Next: PWA

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact