localhost:5173 / app-routes

localhost:5173/login: how the dev server answers app routes

With the default appType "spa", the Vite dev server answers any GET or HEAD request that accepts HTML and matches no file — such as localhost:5173/login — with index.html and status 200, so the route is resolved by your client-side router, not by Vite.

What does the dev server do with /login?

A route like /login is not a file, so no static middleware answers it. Further down the chain, the HTML fallback middleware rewrites the request URL to /index.html, and the index.html middleware serves that file with the Vite client injected. Status 200, content type text/html, and the same document for every unknown path. Which component renders is decided afterwards by your router in the browser.

bash — vite 8.3.4, index.html, about.html, docs/index.html in root
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://localhost:5173/login
200 text/html

$ curl -s http://localhost:5173/login | grep -o "id=app>index"
id=app>index   # the contents of index.html

When does a path return an .html file instead of index.html?

Before falling back, the middleware checks for a matching HTML file in the project root, and the check depends on how the URL ends. A path ending in .html is served if the file exists. A path without extension is tried with .html appended. A path ending in a slash is tried with index.html appended. Only when none of these exists does the request fall back to the root index.html. The slash matters: /docs and /docs/ are different requests.

bash — files in root: index.html, about.html, docs/index.html
/about        -> about.html
/about.html   -> about.html
/docs/        -> docs/index.html
/docs         -> index.html   (no docs.html, so the SPA fallback wins)
/missing.html -> index.html   (file does not exist)

Why does the fallback not apply to every request?

The middleware only acts on GET and HEAD requests, never on the default favicon request /favicon.ico, and only if the Accept header is absent, empty, contains */* or contains text/html. A request that asks for application/json or image/png only, or a POST, passes through untouched and ends in the 404 handler. A browser address-bar navigation sends text/html, so it always qualifies.

bash — same server, same path
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Accept: text/html" http://localhost:5173/login
200
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Accept: application/json" http://localhost:5173/login
404
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:5173/login
404

Why does fetch("/api/users") return HTML with status 200?

A request without a special Accept header counts as */*, and */* qualifies for the fallback. If no proxy rule matches /api, the dev server therefore answers your API call with index.html and status 200 — response.ok is true, and response.json() then fails on the first "<". That is the usual reason for "Unexpected token <" in a project that has no backend running or no server.proxy entry. The same applies to a mistyped script path: an unknown .js URL requested with */* gets the HTML document, not a 404.

bash
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" -H "Accept: */*" http://localhost:5173/api/users
200 text/html
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" -H "Accept: */*" http://localhost:5173/nofile.js
200 text/html

What changes with appType "mpa" or "custom"?

appType decides which middlewares exist. With "spa" (the default) Vite includes the HTML middlewares and the SPA fallback. With "mpa" it includes the HTML middlewares, but the fallback to index.html is switched off: /about and /docs/ still resolve to their files, while /login answers 404 with an empty body. With "custom" no HTML middleware is registered at all, and an unmatched path ends in the plain "Cannot GET /login" page — your own server is expected to answer HTML requests.

bash — vite 8.3.4, appType: "mpa" / "custom"
# appType: 'mpa'
$ curl -si http://localhost:5173/login
HTTP/1.1 404 Not Found
Content-Length: 0

# appType: 'custom'
$ curl -s http://localhost:5173/login
<pre>Cannot GET /login</pre>

A proxy rule is checked before the fallback

In the middleware chain, server.proxy sits ahead of the HTML fallback. A request whose path matches a proxy rule is forwarded to the backend and never reaches the fallback, so /api/users returns the backend’s answer once a rule for /api exists. Without a match, the fallback behaviour above applies. If a route that should be proxied still returns HTML, the rule did not match — see the proxy debugging page.

vite.config.js
export default {
  server: {
    proxy: {
      '/api': 'http://localhost:3000'
    }
  }
}

With a base path, /login is a 404 and /app/login is the route

When base is set, for example to "/app/", the dev server only serves paths below it. Without the prefix, /login returns a 404 with a note about the public base URL; /app/login falls back to index.html as usual. A router configured with a different base than Vite produces exactly this split between the address bar and the server.

bash — base: "/app/"
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/login
404
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/app/login
200

# faq

Questions

Why does localhost:5173/login show my app instead of a 404?

The default appType is "spa". The dev server rewrites every HTML request that matches no file to index.html, so your client-side router receives the path and decides what to render.

Does Vite serve about.html for /about?

Yes, if an about.html file exists in the project root. The middleware appends .html to a path without extension and falls back to index.html only when that file is missing.

Why does fetch() get HTML with status 200 for an unknown API path?

fetch sends Accept */* by default, which qualifies for the SPA fallback. Add a server.proxy rule for the API path, or send Accept: application/json to get a 404 instead.

How do I get a 404 for unknown routes instead of index.html?

Set appType to "mpa". The HTML middlewares stay, the fallback to index.html is switched off, and unmatched paths answer 404.

Does the SPA fallback also run on vite preview?

Yes, in a different place. The appType docs state that sirv is configured with single: true in preview for "spa". Preview runs on port 4173, not 5173.

# next

Related

  • How the Vite dev server handles requests to and from localhost:5173Three settings cover three different requests: server.proxy forwards a request from 5173 to your backend, server.cors decides which origins may read a response from 5173, and server.allowedHosts decides which hostnames may reach 5173 at all.
  • localhost:5173 loads but the page stays blankA blank page on localhost:5173 is a dev-server problem only if the document request itself fails — a 404 with an empty body means Vite found no index.html in the project root — while a 200 with an empty page means a script failed in the browser, which Vite does not show in the terminal by default.
  • Proxying API requests from the Vite dev serverserver.proxy forwards any request whose path starts with a given prefix to another server, so the browser only ever talks to localhost:5173 and no CORS error appears.
  • localhost:5173 — the port itselfWhat the address means, common dev ports, and the autocomplete fix.