RizTech Academy logo
RizTech Academy
Shipping ItLesson 2 of 530 min

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

  1. Run npm run build --workspace=apps/web and read the route table. Find a route you could make static, and work out what stops it.
  2. Remove output: "standalone", build the image, and compare sizes.
  3. Remove outputFileTracingRoot, rebuild, and run the container.
  4. Move the source COPY above npm ci. Change one character in a component and time both builds.
  5. Put a fake secret in NEXT_PUBLIC_SECRET, build, and grep .next for it.
  6. Remove HOSTNAME=0.0.0.0 and try to reach the container from your machine.
  7. Remove USER node and run whoami inside the running container.
  8. Change the cart's fetch to revalidate: 60. Add something in one browser and open the cart in a private window.
  9. Deploy the front end with the API still on your laptop. Work out from the error what is actually wrong.
  10. Set revalidate: 3600 on 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