Fix: Vue Router 404 on Page Refresh — History Mode Returns 404 or Blank Page
Quick Answer
How to fix Vue Router 404 errors on page refresh in history mode — server configuration for nginx, Apache, Vite, Express, and Netlify, plus hash mode as a fallback.
The Problem
Navigating within a Vue app works fine, but refreshing the page (or visiting a URL directly) returns a 404 or blank page:
GET https://myapp.com/users/42 404 (Not Found)Or the server returns the HTML page, but Vue doesn’t render the correct route:
# URL: https://myapp.com/dashboard
# Page loads but shows a blank white screen
# Vue app mounts but the router renders nothingOr the problem only occurs in production:
# localhost:5173/users/42 — works fine (Vite dev server handles it)
# myapp.com/users/42 — 404 after nginx deploymentWhy This Happens
Vue Router’s createWebHistory() mode uses the HTML5 History API to change the URL without triggering a page reload. URLs like /users/42 look like real file paths to the browser and the web server.
When a user visits myapp.com/users/42 directly (or refreshes), the browser sends an actual HTTP request for /users/42. The web server looks for a file or directory at that path — it doesn’t exist, so it returns 404.
The fix is to configure the server to serve the same index.html for all routes. Vue Router then reads the URL from the browser and renders the correct component client-side.
Hash mode (createWebHashHistory()) doesn’t have this problem — the # portion of the URL is never sent to the server, so all requests hit / and the server always returns index.html. But hash mode URLs look like myapp.com/#/users/42, which is less clean.
Fix 1: Configure nginx
Add a try_files directive to fall back to index.html:
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name myapp.com;
root /var/www/myapp/dist; # Path to your built Vue app
index index.html;
location / {
# Try the exact file, then try it as a directory,
# then fall back to index.html for Vue Router to handle
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Never cache index.html — must always be fresh
location = /index.html {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}For HTTPS with SSL:
server {
listen 443 ssl http2;
server_name myapp.com;
ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.key;
root /var/www/myapp/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name myapp.com;
return 301 https://$host$request_uri;
}Fix 2: Configure Apache
Create or edit .htaccess in your web root:
# .htaccess — place in the dist/ directory or web root
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Don't rewrite requests for existing files or directories
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Rewrite everything else to index.html
RewriteRule . /index.html [L]
</IfModule>If your app is in a subdirectory (e.g., /app/):
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /app/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /app/index.html [L]
</IfModule>Also configure Vue Router’s base option to match:
// router/index.js
const router = createRouter({
history: createWebHistory('/app/'), // Must match the subdirectory path
routes,
});Apache virtual host configuration:
<VirtualHost *:80>
ServerName myapp.com
DocumentRoot /var/www/myapp/dist
<Directory /var/www/myapp/dist>
Options -Indexes
AllowOverride All # Allows .htaccess to work
Require all granted
FallbackResource /index.html # Apache 2.2.16+ alternative to mod_rewrite
</Directory>
</VirtualHost>Fix 3: Configure Vite for Production Previews
The Vite dev server automatically serves index.html for all routes. For vite preview (testing the production build):
// vite.config.js
export default {
plugins: [vue()],
preview: {
// vite preview serves index.html for all routes by default
// No additional config needed for basic history mode
},
};The issue only appears when deploying to a separate web server. The Vite dev server and vite preview handle SPA routing correctly out of the box.
Fix 4: Configure Express for SSR or API-proxied Apps
If your Vue app is served by a Node.js/Express backend:
// server.js
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
// Serve static files from the dist folder
app.use(express.static(path.join(__dirname, 'dist')));
// API routes — must be defined before the catch-all
app.use('/api', apiRouter);
// Catch-all — serve index.html for any unmatched route
// Vue Router handles routing client-side
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
app.listen(3000);With a separate API and frontend server:
// Development — proxy API requests to backend
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
};
// Production — nginx handles the split
// /api/* → backend server
// /* → Vue app (index.html)Fix 5: Configure Netlify, Vercel, and Cloudflare Pages
Netlify — create _redirects file in public/ (or dist/ after build):
# public/_redirects
/* /index.html 200Or use netlify.toml:
# netlify.toml
[[redirects]]
from = "/*"
to = "/index.html"
status = 200Vercel — create vercel.json:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}Cloudflare Pages — handles SPA routing automatically. No configuration needed. If you need custom 404 behavior, create a 404.html that redirects to index.html.
GitHub Pages — GitHub Pages doesn’t support server-side redirects. Use hash mode or a custom 404 page workaround:
<!-- 404.html — redirect all 404s back to index.html with the path encoded -->
<!DOCTYPE html>
<html>
<head>
<script>
// Encode the URL path and redirect to index.html
const path = window.location.pathname;
window.location.replace('/?p=' + encodeURIComponent(path));
</script>
</head>
</html>// In your Vue app's main.js — decode and navigate to the path
const params = new URLSearchParams(window.location.search);
const path = params.get('p');
if (path) {
window.history.replaceState({}, '', decodeURIComponent(path));
}Fix 6: Use Hash Mode as a Quick Fallback
If you can’t control server configuration, hash mode works without any server changes:
// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
const router = createRouter({
// Hash mode — URLs look like: myapp.com/#/users/42
// The # portion is never sent to the server — always returns index.html
history: createWebHashHistory(),
routes,
});Hash mode trade-offs:
- Pros: Works on any server, even static file hosts, without configuration
- Cons: URLs contain
#which some consider ugly;#breaks anchor links; some analytics tools handle hash URLs differently
Migrate from hash to history mode — update all internal links and server configuration at the same time. Old hash URLs won’t automatically redirect.
Fix 7: Verify Router Configuration
Ensure the router is set up correctly for history mode:
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import UserView from '../views/UserView.vue';
const routes = [
{ path: '/', component: HomeView },
{ path: '/users/:id', component: UserView },
// Always add a catch-all for unmatched routes
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFoundView },
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
// BASE_URL comes from Vite — defaults to '/'
// Set it in vite.config.js: base: '/my-subpath/'
routes,
});
export default router;BASE_URL for apps in subdirectories:
// vite.config.js
export default {
base: '/my-app/', // Deploy to mysite.com/my-app/
};
// router/index.js — use import.meta.env.BASE_URL (set by Vite from config)
history: createWebHistory(import.meta.env.BASE_URL),
// Produces: createWebHistory('/my-app/')Verify the router is mounted in main.js:
// main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(router); // Must be registered before mount
app.mount('#app');Still Not Working?
CDN caching the 404 — after fixing the server configuration, a CDN (Cloudflare, CloudFront) may have cached the old 404 response. Purge the CDN cache after deploying the fix.
<RouterView /> not in App.vue — if <RouterView /> (or <router-view>) is missing from App.vue, routes match correctly but nothing renders. The component outlet must be in the template.
Named views and route components — using <RouterView name="sidebar" /> requires routes to specify components: { default: MainComponent, sidebar: SidebarComponent }. A mismatch causes the named view to render nothing.
Nested routes and trailing slashes — a route defined as /users/:id matches /users/42 but not /users/42/. Add strict: false (default) or define both routes if needed.
For related Vue issues, see Fix: Vue Router Navigation Guard Not Working and Fix: Vue v-model in Custom Components.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Vue Router Params Not Updating — Component Not Re-rendering or beforeRouteUpdate Not Firing
How to fix Vue Router params not updating when navigating between same-route paths — watch $route, beforeRouteUpdate, onBeforeRouteUpdate, and component reuse behavior explained.
Fix: Nuxt Not Working — useFetch Returns Undefined, Server Route 404, or Hydration Mismatch
How to fix Nuxt 3 issues — useFetch vs $fetch, server routes in server/api, composable SSR rules, useAsyncData, hydration errors, and Nitro configuration problems.
Fix: Vue Slot Not Working — Named Slots Not Rendering or Scoped Slot Data Not Accessible
How to fix Vue 3 slot issues — v-slot syntax, named slots, scoped slots passing data, default slot content, fallback content, and dynamic slot names.
Fix: Vue Teleport Not Rendering — Content Not Appearing at Target Element
How to fix Vue Teleport not working — target element not found, SSR with Teleport, disabled prop, multiple Teleports to the same target, and timing issues.