Building and Deploying
By the end of this lesson
Produce a production build and deploy it.
Deploying a Next.js application means three things: producing a production build, running it somewhere that can serve requests, and supplying configuration that differs between your machine and the environment users reach.
The build is not the development server with a flag changed. It compiles and minifies your code, drops the development-only checks, decides which routes can be rendered ahead of time, and fails on type errors that the development server tolerates. The first production build of an application usually finds something.
Because a Next.js application runs on both sides of the network, deployment has one hazard that a purely server-rendered application does not: some of your code is downloaded by the browser, and a configuration value used in that code is downloaded with it.
# Compile the production build. Type errors and lint errors fail the
# build by default, which is the point of running it before deploying.
npm run build
# Serve that build. This is not the development server: no hot reload,
# no development warnings, and the timings users will actually see.
npm run start
# The standalone output, when next.config.ts sets output: "standalone".
# server.js here is the entry point for a container.
ls .next/standalone- The build prints one line per route with its size and whether it was rendered statically or will be rendered per request. Read it. A route you expected to be static appearing as dynamic means something in it reads cookies, headers or searchParams, or has a fetch with no-store.
- The first load JavaScript figure is the number to watch over time. It includes the shared framework code, so it will never be tiny, but a jump after adding a dependency tells you which change cost what.
- npm run start requires a build to exist. It serves .next; it does not rebuild. Forgetting to rebuild after a change and then wondering why the fix is missing is a normal afternoon.
- A build failing on a type error that the development server ignored is correct behaviour, not an obstacle. The development server type-checks the files it happens to compile; the build checks the whole project.
- The standalone directory contains a server, the compiled application, and only the node_modules actually needed to run it. Static assets and the public folder are copied alongside it, which is why the Dockerfile further down has three COPY lines rather than one.
Environment variables: how Next.js resolves them, and which ones end up in the browser.
- Server-only variables
- A variable with no special prefix is available through process.env in server components, route handlers and server actions. It is not included in the client bundle. Database URLs, API keys and signing secrets belong here.
- NEXT_PUBLIC_ variables
- A variable whose name starts with NEXT_PUBLIC_ is inlined into the JavaScript sent to the browser. The prefix is a declaration that the value is public. Use it for things that are genuinely public: a site URL, an analytics id, a feature flag.
- Inlined at build time
- NEXT_PUBLIC_ values are substituted during the build, not read at startup. The same image cannot be promoted from staging to production with a different public API URL — that needs a rebuild, or the value has to be passed from the server at request time instead.
- Local files
- .env.local holds your machine's values and is not committed. .env can hold non-sensitive defaults that are. Real values for deployed environments come from the platform's own configuration, not from a file in the repository.
- Committing a secret by accident
- Check that .env.local is in .gitignore before the first commit, not after. A secret that reached a remote branch has to be rotated, because deleting the file does not remove it from the history.
- Missing at runtime
- process.env.SOMETHING is typed as possibly undefined for a reason. Validate the variables you require when the application starts and fail with a clear message, rather than sending an Authorization header reading "Bearer undefined" and debugging a 401 from the other end.
// ---- app/employees/page.tsx — a server component ----
// This function runs on the server and is never sent to the browser,
// so it can read a secret.
export default async function EmployeesPage() {
const response = await fetch("https://api.example.com/employees", {
headers: {
Authorization: "Bearer " + process.env.DIRECTORY_API_KEY,
},
});
if (!response.ok) throw new Error("Could not load the directory");
const employees = await response.json();
return <EmployeeList employees={employees} />;
}
// ---- components/SupportLink.tsx — a client component ----
// "use client" must be the first line of its own file. Everything here
// is downloaded by the browser, so only a public value belongs in it.
"use client";
export function SupportLink() {
return (
<a href={process.env.NEXT_PUBLIC_SUPPORT_URL}>
Contact the directory team
</a>
);
}- The server component reads DIRECTORY_API_KEY. That code stays on the server, the key stays with it, and the browser receives only the rendered employee list.
- The client component can only read NEXT_PUBLIC_SUPPORT_URL, because unprefixed variables are not defined in the browser bundle. The value you get otherwise is undefined, which is the framework telling you the boundary exists.
- Renaming the API key to NEXT_PUBLIC_DIRECTORY_API_KEY to make it work in a client component publishes it. The prefix is not a fix for an undefined value; it is a decision to make the value public.
- Data crosses the boundary as props, and props are serialised into the HTML. Passing a whole record to a client component sends every field, including ones you do not render. Send what the component displays.
- If a client component needs something derived from a secret — a short-lived upload token, for example — generate it on the server and pass the result, never the secret that produced it.
# Stage 1 — install dependencies and run the build.
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 — the runtime image, carrying only what serving needs.
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Without this the server listens on localhost inside the container,
# which nothing outside it can reach.
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]- This needs output: "standalone" in next.config.ts. That setting makes the build emit a self-contained server in .next/standalone, including only the production dependencies the application actually reaches at runtime.
- Two stages keep the result small. The build stage needs the full dependency tree and your source; the runtime stage needs neither, so neither is in the final image.
- The three COPY lines are not interchangeable. standalone provides the server and application code, .next/static holds the built assets it serves, and public holds files you added yourself. Miss the second and the page loads with no styles.
- HOSTNAME=0.0.0.0 is the setting people lose an hour to. The server defaults to localhost, which inside a container means the container only, so port mapping appears to do nothing.
- The image runs as a non-root user. If a process is compromised, this limits what it can do inside the container. It costs two lines.
- This site is deployed this way. The Anvi Learning Academy runs from a standalone build in a container, using the structure above — which is also why the Dockerfile in this repository is worth reading alongside this lesson.
Summary
- The production build is a different artefact from the development server, and its output tells you which routes are static and what each costs
- Unprefixed environment variables stay on the server; NEXT_PUBLIC_ variables are inlined into the browser bundle at build time
- Anything sent to the browser is public, so a secret must never appear in client code, a public variable, or props
- output: "standalone" produces a self-contained server, which is what a container image copies
- In a container, set HOSTNAME to 0.0.0.0 and run as a non-root user
- Server components, server actions and streaming need a Node runtime, so check what your host provides before choosing it
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
The employee portal needs four configuration values: the directory API base URL, an API key for that service, the public site URL used in metadata, and an analytics measurement id.
Decide which of the four are public, name them accordingly, and say where each real value comes from in a deployed environment.
Show solution
The API key is the only secret. It goes in DIRECTORY_API_KEY, with no prefix, and is read only in server components, route handlers or server actions. The other three are visible to anyone using the site anyway, so NEXT_PUBLIC_SITE_URL and NEXT_PUBLIC_ANALYTICS_ID are honest names for two of them.
The API base URL is the interesting one. It is not secret, but it does not have to be public either. If only server code calls that API, leave it unprefixed: the fewer values in the browser bundle, the less there is to review later. Prefix it only when client code genuinely needs it.
Where the values come from: .env.local on your machine, and the platform's configuration for anything deployed — the container orchestrator's environment settings, or a secrets manager for the key. Nothing sensitive is in the repository, and .env.local is in .gitignore.
Validate on startup. A small check that throws a named error when DIRECTORY_API_KEY is missing turns a confusing 401 from someone else's API into a message that says which variable is not set.
# .env.local — your machine only, never committed
DIRECTORY_API_URL=https://directory.internal.example.com
DIRECTORY_API_KEY=replace-with-your-own-key
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_ANALYTICS_ID=placeholder-analytics-id
# Verify the key did not reach the browser bundle. This should
# print nothing at all.
npm run build
grep -r "replace-with-your-own-key" .next/static || echo "not in the client bundle"Think about it
Think about it
A review finds that a third-party API key has been read inside a client component for the last two releases, through a NEXT_PUBLIC_ variable.
What has to happen, and in what order?
Show solution
Rotate the key first. It has been in every bundle downloaded for two releases, so it should be treated as known. Changing the code does not recall what has already been served, and old deploys may still be reachable.
Then move the call. The request that needed the key belongs in a route handler, a server action or a server component, with the client component calling your own endpoint instead. That endpoint can check who is asking, which the third-party key never could.
Then remove the variable and rebuild, so the value stops being inlined. Confirm by searching the build output for the old value, rather than assuming the change took effect.
Last, work out how it got through. Usually the honest answer is that a client component needed a value, the value was undefined, and adding the prefix made the error go away. A check in the review process — any new NEXT_PUBLIC_ variable gets a second pair of eyes — costs little and catches the next one.
Saved in this browser only.
End of the published lessons
That is everything written so far in React & Next.js
More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.