Deployment

Romaji to Kana Converter: React & Next.js

Chapter 7 · Deployment

Every prior chapter has been building toward a single deployable thing: one Next.js project holding the client-side converter, the server-comparison box, and the real /api/convert route all together. This chapter puts that architecture to its own real test — building it for production, confirming exactly what does and doesn't survive that build process, and getting it running behind a real process manager and reverse proxy.

The Payoff of Chapter 1's Own Architectural Choice
Chapter 1 framed this variant's own real difference from its two siblings directly: Astro treats an API route as something you opt into per page, and the Angular & Express sibling course needed two genuinely separate deployables — an Angular build served one way, an Express process running another. This course has never had that split. There's one project, one build command, and — as this chapter confirms directly — one running process that serves the UI and answers /api/convert at the same time.

Building for Production: The Standalone Output Mode

Next.js's own default production build (next build followed by next start) still expects the full project, including every package in node_modules, to be present on the machine that runs it. Next.js's own real, current documentation offers a leaner alternative specifically for deployment: setting output: 'standalone' in next.config.js makes next build trace every file each page and route actually needs and copy only those into .next/standalone, along with a small, self-contained server.js that can run without next start or a full node_modules install at all.

// next.config.js module.exports = { output: 'standalone', };

Building a real, minimal Next.js 16.3.5 project with this setting confirms the documentation's own claim directly — the standalone output holds exactly three things, nothing more:

$ ls .next/standalone node_modules/ package.json server.js
What's Deliberately Missing — Verified
Checking for public/ and .next/static inside the freshly-built standalone folder confirms both are genuinely absent:
$ ls .next/standalone/public ls: cannot access '.next/standalone/public': No such file or directory $ ls .next/standalone/.next/static ls: cannot access '.next/standalone/.next/static': No such file or directory
This isn't an oversight — the documentation explains the reasoning directly: these two folders are meant to be served by a CDN in a real production setup, not by the Node process itself, so next build doesn't copy them in by default. For a deployment with no separate CDN, they have to be copied in by hand, after the build:
$ cp -r public .next/standalone/ $ cp -r .next/static .next/standalone/.next/
Skipping this step doesn't break the app outright — it fails silently, per-asset. Running the standalone server with neither folder copied in, the homepage itself still returns a real 200 (its HTML is generated by server.js directly), but a real static file from public/ returns a genuine 404:
HTTP status for /file.svg: 404 HTTP status for /: 200

Once both folders are in place, the server runs directly with node — no next start, no full node_modules install required on the deployment machine:

$ PORT=3000 HOSTNAME=0.0.0.0 node .next/standalone/server.js

PORT and HOSTNAME are read directly by server.js itself — a real, documented mechanism, not something this course is bolting on.

A Real, Measured Reason This Matters
Building the same minimal project's own two node_modules folders side by side and measuring their real disk size:
Full project node_modules: 432M Standalone's pruned node_modules: 19M
A real ~22.7× reduction, purely from tracing which files each route actually needs at runtime rather than shipping every package a full development install pulls in. For a project that will grow to include Chapter 6's own Redis client once kanji conversion is eventually added, this is a genuine, compounding saving — a deployment artifact this much smaller uploads faster, starts faster, and costs less to store.

Environment Variables: Two Genuinely Different Lifetimes

Next.js draws a real, load-bearing line between two kinds of environment variable, and this course's own Chapter 1 finding — that Route Handlers are dynamic by default — turns out to directly explain which side of that line /api/convert lands on.

NEXT_PUBLIC_ Variables Are Frozen at Build Time

A variable prefixed NEXT_PUBLIC_ is inlined directly into the client-side JavaScript bundle the moment next build runs — every reference to it is replaced with a literal, hardcoded string, not a live lookup. Building a real client component that reads process.env.NEXT_PUBLIC_APP_LABEL, set to "BUILD-TIME-LABEL-XYZ" at build time, and then searching the actual built output confirms this directly — the literal string is physically present in a static chunk file, and the variable's own name is nowhere to be found:

$ grep -rl "BUILD-TIME-LABEL-XYZ" .next/static/ .next/static/chunks/0ut3zcttubcxq.js // the literal value IS baked in $ grep -rl "NEXT_PUBLIC_APP_LABEL" .next/static/ (no matches -- the raw variable name is gone entirely)

Changing a NEXT_PUBLIC_ variable after the build has already happened does nothing — the value is already compiled into the shipped JavaScript. It needs a real rebuild to take effect.

Server-Only Variables in a Dynamic Route Handler Stay Live

A variable with no NEXT_PUBLIC_ prefix is never sent to the browser at all — it only ever exists inside process.env on the server. Whether it's evaluated once at build time or freshly on every request depends on whether the code reading it opts into dynamic rendering — and Chapter 1 already established that a Route Handler like /api/convert does exactly that, by default, since Next.js 15. Building a real Route Handler that reads a plain SECRET_MESSAGE variable, building the app with one value set, then running the finished standalone server with a genuinely different value in its own environment confirms this concretely:

// .env at build time: SECRET_MESSAGE=build-time-value $ npm run build // running the built server with a DIFFERENT value: $ SECRET_MESSAGE=runtime-override-value node .next/standalone/server.js $ curl http://localhost:3000/api/env-test {"secret":"runtime-override-value"} // the runtime value, not the build-time one
The Real Payoff of Chapter 1's Dynamic-by-Default Finding
This is a direct, concrete consequence of Route Handlers being dynamic by default, not something specific to environment variables on their own. A dynamically-rendered route genuinely re-runs on every request, so any process.env read inside it re-reads the environment fresh each time too — the exact opposite of a NEXT_PUBLIC_ variable's own build-time freeze. In practice, this means the production server can be started with a real secret — a database URL, an API key, or (once Chapter 6's own architecture is actually built out) a Redis connection string — set as a genuine runtime environment variable, changeable per deployment without ever needing a rebuild.

Process Management with PM2

node .next/standalone/server.js run directly in a terminal dies the moment that terminal closes, and won't restart itself if it crashes. PM2, the same process manager this site's own sibling Node-backed courses already use, solves both problems:

$ npm install -g pm2 $ PORT=3000 HOSTNAME=0.0.0.0 pm2 start .next/standalone/server.js --name romaji-nextjs $ pm2 save $ pm2 startup // registers PM2 itself to start on server boot

PM2 restarts the process automatically if it crashes, keeps it running across the terminal session ending, and gives a real, queryable process list (pm2 list) and live log stream (pm2 logs romaji-nextjs) without any custom tooling.

An nginx Reverse Proxy

With the app itself running on port 3000, nginx sits in front of it, handling the public-facing port and (per this site's own HTTPS/TLS Fundamentals course) real TLS termination:

server { listen 80; server_name romaji.example.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } }

Nothing about this proxy config is specific to this course's own architecture — it's the same real pattern this site has already used for every other Node-backed course — which is itself a quiet confirmation of Chapter 1's own point: this project deploys like any single Node application, because that's genuinely all it is.

Hands-On Exercises

Exercise 1

This chapter measured a real ~22.7x reduction in node_modules size using the standalone output mode (432MB full vs. 19MB pruned). Explain, in your own words, why a real production install of this project's own future Redis client (Chapter 6) would make this pruning step matter even more than it already does for the app as it stands today.

📄 View solution
Exercise 2

This chapter showed that skipping the manual public/.next-static copy step produces a real 404 for a static file while the homepage itself still returns 200. Explain why these two things fail differently, tracing the difference back to where each one's own content actually comes from.

📄 View solution
Exercise 3

A teammate suggests moving the site's public-facing "converter is temporarily under maintenance" flag into a NEXT_PUBLIC_ environment variable, so it can be toggled without redeploying the client UI. Using this chapter's own real, verified findings, explain why that plan wouldn't actually work the way they expect, and what the real fix would be.

📄 View solution

Chapter 7 Quick Reference

  • One deploy, not two — the direct payoff of Chapter 1's own single-project architecture, confirmed by having only one process to build and run
  • output: 'standalone' — traces and copies only the files actually needed, verified ~22.7× smaller than a full node_modules install
  • public/ and .next/static aren't copied automatically — verified: skipping them fails silently, per-asset, not by crashing the whole app
  • NEXT_PUBLIC_ variables are frozen at build time — verified: the literal value is baked into a static chunk file; changing it needs a rebuild
  • Server-only variables in /api/convert stay live — verified: because the route is dynamic by default (Chapter 1), it re-reads process.env fresh on every request