How to Test SPA Deep Links, Refreshes, and 404 Fallbacks Without Confusing Router Bugs With Server Rewrite Problems
By Luca Müller · November 14, 2026
Learn how to test SPA deep links and refresh behavior in browser automation, separate router defects from server rewrite problems, and verify 404 fallback handling with clear assertions.
A deep link failure in a single-page app usually looks like one bug, but it can come from two very different layers. The client-side router may be wrong, or the hosting edge may be serving the wrong document for a direct navigation. If you do not separate those cases, browser tests become noisy, and teams end up chasing the wrong fix.
The distinction matters because a SPA route like /settings/billing depends on two things at once:
- the browser must request that URL directly on entry or refresh
- the server or CDN must return the SPA shell for routes the client router owns
If the browser lands on a blank page, a raw 404, or a broken asset load, the router is not always the culprit. Sometimes the edge never returned the app shell.
This article shows how to test SPA deep links and refresh behavior in a way that tells you whether the failure is in the frontend router, the rewrite rules, or the 404 fallback path.
The three cases you need to separate
Before writing tests, define the three behaviors explicitly.
1. Client-side route navigation
This is the in-app transition from /home to /settings/billing without a full page load. The router intercepts the navigation and updates the URL using the browser history API, described in MDN’s History API documentation.
2. Direct entry or hard refresh on a deep link
This is when the browser starts on /settings/billing by using the address bar, reloading the page, or opening a bookmarked URL. The server or CDN must decide whether that path should return the SPA shell or a real 404 response.
3. Unknown route fallback
This is what happens for a path the app does not own, for example /no-such-page. A mature SPA usually either serves a custom 404 page or returns the shell and lets the client render a not-found route, depending on product requirements.
The tests should prove which layer failed.
A practical rule for assertions
Do not write a single assertion like “page contains the right heading.” That proves only that the final DOM happened to look right. It does not prove whether the route was loaded by the router, by a cached shell, or by a mistaken rewrite.
Use assertions that answer these questions separately:
- Did the browser request the expected URL?
- Did the app shell load, or did the server return a raw 404/redirect/login page?
- Did the router render the expected screen for that route?
- On an invalid route, did the app show the intended fallback behavior?
That separation gives you a debugging path instead of a false pass.
What to verify for deep links and refreshes
A reliable SPA routing test usually checks four signals.
URL
Confirm the browser remains on the intended path after navigation or refresh.
HTTP response or document content
Check whether the initial document is the SPA shell. If your automation stack exposes network events, verify the document request status and the response body shape when possible.
App-specific marker
Assert on a stable element that belongs to the route, not a generic layout element that appears on every page.
Negative evidence
Verify that common failure indicators are absent, such as a raw server 404 page, a redirect to the homepage, or a sign-in wall that masks the routing problem.
A test that only checks for visible content can miss a rewrite defect if the content is injected from cached state or a prior session.
A test matrix that catches the real failure modes
| Scenario | What you do | What it proves | Likely failure if it breaks |
|---|---|---|---|
| In-app route change | Click a router link | Router navigation works | Client route bug, bad component render, broken guards |
| Direct deep link entry | Start browser at nested URL | Hosting returns app shell for owned route | Missing server rewrite, wrong CDN rule |
| Hard refresh on nested URL | Reload the page on the route | Refresh path is supported | Server rewrite problem, base path issue, asset path issue |
| Invalid route | Open a non-existent path | 404 fallback works as designed | Rewrite is too broad, fallback route missing, server 404 mapping wrong |
| Nested route with asset-heavy page | Open a route that loads client assets | JS and static asset paths still resolve | Base href problem, asset origin problem, cache issue |
You do not need dozens of routes to get useful coverage. Pick one representative route from each route family, then add one invalid path test.
How to structure the tests
1. Test in-app navigation first
Start with a normal navigation inside the app. This isolates router logic from server rewrites.
import { test, expect } from '@playwright/test';
test('navigates to billing from inside the app', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Billing' }).click();
await expect(page).toHaveURL(/\/settings\/billing$/);
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
});
If this fails, you probably have a frontend routing defect, broken link target, or route-level render problem. It is not yet a rewrite test.
2. Test direct entry to the same route
Now start the browser directly at the nested URL.
import { test, expect } from '@playwright/test';
test('loads billing on direct entry', async ({ page }) => {
await page.goto('/settings/billing');
await expect(page).toHaveURL(/\/settings\/billing$/);
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
await expect(page.locator('body')).not.toContainText('404');
});
If the first test passes and this one fails, the router is probably fine, but the server, CDN, or reverse proxy is not rewriting deep links to the app shell.
3. Test hard refresh on the nested route
A refresh is the best detector for rewrite mistakes because it forces a new document request.
import { test, expect } from '@playwright/test';
test('survives refresh on a nested route', async ({ page }) => {
await page.goto('/settings/billing');
await page.reload();
await expect(page).toHaveURL(/\/settings\/billing$/);
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
});
If refresh fails but first-load navigation works, look at rewrite rules, base path configuration, and static asset URLs. Many teams mistake this for a router regression when it is actually an edge routing problem.
4. Test the invalid route behavior
This test should reflect your product decision. Some apps show a client-rendered 404 page, some return a server 404, and some redirect to a safe landing page. Decide which one is correct before you automate it.
import { test, expect } from '@playwright/test';
test('shows the not found screen for unknown routes', async ({ page }) => {
await page.goto('/definitely-not-a-real-route');
await expect(page.getByText('Page not found')).toBeVisible();
await expect(page).toHaveURL(/definitely-not-a-real-route/);
});
If the app shows the wrong page but the URL is correct, the router fallback is wrong. If the browser receives a raw server 404 page instead of the app shell, the rewrite or fallback mapping is wrong.
Where server rewrites usually go wrong
The most useful rewrite failures are predictable.
Missing fallback for nested routes
The hosting layer serves only /index.html for /, but not for /settings/billing or /projects/123.
Rewrite applied too broadly
A rule catches everything, including API paths or file assets. That can hide broken routes by returning the shell for URLs that should truly 404.
Base path mismatch
The app is deployed under /app/, but the router and asset URLs still assume /. This can create a page that loads the shell but loses styles, scripts, or navigation on refresh.
CDN cache inconsistency
A stale cached 404 or stale shell can make one route work and another fail inconsistently. If a route passes locally but fails through the edge, check the cache key and invalidation rules.
Login or auth redirects masking routing issues
A protected route may redirect to sign-in before the router or rewrite path is even exercised. That test is still valid, but it is not a pure routing test. Separate authenticated and anonymous route checks.
How to tell router bugs from rewrite bugs
Use this quick diagnostic logic.
If in-app click fails
Suspect router config, route guards, component loading, or link targets.
If direct entry fails but in-app click works
Suspect server rewrites, CDN rules, base href, or asset path configuration.
If refresh fails but initial direct entry passes
Suspect document routing, stale cache, or a path that only works on first load because the shell is served differently after reload.
If the invalid route test returns the wrong page
Suspect over-broad rewrites or a missing not-found route.
If the URL is right but the page is blank
Check JS bundle loading, CSP problems, blocked scripts, or base path issues.
A small but valuable network assertion
When the tooling supports it, capture the initial document request and confirm it is not a raw 404 response for a route that should exist.
import { test, expect } from '@playwright/test';
test('deep link document is not a 404', async ({ page }) => {
const response = await page.goto('/settings/billing');
expect(response?.status()).not.toBe(404);
});
This is not enough on its own, because a server could return a 200 with the wrong body. But paired with route-specific UI assertions, it helps prove whether the hosting layer is serving the shell.
What not to over-assert
Do not make your routing tests depend on details that change frequently:
- exact HTML structure of the shell
- analytics beacons
- animation timing
- copy that varies by locale
- a single CSS class used only for styling
Use stable, user-visible markers such as headings, landmarks, or route-specific labels. If your app already exposes route semantics in the DOM, prefer those over brittle class names.
When a fuller browser test is worth it
A simple route test is enough for many apps. Add broader coverage when one of these is true:
- the app has a non-trivial base path or subdomain deployment
- the CDN or reverse proxy has custom rewrite logic
- authenticated and anonymous routes behave differently
- the app has many nested routes generated from dynamic data
- failures have caused production incidents or support escalations
If the app is tiny and deployed from a single static host with default rewrites, one direct-entry test and one not-found test may be sufficient.
A minimal release gate for SPA routing
If you want a small but meaningful gate, use this set:
- One in-app navigation test for a representative nested route
- One direct-entry test for the same route
- One refresh test on that route
- One unknown-route fallback test
That set is small enough to maintain and strong enough to catch the most expensive category of SPA routing regressions.
Final judgment
For SPA routing, the most useful test suite is not the biggest one. It is the one that separates navigation from entry, and entry from rewrite behavior. If you can answer, “Did the router fail, or did the edge fail?” from a single failed test, your assertions are doing their job.
That is the real goal of test spa deep links and refresh behavior: not just to confirm that routes render, but to localize the failure fast enough that the right team can fix it.
FAQ
Do I need to test every route in a SPA?
No. Test one representative route from each route family, plus a not-found path. Add more only when a specific route family has different rewrite, auth, or asset behavior.
Is a 404 fallback always a server 404?
No. Some apps return a server 404 for unknown paths, while others return the SPA shell and render a client-side not-found screen. Pick one behavior and test it consistently.
Why does a route work when I click inside the app but fail on refresh?
That usually points to a rewrite or hosting problem, not a router problem. The client router can handle in-app transitions, but refresh requires the server or CDN to serve the SPA shell for that URL.
Should API routes share the same rewrite rule as SPA routes?
Usually no. API paths should keep returning API responses, while only app-owned paths should fall back to the SPA shell. Over-broad rewrites can hide real server errors.
What is the most important assertion in a deep-link test?
Combine a route-specific UI assertion with a document-level check. The UI confirms the app rendered the right screen, and the document check helps distinguish router behavior from rewrite behavior.
How do I tell if the app is failing because of assets instead of routing?
If the URL is correct and the shell loads but the page is blank or unstyled, inspect script and CSS requests, base path settings, and CSP or asset-host configuration.