Skip to main content

Command Palette

Search for a command to run...

SPA Routing in the Real World: Why History-Based URLs Still Need Server-Side Fallbacks

Updated
•4 min read•View as Markdown
P
I am a Senior Engineering Leader with over 22 years of experience architecting mission-critical enterprise systems and leading high-performing teams. My career has been defined by a focus on operational excellence like transitioning complex platforms from prototype to production and maintaining the architectural rigor required for enterprise SaaS. Currently, I am deeply attracted towards leveraging AI in a responsible way to bolster engineering workflows. On this blog, I share my thoughts, observations and experiences from my career, with a focus on leadership as well as engineering aspects. Core Focus: Enterprise SaaS | Engineering Management | Distributed Systems | AI/RAG

Introduction

Single-page applications (SPAs) turned web development on its head. Angular, Ember.js, React, Vue - all of them brought in client-side routing through the HTML5 History API (pushState). That meant users could move around your app smoothly, without all those clunky page reloads.

But here’s the snag that won’t go away, no matter what framework you pick:

How do you make sure deep links in SPAs actually work if someone loads them directly in their browser?

Let’s dig into the problem again, look at why it’s still around, and see how teams are really solving it.

The core problem: client-side routing vs server-side routing

Old-school multi-page apps put the server in charge of routing:

  • /home → server gives back the Home page

  • /profile → server gives back the Profile page

SPAs hand that job to JavaScript in your browser:

  • Routes like /home and /profile get handled on the client

  • The server usually just serves one entry point - index.html

With the History API:

  • pushState()

  • replaceState()

You get clean, pretty URLs like:

/app/screen1
/app/screen2

No silly hashes needed (#/screen1).

Where the problem appears

You run into trouble when someone lands directly on a deep link:

https://example.com/app/screen1

What happens?

  • The browser asks the server for /app/screen1

  • The server (Tomcat, Nginx, Apache, whatever) looks for something at that path

  • There’s nothing there - no file, no mapping

  • Boom: 404 error unless you’ve set up a workaround

This isn’t something your frontend framework can fix. It’s a design mismatch - your server and your client think about routing very differently.

Historical context: Ember and Angular

Look back at early frontend frameworks like Ember.js and AngularJS. They made SPA routing with the History API pretty common.

Modern Angular still uses this with PathLocationStrategy. Ember rides atop the same browser APIs, using its router system.

The basic approach never really changed:

The browser tracks where the user is; the server delivers the bootstrapping entry point.

Why is this still a real problem in modern architectures

Fast forward to today - nothing’s really changed:

  • SPAs are everywhere

  • Micro-frontends mean more routing layers, more complexity

  • Static hosting and CDNs still need explicit fallback rules

  • Serverless stacks split routing from app logic

Honestly, this is no longer a framework issue. It’s all about infrastructure.

The correct mental model: fallback routing

Don’t try to “fix the URLs.” Rethink what the server’s supposed to do:

The server must send back your SPA entry point for any unknown route, but still serve static assets and APIs as usual.

Common solutions in production systems

1. Reverse proxy fallback (today’s default)

Nginx example:

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

In short:

  • Static files go out as normal

  • Unknown paths? They all get the SPA entry point

2. Backend-controlled fallback (Java / Tomcat / Spring)

In Java-heavy setups, you handle this with:

  • Servlet mapping

  • Controller fallbacks

  • URL rewrite filters (especially in older setups)

Stuff like UrlRewriteFilter is still hanging around in legacy Java, but it’s fading with new cloud-native systems.

3. Node.js / Express-based routing

app.get('*', (req, res) => {
  res.sendFile(indexHtml);
});

This is everywhere in server-rendered SPA stacks.

4. Static hosting platforms (modern default)

CDNs and serverless hosts usually offer:

  • “SPA fallback mode”

  • Built-in rewrite rules

  • Edge-level routing logic

A subtle but important detail: static asset separation

One thing that bites a lot of teams at first: overbroad rewrite rules.

You have to make sure:

  • /assets/* gets served directly

  • /images/*, /scripts/* Don’t fall into the SPA rewrite trap

  • /api/* is routed off to your backend

  • Everything else - ship out index.html

That’s where clever regex and fine-tuned routing configs matter.

The deeper architectural insight

At bottom, this isn’t a routing issue.

It’s really about who handles what:

  • The browser takes care of UI state changes

  • The server’s job is to get resources and bootstrapping

  • The infrastructure glues it all together

Frameworks hide this from you, but they can’t make the boundary disappear.

Conclusion

History-based routing in SPAs won’t go away - it’s understood now, not “solved.”

The ways teams tackle it evolve, but the real need stays the same:

If you want client-side routing, you always need fallback logic on the server.

What changes is where you glue it all together - from Java servers to proxies to the network edge.

If you’re building big frontends, you still have to get this right. Understanding this boundary makes all the difference.

Engineering Insights

Part 2 of 4

This series is a collection of 'notes from the field' - practical solutions, minor discoveries, and useful patterns I've encountered while working with Java, Ruby on Rails, and enterprise stacks. These aren't deep academic dives; they are the real-world 'aha!' moments that help get the job done.

Up next

Rails I18n Pattern: Combining Parameterized Translations with Fallback Keys

Introduction Rails I18n is usually introduced through two basic patterns: interpolation using named placeholders fallback translations using default keys Individually, both are well understood. Bu