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:

  1. Did the browser request the expected URL?
  2. Did the app shell load, or did the server return a raw 404/redirect/login page?
  3. Did the router render the expected screen for that route?
  4. On an invalid route, did the app show the intended fallback behavior?

That separation gives you a debugging path instead of a false pass.

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:

  1. One in-app navigation test for a representative nested route
  2. One direct-entry test for the same route
  3. One refresh test on that route
  4. 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.

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.