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?