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.
| File | Introduced in | What hardcodes the URL |
|---|---|---|
| AddItemForm.jsx | Ch.4, Ch.8 | The POST request, and the duplicate-check hook it calls |
| TagEditor.jsx | Ch.5 | Both PATCH requests (add and remove) |
| TagFilter.jsx | Ch.5 | The GET request |
| SearchBar.jsx | Ch.6 | The GET request |
| App.jsx | Ch.7 | The initial catalogue-loading fetch |
| useDuplicateCheck.js | Ch.8 | The 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.
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:
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
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
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.
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:
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:
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:
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:
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
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
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
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 solutionBuild 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 solutionCreate 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 solutionChapter 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