A single-page app (SPA) is a website that loads once and then re-renders in the browser as you click around — React, built with Vite, is the common stack. The Node.js application runs one entry file, so the recipe is: build the SPA into static files, then serve those files from a tiny Express server, with one crucial route so the app's own navigation doesn't 404. This guide is the exact, verified setup.
| At a glance | |
|---|---|
| You need | A server running the Node.js application |
| Background | Node.js on Falix and Build an Express website + API |
| Plan | Any plan (see the memory note before building big apps on free) |
| Time | Thirty minutes, plus build time |
The shape of the problem
Vite's npm run dev server is for your laptop. In production you run vite build, which compiles your React app into a dist/ folder of plain HTML, JS, and CSS — no framework needed to serve it. So two jobs live in one project here: build dist/ on install, and serve dist/ with Express. Both run on your one SERVER_PORT.
🎯 Good to know:
npm create viteis interactive and can't run on the server. Either scaffold locally and deploy from Git, or hand-write the handful of files below — they're a complete, minimal Vite + React project.
The project files
package.json
vite.config.js
index.html
index.js <- the Express server (your Main file)
src/
main.jsx
App.jsx
package.json — note that the build tools live in dependencies, not devDependencies, so they install on the server, and postinstall runs the build automatically after npm install:
{
"name": "react-spa",
"scripts": {
"build": "vite build",
"postinstall": "vite build"
},
"dependencies": {
"express": "^4.19.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"vite": "^5.4.0",
"@vitejs/plugin-react": "^4.3.1"
}
}
vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: { outDir: 'dist' },
});
index.html (Vite's entry — it lives at the project root):
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>My SPA</title></head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
src/main.jsx and src/App.jsx — a minimal React app:
// src/main.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(<App />);
// src/App.jsx
import React, { useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Hello from a Vite + React SPA on Falix</h1>
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
</div>
);
}
The server — static files plus the SPA fallback
index.js is your Main file. It serves dist/ and does the one thing SPAs need: any URL that isn't a real file returns index.html, so the client-side router can take over.
const express = require('express');
const path = require('node:path');
const app = express();
const dist = path.join(__dirname, 'dist');
// Serve the built assets (hashed JS/CSS, images).
app.use(express.static(dist));
// SPA fallback: anything not matched above returns index.html.
app.get('*', (req, res) => {
res.sendFile(path.join(dist, 'index.html'));
});
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
The app.get('*', ...) catch-all is the piece everyone forgets. A SPA router (React Router and friends) makes URLs like /dashboard/settings that don't exist as files on disk. Without the fallback, refreshing that page hits Express, finds no file, and 404s. The fallback hands back index.html instead, React boots, reads the URL, and shows the right screen. Keep it last, after express.static, so real files still win.
Start it — and expect a build
Press Start. Because a package.json exists, npm install runs, and postinstall fires vite build. You'll see Vite transform your modules and write dist/:
vite v5.4.21 building for production...
✓ 30 modules transformed.
dist/index.html 0.22 kB
dist/assets/index-D1CDmCra.js 142.62 kB
✓ built in 316ms
Then Express prints Listening on port … — the line that flips the server to online — and your address (Network page) serves the app.
Verify it works
Three checks prove the whole pipeline:
| Request | Expected |
|---|---|
GET / |
The built index.html, referencing the hashed JS asset |
GET /dashboard/settings (a made-up route) |
200 + index.html — the fallback working |
GET /assets/index-xxxx.js |
200, content-type application/javascript |
The middle one is the important one: a 200 (not a 404) on a deep route means client-side routing will survive a page refresh.
The honest note on building here
vite build — really esbuild plus Rollup under the hood — is CPU- and memory-hungry. The tiny app above builds in well under a second, but a real app with many dependencies is a heavier build.
⚠️ Heads up: On the free plan's 2.5 GB shared RAM, a large build can be killed mid-flight (exit code 137). If your first start dies during
vite build, that's memory, not your code — see Out of memory. Two honest fixes: build on a plan with more RAM, or build on your own machine and uploaddist/(then the server only runs the little Express file — no build at all).
There's also a choice of when to build. postinstall rebuilds on every start, which is simplest but slow to boot. If you deploy from Git, move vite build out of postinstall and into a post-deploy command so it runs once per deploy instead — see Build steps on deploy. The server file and config stay the same; only where the build runs changes.
Everything past this — React Router, environment variables (import.meta.env, VITE_-prefixed), code splitting — is standard Vite and React. The official guides at vite.dev and react.dev take it from here.
Troubleshooting
- Blank page, console 404 for the JS —
dist/wasn't built. Confirmpostinstallranvite buildcleanly in the console, and thatexpress.staticpoints atdist. - Refreshing a route 404s — the SPA fallback is missing or above
express.static. Theapp.get('*', ...)must be last. - Killed during build (137) — out of memory; see the note above.
vite: not foundon start — Vite is indevDependencies, which a production install may skip. Move the build tools todependenciesas shown.
Next steps
- Designing a small REST API — a backend for your SPA
- CORS errors explained — if your API is a separate server
- Deploy from Git — build once per deploy, not per start
- Domains and HTTPS — a real address for your app