Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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. Since no file exists at that path on disk, the server returns its default 404 response. This is purely a server-side problem: the Vue app never loads, so Vue Router never gets a chance to parse the URL and render the correct component.

The distinction matters because it means the fix is always on the server, not in the Vue application code. You need to configure the server to return the same index.html for all routes that do not match a real file. Vue Router then reads the URL from the browser’s address bar and renders the correct component client-side. Every hosting platform and web server has a different way of configuring this fallback.

Hash mode (createWebHashHistory()) avoids this problem entirely because the # portion of the URL is never sent to the server. All requests hit /, and the server always returns index.html. But hash mode URLs look like myapp.com/#/users/42, which is harder to read, breaks anchor links, and may be handled differently by analytics tools.

Platform and Environment Differences

The server-side rewrite that fixes this error is configured differently on every platform. Getting it wrong is the single most common deployment mistake for Vue SPAs.

Vercel handles SPA fallback automatically for static deployments (vercel.json with "rewrites" is only needed if you have API routes that should not fall back). If you deploy a Vue app with the Vercel CLI or Git integration and it still 404s on refresh, check that the Framework Preset is set to “Vue.js” (not “Other”) in the project settings — the wrong preset skips the automatic rewrite.

Netlify requires a _redirects file in the publish directory (usually dist/) with /* /index.html 200. Placing it in public/ works if your build tool copies public/ contents into dist/. A common mistake is placing _redirects in the project root, where Netlify ignores it.

Apache uses .htaccess with mod_rewrite or the FallbackResource directive. On shared hosting, mod_rewrite may be disabled by default. FallbackResource /index.html is simpler and does not require mod_rewrite, but it is only available in Apache 2.2.16+. On XAMPP (Windows/macOS), mod_rewrite is enabled by default but AllowOverride may be set to None in httpd.conf, causing .htaccess to be silently ignored.

nginx uses try_files $uri $uri/ /index.html inside a location / block. On Alpine-based Docker images (nginx:alpine), the default config file path is /etc/nginx/conf.d/default.conf, not /etc/nginx/sites-available/. On Debian-based images (nginx:latest), the sites-available/sites-enabled symlink pattern is used. Confusing the two is a common Docker deployment mistake.

GitHub Pages does not support server-side redirects at all. The only options are hash mode, or a custom 404.html that uses JavaScript to redirect to index.html with the path encoded as a query parameter. GitHub Pages also strips trailing slashes from URLs, which can conflict with Vue Router’s strict option.

Cloudflare Pages handles SPA fallback automatically for single-page applications. If your project has a _redirects or _headers file, Cloudflare processes those first. A _redirects rule that matches before the SPA fallback can override it and cause 404s.

Docker nginx.conf — when containerizing a Vue app with a multi-stage build (build with node, serve with nginx:alpine), the nginx.conf must include the try_files directive. The default nginx config does not have it. Copy a custom config into the image at build time.

Capacitor / Cordova (mobile) — hybrid mobile apps serve files from the file:// scheme, not HTTP. createWebHistory() does not work with file:// because there is no server to handle the fallback. Use createWebHashHistory() or Capacitor’s built-in router integration instead.

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;
}

Docker multi-stage build with custom nginx config:

# nginx.conf for Docker
server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}
# Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

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.

AWS S3 + CloudFront static hosting — S3 does not support SPA fallback natively. Configure a CloudFront custom error response: set the 403 and 404 error pages to /index.html with a 200 response code. Without this, direct URL visits return S3’s XML error page.

Firebase Hosting — add a rewrites rule to firebase.json: {"source": "**", "destination": "/index.html"}. This must come after any API function rewrites.

base mismatch between Vite and router — if vite.config.js sets base: '/app/' but the router uses createWebHistory('/'), asset paths will resolve but route matching will fail. The router’s base must match the Vite base value. Use import.meta.env.BASE_URL to keep them in sync automatically.

For related Vue issues, see Fix: Vue Router Navigation Guard Not Working, Fix: Vue v-model in Custom Components, Fix: Vue Computed Not Updating, and Fix: Vue Router Params Not Updating.

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