Answer:
The problem is exactly what you identified: when you hit /login
directly, the server looks for a file named /dist/login
and returns 404 before React Router ever runs.
The fix is to make Express always serve index.html
for any non-API route. That way, React Router in the browser takes over routing.
Here’s the typical setup:
import express from 'express'
import path from 'path'
const app = express()
// Serve static files from Vite build output
app.use(express.static(path.join(__dirname, 'dist')))
// API routes go here, *before* the catch-all
app.use('/api', require('./api'))
// Catch-all: send index.html for any other route
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'))
})
app.listen(3000, () => {
console.log('Server running on http://localhost:3000')
})
Correct dist
path
If you’re using Vite, the build output defaults to dist/
.
In Express (running from build
or dist
folder), you might need:
path.join(__dirname, '../dist')
instead of just __dirname/dist
.
Use console.log(path.join(...))
to verify.
Order matters
app.use(express.static(...))
→ serves real files (CSS, JS, images).app.get('*', ...)
must come after your API routes, otherwise it will catch those too.Windows/IIS hosting
If you’re behind IIS, make sure your reverse proxy points all unknown paths to Express and doesn’t intercept /login
. Otherwise IIS itself might be returning the 404.
catch-all
route in Express to send index.html
for client-side routes.dist
is correct and that your catch-all comes after API routes.With that setup, https://site:port/login
will load index.html
, React Router will boot, and the login page will render just like locally.
✅ This is the same pattern used by CRA, Vite, Next.js custom servers, etc.
Do you want me to also show you how to configure the Vite build output (vite.config.ts
) to make sure Express finds the files correctly in your dist
folder?