Deploying the front end
The front end is the easy half to deploy, and that is worth knowing, because it means the hard parts of this module are all in the next lesson.
What Next.js actually produces
A Next application is not one thing. next build sorts every route into a kind,
and the deployment story follows from which kinds you have:
Route (app)
┌ ƒ / redirect
├ ƒ /products server-rendered on demand
├ ƒ /products/[slug] server-rendered on demand
├ ƒ /cart server-rendered on demand
└ ƒ /orders/[orderNumber] server-rendered on demand
| Symbol | Kind | What it needs at runtime |
|---|---|---|
○ |
Static | a file server |
● |
Prerendered at build | a file server |
ƒ |
Dynamic | a running Node process |
Every route in this shop is ƒ, and that is not an accident: the catalogue
reads cookies to render the basket count, and the cart and orders are personal.
Reading a cookie makes a route dynamic by definition — the answer differs
per visitor.
So the Kirana Store cannot go on static hosting. No S3 bucket, no GitHub Pages. It needs somewhere that runs Node.
That is normal for a shop. But learn to read that table, because a marketing
site with ○ everywhere can sit on a CDN for nothing, and knowing which you
have is the difference between a zero bill and a real one.
The easy path: Vercel
Vercel is made by the people who make Next, so the integration is as good as it
gets: connect the repository, and every push to main deploys while every pull
request gets its own URL.
Those preview URLs are worth more than they look. Somebody who is not you clicks through the change before it is merged, which catches what a screenshot in a pull request never does.
Three things to configure, and one of them wastes an afternoon:
Root directory is apps/web, because this is a monorepo.
Environment variables, per environment. Production's API_URL points at the
deployed API; preview points at a staging one.
NEXT_PUBLIC_API_URL is baked in at build time. Change it and you must
rebuild. Setting it on a running deployment does nothing, because the value is
already inside the JavaScript the browser downloaded. That is the one.
The portable path: a container
If you would rather not be tied to one platform, or you are deploying to a cloud you already pay for, the whole thing goes in a container.
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/shared/package.json packages/shared/
COPY apps/web/package.json apps/web/
RUN npm ci
COPY packages/shared packages/shared
COPY apps/web apps/web
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build --workspace=packages/shared \
&& npm run build --workspace=apps/web
Two things here are most of the craft of writing a Dockerfile.
Copy the manifests before the source
Docker caches each instruction as a layer and reuses it when its inputs have not
changed. Copying the manifests alone means npm ci is reused on every build
where dependencies did not change — which is almost all of them.
Copy everything first and npm ci reruns on every one-character change to a
component. That is fifteen seconds against three minutes, on every push,
forever.
ARG for build time, ENV for runtime
ARG is a build argument, passed with --build-arg. It has to be, because
NEXT_PUBLIC_ values are compiled into the bundle before the container ever
runs.
And the corollary, again, because it is the one people get wrong: a secret passed this way is published. It ends up in a file every visitor downloads.
Standalone output
// next.config.mjs
output: "standalone",
outputFileTracingRoot: new URL("../../", import.meta.url).pathname,
standalone traces which files the server actually needs and writes a
self-contained server.js beside them. The runtime image then copies three
things and no node_modules:
COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static apps/web/.next/static
COPY --from=build /app/apps/web/public apps/web/public
Roughly 1.2 GB down to roughly 200 MB. That is a real difference on every deploy, every autoscale event and every cold start.
outputFileTracingRoot is the monorepo detail. Without it, tracing starts at
apps/web, misses packages/shared, and the container dies at startup with a
module it cannot find.
The runtime image is a different image
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/web/.next/standalone ./
USER node
EXPOSE 3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/web/server.js"]
A multi-stage build: the second FROM starts a fresh image and only what is
explicitly copied comes across. The shipped image has no TypeScript compiler,
no test runner and no dev dependencies — smaller to pull, and less in it for
anybody looking for something to exploit.
Three details that each cost somebody an afternoon the first time:
USER node. Containers run as root unless told otherwise. A process that
does not need root should not have it, and that matters on the day something
else has already gone wrong.
HOSTNAME=0.0.0.0. A server bound to localhost inside a container is
reachable only from inside that container. The logs say "ready on port 3000" and
every request from outside times out. There is no error to search for, which is
what makes it so expensive.
CMD ["node", …], not npm start. npm sits between the signal and your
process, so a SIGTERM at shutdown reaches npm and not Node — and the platform
eventually kills a process that never got the chance to finish its requests.
Caching, which is the actual performance work
return get(`/products${toQueryString(query)}`, {
revalidate: 60,
tags: ["catalogue"],
});
Sixty seconds. A shop whose products change a few times a day does not need a database query per visitor, and sixty seconds of staleness on a price is invisible.
The cart is the opposite, and absolutely:
cache: "no-store",
A cart cached for one visitor and served to another is the worst bug in this entire application, and it is exactly what an over-eager cache setting produces.
Tags are what make invalidation precise:
revalidatePath("/", "layout");
A write invalidates what it changed rather than waiting out the sixty seconds. Module 13 had the fuller version — including the one people forget, that cancelling an order returns stock and so must invalidate the catalogue too.
Images
next/image resizes, converts to modern formats and serves the right size per
device. Each of those transformations is billed on most platforms.
unoptimized={src.endsWith(".svg")}
The shop's product illustrations are SVG, so there is nothing to optimise and they skip it. Real photographs would go through it.
And they are files in the repository, not links to somebody else's server. A hotlinked image rots — the host moves it, rate-limits you, or starts serving something else — and the shop looks broken with nothing in the code to explain why.
Check your work
Why every route is ƒ: they read cookies, and a per-visitor answer cannot be
a static file.
What that rules out: static hosting. The shop needs a running Node process.
Why preview deployments are worth it: somebody who is not you clicks through before it merges.
Why NEXT_PUBLIC_ needs a rebuild: it is compiled into the bundle, so
setting it at runtime does nothing.
Why manifests are copied before source: Docker reuses the install layer, so a code change does not reinstall dependencies.
What output: "standalone" saves: about a gigabyte, by tracing only what
the server needs.
Why outputFileTracingRoot is needed here: tracing would start at
apps/web and miss packages/shared.
Why USER node: a process that does not need root should not have it.
Why HOSTNAME=0.0.0.0: bound to localhost, a container is unreachable from
outside and its logs look perfect.
Why CMD ["node"] rather than npm start: npm swallows the shutdown
signal.
Why the cart is no-store: a cached cart served to the wrong visitor.
Practice
- Run
npm run build --workspace=apps/weband read the route table. Find a route you could make static, and work out what stops it. - Remove
output: "standalone", build the image, and compare sizes. - Remove
outputFileTracingRoot, rebuild, and run the container. - Move the source
COPYabovenpm ci. Change one character in a component and time both builds. - Put a fake secret in
NEXT_PUBLIC_SECRET, build, and grep.nextfor it. - Remove
HOSTNAME=0.0.0.0and try to reach the container from your machine. - Remove
USER nodeand runwhoamiinside the running container. - Change the cart's fetch to
revalidate: 60. Add something in one browser and open the cart in a private window. - Deploy the front end with the API still on your laptop. Work out from the error what is actually wrong.
- Set
revalidate: 3600on the catalogue, change a price in the database, and time how long the shop lies to you.
Next: the half that is genuinely hard — the API and the database.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship