Build and host a React SPA

Build a Vite + React single-page app on the server and serve it from Express — the build-on-install step, static asset serving, and the SPA fallback that makes client-side routing work. Verified in-container.

Skip the setup This guide has a one-click starter template that installs everything below onto your server.

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 vite is 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 upload dist/ (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 JSdist/ wasn't built. Confirm postinstall ran vite build cleanly in the console, and that express.static points at dist.
  • Refreshing a route 404s — the SPA fallback is missing or above express.static. The app.get('*', ...) must be last.
  • Killed during build (137) — out of memory; see the note above.
  • vite: not found on start — Vite is in devDependencies, which a production install may skip. Move the build tools to dependencies as shown.

Next steps

Was this guide helpful?