The Refresh Problem: Why Modern Web Apps Break on Direct URL Access

The first time I hit this, I thought something was wrong with Render's deployment pipeline. I had just pushed PixelPerfect Screenshot API to production — the React frontend was live, navigation between pages was smooth, everything looked correct. Then I bookmarked the dashboard URL and opened it in a new tab.

Blank page.

Not a 404 with a message. Not an error I could trace. Just nothing. Clicking through from the homepage worked. Typing the URL directly didn't. Sharing a link to a specific page produced the same result for anyone I sent it to.

The cause turned out to be one missing file: _redirects. One line of configuration that took two minutes to add once I understood what was happening. Getting to that understanding took longer — because the problem isn't a bug, it's an architectural mismatch between how React Router works and how web servers work by default.

The Core Mismatch

A traditional website and a React SPA serve the same URLs in completely different ways. In a traditional site, every URL corresponds to an actual file on the server — /about maps to about.html, /contact maps to contact.html. The server knows about every page because every page is a file it can find on disk.

A React SPA has exactly one HTML file. That's it. index.html. Every page — dashboard, settings, history, profile — is rendered by JavaScript reading the URL and deciding what to show. The server only knows about one file: index.html. It has no idea /dashboard exists as a concept.

This works perfectly during normal navigation because React Router intercepts every click and handles the URL change entirely in JavaScript — no server request happens. But the moment a user refreshes the page, opens a bookmark, or clicks a shared link, the browser makes a fresh HTTP request to the server for that URL. And the server, which only knows about index.html, returns a 404 for anything else.

Two Fundamentally Different Approaches to Routing
Traditional Multi-Page App
The Server Decides
Flow:
User requests: /about

Browser: GET /about.html

Server: "I have about.html"

Returns: full HTML page
  • Each URL is a real file
  • Every navigation reloads the page
  • State lost between navigations
  • Server knows every route
  • Refresh on any page: always works
Refresh behavior:
Refresh any page → server finds the file → works every time.
Single Page Application
The Browser Decides
Flow:
User requests: /dashboard

Browser: GET /dashboard

Server: "No /dashboard file..."

Rule: /* → index.html

JavaScript reads URL, renders dashboard
  • Only one HTML file exists
  • JavaScript handles all navigation
  • No reloads during in-app navigation
  • State persists between views
  • Refresh: broken without special config
Refresh behavior:
Refresh on /dashboard → server has no idea what /dashboard is → 404 or blank page.

The mismatch: React Router manages URLs in the browser. Your web server knows nothing about React Router. It only serves files. If no file exists at the requested path, it returns an error — regardless of what your JavaScript thinks should happen.

Three Ways This Breaks in Practice

It's not just refreshing. The same underlying problem shows up in three different scenarios, each of which causes real user-facing failures:

How the SPA Routing Gap Manifests
1
Normal Navigation (Works)
At homepage (/)

Click "Dashboard" link

React Router intercepts

Updates URL in browser

Renders Dashboard component
✅ Works — no server request made
2
Refresh or Bookmark (Fails)
At /dashboard

Press F5 (refresh)

Browser: GET /dashboard

Server: "No such file"

Returns: 404
❌ Blank page or 404 error
3
Shared Link (Fails)
User copies /dashboard URL

Sends to colleague

Colleague clicks link

Browser: GET /dashboard

Server: "No such file"
❌ Link appears broken to recipient

The pattern: Any time the browser makes a fresh HTTP request instead of React Router handling navigation in JavaScript, the server gets involved — and the server doesn't know about your routes.

The Fix: One Line in _redirects

The solution is a file called _redirects in your public/ folder with one line:

/*    /index.html   200

That's the entire fix. This tells the server: for any path that doesn't match an actual file, serve index.html and return a 200 status. The URL in the browser stays unchanged — /dashboard stays /dashboard — but index.html is what actually gets delivered. JavaScript loads, React Router reads the URL, and the correct component renders.

The 200 is important. A 301 or 302 would cause the browser to navigate to /index.html as a literal path, which is not what you want. The 200 says "serve this file as-is for this URL" — a rewrite, not a redirect.

What the _redirects Rule Does
/* /index.html 200

Reading this line:

  • /* — match any path that isn't a real file on disk
  • /index.html — serve this file instead
  • 200 — return success status (not a redirect — the URL doesn't change)
Static assets — handled normally, rule doesn't apply
Browser: GET /static/css/main.css
Server: "This file exists on disk"
Returns: main.css (rule never fires)
✅ CSS loads correctly
Deep route refresh — rule applies
Browser: GET /dashboard (after refresh)
Server: "No /dashboard file, checking rules..."
Rule: /* → /index.html 200
Returns: index.html (URL stays /dashboard)
JavaScript loads → React Router reads /dashboard → renders Dashboard
✅ Dashboard renders correctly after refresh
Nested route — rule applies
Browser: GET /help/article/api-timeout-errors
Server: "No file at that path"
Rule: /* → /index.html 200
JavaScript loads → React Router renders article
✅ Help Center article renders from direct link

What makes this work: The server checks if a real file exists first. Static assets — your .css, .js, images — are real files and load normally. Only when no file is found does the rule fire, returning index.html for JavaScript to handle.

Platform-Specific Syntax

The concept is identical across hosting platforms — serve index.html for unknown routes — but the syntax varies. Here's what each platform needs:

The Same Rule, Five Different Syntaxes
🚀 Render.com / Netlify
File: public/_redirects
/* /index.html 200
▲ Vercel
File: vercel.json
{ "rewrites": [ { "source": "/(.*)", "destination": "/index.html" } ] }
🔧 Nginx
File: nginx.conf
location / { try_files $uri $uri/ /index.html; }
⚡ Express (Node.js)
File: server.js
app.use(express.static('build')); app.get('*', (req, res) => { res.sendFile( path.join(__dirname, 'build', 'index.html') ); });
☁️ AWS S3 + CloudFront
CloudFront Error Pages
Error 403 → /index.html (200) Error 404 → /index.html (200)

For React projects: the _redirects file belongs in public/ so Create React App copies it to the build output during npm run build. If it's in src/ it won't end up in the deployed files.

Common Mistakes That Break It

Using 301 instead of 200

# ❌ Wrong — causes redirect to /index.html literally
/*    /index.html   301

# ✅ Correct — rewrites silently, URL stays the same
/*    /index.html   200

The 301 tells the browser to navigate to /index.html as a new URL. You'll see the address bar change to /index.html which is not what you want. The 200 serves the file without changing the URL.

Listing routes individually

# ❌ Wrong — you'll miss every new route you add
/dashboard    /index.html   200
/settings     /index.html   200
/history      /index.html   200

# ✅ Correct — catches everything automatically
/*    /index.html   200

I made this mistake early on with PixelPerfect. Listed about eight routes explicitly. Then added the Help Center with 32 article routes and spent an afternoon wondering why they all broke. One wildcard rule catches every current and future route.

Apache .htaccess (if you're on Apache)

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

The two RewriteCond lines are what makes this safe — they tell Apache to only apply the rule if the requested path isn't a real file (!-f) or directory (!-d). Without them, your CSS and JavaScript would be redirected to index.html too, which breaks everything.

Testing It Properly

The issue with this bug is that it's invisible in development. Your local dev server (whatever npm start runs) handles SPA routing automatically. You have to test the production deployment specifically. Here's the six-scenario checklist I use:

Six Tests Before You Ship
1
Direct URL Access
Open new browser tab (no cache)
Type a deep URL directly
Press Enter
✅ Page renders correctly
2
Refresh on Deep Route
Navigate to any non-root page
Press F5 or Ctrl+R
✅ Same page reappears
3
Bookmark and Reopen
Navigate to a deep page
Bookmark it
Close browser tab
Open bookmark
✅ Correct page loads
4
Incognito Deep Link
Copy URL from a deep page
Open incognito window
Paste URL and navigate
✅ Page renders for new session
5
Browser Back/Forward
Navigate: Home → About → Dashboard
Click browser back button twice
✅ Returns to Home through history
6
Static Assets Unaffected
Open DevTools → Network tab
Hard refresh the page
Check CSS, JS, image requests
✅ All return 200, not index.html

Test on production, not dev: Your local dev server is already configured for SPA routing. These tests only catch real issues when run against the deployed build. Ideally test on a staging environment that mirrors production exactly before going live.

When SPAs Aren't the Right Choice

It's worth being clear that SPAs aren't universally better. The extra configuration complexity is a real cost, and it's not worth paying if you don't need the benefits.

SPAs make sense when you have lots of navigation, persistent state between views, rich interactions, and users spending extended sessions in the app. PixelPerfect's dashboard fits this well — users switch between taking screenshots, viewing history, managing batch jobs, and checking settings, and maintaining state across those transitions matters.

They're a poor fit for content sites where SEO is critical, pages are mostly static, and users typically visit one or two pages per session. A blog, a marketing site, or an e-commerce catalog is usually better served by server-rendered HTML or a hybrid approach like Next.js, which gives you server-rendered HTML for initial load and SPA behavior for subsequent navigation.

The hybrid option: Frameworks like Next.js, Remix, and SvelteKit handle SPA routing configuration automatically — you don't write _redirects files manually because the framework manages it. If you're starting a new project and aren't committed to Create React App, these frameworks eliminate this whole class of deployment configuration work.

The short version: Add /* /index.html 200 to public/_redirects before your first deployment. Test with direct URL access and refresh on production — not dev. If those work, you're done. The file is small, the fix is fast, and the alternative is a broken experience for every user who tries to share or bookmark a link.


This came up during the Render.com deployment of PixelPerfect Screenshot API. For the related database connection issue that also surfaced during that deployment — specifically which DATABASE_URL to use and why the internal vs external distinction matters — see the DATABASE_URL on Render article. More at OneTechly.

Comments

Popular posts from this blog