Deployment

Food Tracker (React + Express)

Chapter 11 · Deployment

Every prior chapter ran two separate processes — Vite's dev server and Express — talking across the CORS boundary Chapter 1 set up. A real deployment collapses that back down to one.

Building the Client

cd client npm run build # produces client/dist/ — a static index.html, JS, and CSS bundle

Serving the Build From Express

// server/index.js import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // API routes registered first app.use("/api/items", itemsRouter); app.use("/api/lookup", lookupRouter); app.use("/api/recipes", recipesRouter); // static build + SPA fallback registered last if (process.env.NODE_ENV === "production") { const clientDist = path.join(__dirname, "../client/dist"); app.use(express.static(clientDist)); app.get("*", (req, res) => { res.sendFile(path.join(clientDist, "index.html")); }); }
Registration order is not cosmetic — it determines behavior
Express matches routes in the order they're registered, and app.get("*", ...) matches every path, including /api/items. If the catch-all were registered before the API routers, every single API request would be swallowed by it, silently returning index.html instead of JSON — a real, subtle bug with no error message anywhere, just API calls that mysteriously "stop working" the moment static serving is added. The API routes must always be registered first.

Why the Catch-All Route Exists At All

React Router (or any client-side router) handles navigation entirely in the browser — a URL like /history never actually exists as a real file on the server. Without the catch-all, refreshing the page on /history would hit Express directly, find no matching route, and return a 404. app.get("*", ...) always returns index.html instead, letting React Router take over and render the correct view client-side once the page loads.

The honest cost of "no backend you write" not applying here
Food Tracker (React + Firebase)'s own deployment chapter got an SPA rewrite rule and automatic HTTPS from Firebase Hosting configuration alone — a few lines of JSON, no server code. This course has to hand-write both: the catch-all route above is this course's own SPA rewrite rule, and HTTPS termination (below) has to be arranged separately too, rather than coming free with the platform. Neither is difficult, but both are real, additional work this course's own architecture — "a server you write and run yourself," established back in Chapter 1 — genuinely requires that the Firebase sibling's architecture doesn't.

Environment Configuration and Process Management

# .env (production) NODE_ENV=production PORT=3001

A plain node server/index.js process exits the moment it crashes, and doesn't restart on its own. A process manager like pm2 keeps the server running, restarting it automatically on a crash or a server reboot — the Node equivalent of what a platform's own process supervisor would otherwise provide:

npm install -g pm2 pm2 start server/index.js --name foodtracker pm2 startup # configures pm2 to launch on system boot

TLS Termination

Chapter 4's own getUserMedia requires HTTPS in production — no exception. Express itself doesn't handle TLS certificates; the standard approach is a reverse proxy (nginx, matching the pattern already covered on this site in Nginx In Depth) sitting in front of the Node process, terminating HTTPS and forwarding plain HTTP internally — the same general shape Food Tracker (Django)'s own deployment chapter used with gunicorn sitting behind a real web server, just with Node in place of gunicorn.

better-sqlite3's file needs a persistent volume
Because the entire database is one file on disk, deploying to a platform with an ephemeral filesystem — one that wipes local storage on every redeploy or restart — would silently lose every item ever tracked. This is a different SQLite gotcha than Food Tracker (Django)'s own production concern (that course named SQLite's concurrency limits under multi-user load); here, at this app's own realistic single-household scale, concurrency was never really the risk — storage persistence is. Confirming the deployment target keeps the database file across restarts is a genuine, easy-to-overlook step.

Where This Course Is Headed

One chapter left: a capstone tying every chapter into one complete, working app.

Hands-On Exercises

Exercise 1

Explain exactly what would happen to a request for /api/items if the catch-all route were registered before the API routers instead of after, and why no error would appear anywhere to signal the problem.

📄 View solution
Exercise 2

Explain why the catch-all route needs to exist at all, given that React Router already handles client-side navigation.

📄 View solution
Exercise 3

Explain the difference between this chapter's own SQLite production concern (ephemeral storage) and Food Tracker (Django)'s own SQLite production concern (concurrency), and why each course names a different risk as the more relevant one.

📄 View solution

Chapter 11 Quick Reference

  • Build: npm run build produces client/dist/, served via express.static
  • Order matters: API routes must be registered before the catch-all app.get("*", ...), or it swallows every API request
  • The catch-all's job: returns index.html for any unmatched path, letting React Router handle client-side navigation on refresh
  • Honest cost: this course hand-configures what Firebase Hosting provided automatically (SPA rewrite, HTTPS) for the Firebase sibling
  • Process management: pm2 restarts the server automatically on crash or reboot
  • TLS: a reverse proxy (nginx) in front of Node, the same general shape as the Django sibling's gunicorn-behind-a-web-server model
  • Real gotcha: better-sqlite3's single database file needs a persistent volume — an ephemeral filesystem silently loses all data
  • Next chapter: Capstone