The code you write is not the code a browser runs. Between them sits a build step, and after it sits the question of how those files reach a person on the other side of the world. Both are usually somebody else's problem right up until the day they are yours — and the failure modes are specific enough that knowing them is worth an hour.
What a build tool actually does
Browsers cannot run TypeScript or JSX, do not want a thousand separate module requests, and have
no idea what import styles from './x.scss' means. The build tool resolves all of
that:
| Job | What it means |
|---|---|
| Transform | TypeScript and JSX become JavaScript. Types are erased — the browser never sees one. |
| Bundle | Follow the import graph and produce a few files instead of hundreds. |
| Split | Cut the graph at each dynamic import() so rarely-used code is a separate chunk. |
| Tree-shake | Drop exports nothing imports. Only works because ES modules are statically analysable. |
| Minify | Shorten names, strip whitespace and comments. |
| Hash | Put a content hash in each filename, which is what makes caching safe. |
| Process assets | Compile Sass, inline small images, copy the rest. |
Configuration for a modern tool is close to nothing:
export default defineConfig({
plugins: [react()],
})That is the demo app's entire Vite config. If you have met older Webpack configurations running to hundreds of lines, this is the thing that changed.
The dev server is not the build
These are two different code paths, which is why "works locally, broken in production" is such a common sentence. The dev server serves modules to the browser more or less as-is for instant startup and hot reloading; the production build does the whole bundle-minify-hash pipeline. Optimisations, environment variables and dead-code elimination can all behave differently.
Always run the production build locally before you ship — npm run
build && npm run preview. It takes thirty seconds and catches the class of bug that
otherwise gets found by users.
Environment variables are not secret
The most expensive misunderstanding in frontend deployment, and it leaks API keys constantly.
A frontend environment variable is substituted into your bundle at build time. It is then a string in a public JavaScript file that anybody can read. It is configuration, not a secret.
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8085';That is a fine use: it changes per environment and is not sensitive — the URL is visible in the
network tab regardless. Build tools deliberately require a prefix (VITE_,
NEXT_PUBLIC_) so that exposing a variable is an explicit act rather than an
accident.
| Fine in a frontend variable | Never |
|---|---|
| API base URL | Database credentials |
Publishable/public keys (e.g. Stripe's pk_) | Secret keys (sk_) — this is the classic leak |
| Feature flags | Third-party API tokens |
| Environment name, build version | Anything you would not print on a billboard |
The rule: if it must stay secret, it lives on a server and the browser asks that server. There is no frontend-only way to keep a secret. The corollary is that a build is per-environment — the same artifact cannot be promoted from staging to production if the API URL was baked in, unless you fetch configuration at runtime instead.
Deploying a static frontend
A built SPA is a folder of static files. There is no server to run, which makes hosting cheap and fast: the files go in object storage, a CDN puts copies near your users, and that is the whole architecture. This site is deployed that way and costs well under a dollar a month.
Cache headers, and the two rules that matter
Content hashing is what makes aggressive caching safe. Because
index-a3f9c2.js changes its name whenever its contents change, it can be cached
forever — a new deploy produces a new name rather than a stale file.
| File | Cache-Control | Why |
|---|---|---|
| Hashed assets — JS, CSS, images | max-age=31536000, immutable | The name changes when the content does. |
index.html | no-cache | It names the hashed files. Cache it and users get last week's app. |
Getting these backwards is the classic deploy bug: everything looks fine for you and users see the old version until they hard-refresh. And if you use a CDN, remember it holds its own copy — invalidate it on deploy, or the new files sit in the bucket unread.
The SPA fallback, which everybody forgets once
Your router handles /orders in the browser. But a user who pastes that URL, or
refreshes on it, sends a request for /orders to the server — and there is no file
there. You get a 404 on every deep link while the app works perfectly if you navigate from the home
page.
The fix is to configure the host to serve index.html for any path that does not
match a file. Every static host has a name for this — rewrite rule, fallback document,
try_files — and it is a one-line setting. It is also the single most common
frontend deployment bug, so check deep links explicitly after your first deploy anywhere new.
When it is not just static files
| Approach | What runs | Good for |
|---|---|---|
| SPA (this app) | Nothing — static files | Apps behind a login, where SEO is irrelevant |
| SSG — static generation | Nothing; HTML built ahead of time | Content sites. Fast and cheap, needs a rebuild to change |
| SSR — server rendering | A Node server per request | SEO plus fresh data. Next.js, Nuxt, SvelteKit |
An SPA sends an empty page and fills it in with JavaScript. That is fine for a dashboard and bad for a public product page a crawler should read — which is the actual reason the frameworks in the third row exist.
A pipeline worth having
Automate the sequence so it is identical every time and nobody deploys from a laptop with uncommitted changes.
On every pull request, in this order — cheapest first, so quick failures fail quickly:
- Install dependencies from the lockfile, so CI builds what you built.
- Lint. Seconds.
- Typecheck. The demo app exposes this separately as
tsc -b --noEmit, because the dev server does not typecheck — it strips types and carries on. A type error can sit in your working app for a week. - Unit and component tests.
- Build. A build failure must never first appear during a deploy.
- End-to-end tests against the built output.
Then on merge to the main branch: build, upload, set the cache headers, invalidate the CDN, and verify. That last step deserves emphasis — a deploy script that does not check its own result will happily report success having uploaded nothing.
Things worth adding once
- A bundle-size check that fails the build if the entry chunk grows past a threshold. Bundles do not blow up in one commit; they creep.
- A Lighthouse run for performance and accessibility scores (post 10).
npm auditfor known vulnerabilities (post 9).- A preview deploy per pull request, so reviewers click the change instead of imagining it. This is the single biggest quality-of-life improvement on most teams.
After it is live
You cannot fix what you never hear about. Users do not file bug reports; they leave.
- Error reporting — Sentry or similar, catching unhandled errors and rejections with a stack trace. Upload your source maps or every trace is minified gibberish. Do not serve source maps publicly unless you are happy publishing your source.
- Real user metrics — the numbers from post 10, measured on real devices rather than your laptop.
- A way back. Deploys break. Being able to roll back in one command is worth more than any amount of pre-release checking. With hashed static files this is usually just re-uploading the previous build.
- An error boundary so one component's crash shows a message instead of a blank white page.
The backend track's post on deployment and observability covers the same ideas from the server side, where the tooling is richer and the stakes are usually higher.
The one thing to take from this post
Nothing in your frontend build is secret, and nothing about your dev server proves your production build works. Run the real build locally before you ship, get the two cache rules the right way round, configure the SPA fallback before someone pastes a deep link into Slack — and make sure that when it does break, you hear about it from a tool rather than from a customer.
That is the end of the track. Back to Get Started for the map, or across to Backend Development for the other half of the same app.