Deployment

Personal Catalogue: React, Express & MongoDB

Chapter 9 · Deployment

This chapter gets the whole stack running on its own, as a real standalone deployment — Chapter 10's own capstone handles the more specific question of mounting it inside the user's existing Astro-based site. Getting there surfaces one real, pervasive problem that's been quietly sitting in every chapter since Chapter 4.

A Real Problem Hiding in Plain Sight: Hardcoded localhost URLs

Every fetch() call written since the add-item form first went in points directly at http://localhost:4000 — a literal string, typed the same way in six different places across five chapters. It has worked perfectly the entire time, for the same reason it's about to stop working the moment this app is deployed anywhere else: development has always run on localhost.

FileIntroduced inWhat hardcodes the URL
AddItemForm.jsxCh.4, Ch.8The POST request, and the duplicate-check hook it calls
TagEditor.jsxCh.5Both PATCH requests (add and remove)
TagFilter.jsxCh.5The GET request
SearchBar.jsxCh.6The GET request
App.jsxCh.7The initial catalogue-loading fetch
useDuplicateCheck.jsCh.8The debounced search fetch

A Single Source of Truth for the API's Own Base URL

Vite exposes environment variables to client-side code, but only ones explicitly prefixed VITE_ — a deliberate, documented security choice, so a variable never accidentally ends up bundled into a public build just because it happened to share a name with something server-side.

// src/api.js export const API_BASE_URL = import.meta.env.VITE_API_URL ?? '';
# catalogue-web/.env.development — loaded automatically by `npm run dev` VITE_API_URL=http://localhost:4000

No .env.production file is needed at all. With nothing defining VITE_API_URL in a production build, API_BASE_URL falls back to an empty string — and a fetch call built from an empty base, like ${API_BASE_URL}/api/items, becomes the plain relative path /api/items. A relative URL like that resolves against whatever origin the page itself was actually loaded from, whatever real domain that turns out to be — with no domain name ever hardcoded into the built bundle.

Updating Every Fetch Call

Two representative examples — the same one-line change repeated across all six files from the table above:

// SearchBar.jsx — before const url = q ? `http://localhost:4000/api/items/search?q=${encodeURIComponent(q)}` : 'http://localhost:4000/api/items'; // SearchBar.jsx — after import { API_BASE_URL } from '../api'; // ... const url = q ? `${API_BASE_URL}/api/items/search?q=${encodeURIComponent(q)}` : `${API_BASE_URL}/api/items`;
// TagEditor.jsx — before const res = await fetch(`http://localhost:4000/api/items/${bookId}/tags/add`, ...); // TagEditor.jsx — after import { API_BASE_URL } from '../api'; // ... const res = await fetch(`${API_BASE_URL}/api/items/${bookId}/tags/add`, ...);

The identical substitution applies to AddItemForm's own submit URL and its duplicate-check hook, TagFilter's GET request, App's initial load, and TagEditor's own remove-tag request — every one of the six locations in the table above, all pointing at the same single API_BASE_URL constant instead of six separate literal strings.

Building the Frontend for Production

# from catalogue-web/ npm run build

Vite's default build writes a real, deployable set of static files into catalogue-web/dist/ — an index.html, and hashed, minified JavaScript and CSS bundles. Nothing in this project needs to serve those files with a second Node process; Express already runs one.

Serving the Built Frontend From Express

// server.js — added after the API routes and error-handling middleware are set up const path = require('path'); app.use(express.static(path.join(__dirname, '../catalogue-web/dist')));

express.static() only intercepts a request that matches a real file it's actually serving — a request for /api/items never matches anything inside dist/, so it falls straight through to Chapter 3's own routes regardless of where this line sits relative to them. Unlike Chapter 6's own /search-versus-/:id ordering bug, there's genuinely nothing to get wrong here about registration order.

No SPA-Fallback Route Needed — a Direct Payoff of Chapter 7's Own Design
Most React deployment guides need one more piece here: a catch-all route (app.get('*', ...)) that serves index.html for any path that isn't a real static file, so refreshing the browser on a client-side route like /books/123 doesn't 404. This app has never had a client-side route at all — Chapter 7's own list/detail toggle is pure component state (selectedId), never a URL change. A single request for / already resolves to index.html automatically, since that's express.static()'s own default behavior for a directory request — no fallback route to add, because there's no second real page for the browser to ever land on directly.

Tightening CORS for Production

Chapter 1's app.use(cors()) allows a request from any origin at all. Once the frontend is served by the very same Express process, its own requests to /api/items are same-origin already and don't strictly need CORS to succeed — but restricting it explicitly is cheap, real defense in depth, and keeps this server honest about which origins should be allowed to call it directly:

// server.js app.use(cors({ origin: process.env.ALLOWED_ORIGIN || 'http://localhost:5173', }));
Set ALLOWED_ORIGIN Once a Real Domain Exists
In production, ALLOWED_ORIGIN should be set to the real deployed domain (https://yourdomain.com), not left on the dev-server default. Chapter 10's own integration into the existing Astro site may end up mounting this app at a path under that site's own domain rather than a separate one entirely — whichever arrangement it turns out to be, this is the one setting that needs updating to match.

Keeping the Server Running: PM2

A real production server shouldn't stop because a terminal window closed, and should restart itself if the process ever crashes. PM2 is the standard, widely used process manager for exactly this:

# installed once, globally, on the server npm install -g pm2 # start the app under PM2's own supervision pm2 start server.js --name catalogue-api # persist the process list across a real server reboot pm2 save pm2 startup

pm2 logs catalogue-api tails real, live output from the running process; pm2 restart catalogue-api picks up a new deployment without a full stop-and-start cycle.

A Reverse Proxy for a Real Domain and HTTPS

Node's own process doesn't need to be exposed to the internet directly. A real nginx reverse proxy in front of it handles the public-facing domain and — following this site's own dedicated HTTPS/TLS Fundamentals course for the actual certificate setup — TLS termination:

# /etc/nginx/sites-available/catalogue server { listen 80; server_name yourdomain.com; location / { proxy_pass http://localhost:4000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

A Least-Privilege MongoDB User for Production

Chapter 1's own local development connection has no reason to run with admin-level database access in production. A dedicated user, scoped to exactly the one database this app actually touches:

// run once, connected as an admin user use catalogue db.createUser({ user: "catalogue_app", pwd: "REPLACE_WITH_A_REAL_GENERATED_PASSWORD", roles: [{ role: "readWrite", db: "catalogue" }], })

The production .env's own MONGODB_URI is updated to authenticate as catalogue_app, not whatever admin account was used to set the database up in the first place — the same real-world consequence as the PHP and Django siblings' own least-privilege database users, applied here to MongoDB's own role system instead of GRANT statements.

Scheduled Backups With mongodump

# a real, working backup command mongodump --uri="$MONGODB_URI" --out=/backups/catalogue-$(date +%F)

Placed in a daily cron job, this keeps a dated snapshot of the entire catalogue on disk, independent of whatever hosting provider's own backup story does or doesn't cover.

Trying It

# build the frontend cd catalogue-web && npm run build && cd .. # start the backend, now also serving the built frontend cd catalogue-api && node server.js

Visiting http://localhost:4000/ alone — with no separate Vite dev server running at all — should load the real, working catalogue app, and every action (search, add, tag edit) should succeed exactly as it did throughout development, now reaching the API through a relative path resolved against that same origin.

Hands-On Exercises

Exercise 1

Create src/api.js and .env.development, then update all six fetch call sites listed in this chapter's own table to use API_BASE_URL. Confirm the dev app (npm run dev + nodemon server.js) still works exactly as before.

📄 View solution
Exercise 2

Build the frontend (npm run build), wire express.static into server.js, and confirm the whole app is reachable and fully functional at http://localhost:4000/ alone, with no separate Vite dev server running, and with a real API call succeeding via a relative path.

📄 View solution
Exercise 3

Create the least-privileged catalogue_app MongoDB user, update MONGODB_URI to use it, confirm the app still works normally, then confirm directly (e.g. attempting db.dropDatabase() while authenticated as catalogue_app) that the restricted user genuinely cannot perform an admin-only operation.

📄 View solution

Chapter 9 Quick Reference

  • API_BASE_URL — one Vite-driven constant replacing six separate hardcoded localhost:4000 URLs across Chapters 4-8
  • VITE_ prefix — required for any env variable to reach client-side code, a deliberate Vite security choice
  • Empty production fallback — no VITE_API_URL in production means relative paths, resolved against whatever real domain the app is actually deployed under
  • express.static + no fallback route — a direct payoff of Chapter 7's own router-free design; most SPAs need a catch-all route, this one genuinely doesn't
  • CORS tightened — restricted to a real allowed origin, largely redundant once unified but cheap, real defense in depth
  • PM2 / nginx / least-privilege MongoDB user / mongodump backups — the standard production hardening layer, applied to this stack specifically
  • Next chapter: Capstone — integrating the catalogue into the existing Astro site, and planning barcode scanning as a later phase