Skip to content

Fix: Vue Router 404 on Page Refresh — History Mode Returns 404 or Blank Page

FixDevs ·

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 nothing

Or the problem only occurs in production:

# localhost:5173/users/42 — works fine (Vite dev server handles it)
# myapp.com/users/42 — 404 after nginx deployment

Why 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  200

Or use netlify.toml:

# netlify.toml
[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Vercel — 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles