Build & Output
Understanding What `next build` Actually Produces
LinkedIn Hook
"I ran
next buildand saw a bunch of symbols I didn't understand."Circles, filled circles, lambdas — the Next.js build output is trying to tell you something critical about how your app will behave in production. Most developers glance at it and move on. Senior engineers read it like a diagnostic report.
Understanding your build output is the difference between deploying a fast, cost-efficient app and deploying a ticking time bomb of server costs and slow responses.
In Lesson 8.1, I break down every symbol, explain standalone output mode for Docker deployments, compare Edge vs Node.js runtime, and walk through the most common build errors and how to fix them.
Read the full lesson -> [link]
#NextJS #WebDevelopment #Deployment #DevOps #FrontendDevelopment #InterviewPrep
What You'll Learn
- How to read every symbol and metric in
next buildoutput - The difference between static pages, dynamic pages, and ISR pages in build results
- How standalone output mode works and why it matters for Docker/containerized deployments
- When to choose Edge runtime vs Node.js runtime and the trade-offs of each
- The most common build errors you will encounter and how to fix them systematically
The Blueprint Analogy — Reading the Architect's Notes
Before a skyscraper is built, the architect produces blueprints — detailed drawings that show what every floor looks like, which walls are load-bearing, which rooms have plumbing, and which are simple empty spaces. A construction worker who ignores the blueprints will eventually knock out a load-bearing wall and bring the whole building down.
next build is your blueprint. Every line in the output tells you something about how that route will behave once deployed. The static pages are like pre-fabricated rooms — built in the factory, shipped ready to install. The dynamic pages are custom-built on site, requiring workers (server compute) every time someone walks in. And standalone output mode is like packaging the entire building into a shipping container — everything you need, nothing you don't.
Ignoring the build output is like a contractor who never reads blueprints. Things might work, until they catastrophically don't.
+------------------------------------------------------------------+
| THE BLUEPRINT MODEL |
+------------------------------------------------------------------+
| |
| Static (○) [ Pre-fab room ] --> Ship & install --> $0 |
| |
| ISR (○ + rev) [ Pre-fab room ] --> Swap panels on --> $ |
| + renovation a timer |
| |
| Dynamic (λ) [ Custom build ] --> Build per visit --> $$$ |
| on-site |
| |
| Standalone [ Entire bldg ] --> Ship in one --> 🐳 |
| in a container container |
| |
+------------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Four horizontal lanes showing building metaphors. Static = pre-fab wall panel (green #10b981), ISR = pre-fab with clock icon (teal), Dynamic = construction crane (purple #8b5cf6), Standalone = shipping container (orange). Arrows showing deployment flow. White monospace labels. Title: 'Reading the Build Output'."
Understanding next build Output Symbols
When you run next build, Next.js analyzes every route in your application and produces a report. Here is what a typical build output looks like:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 89.1 kB
├ ○ /about 1.8 kB 85.7 kB
├ ● /blog 3.1 kB 87.0 kB
├ ● /blog/[slug] 2.9 kB 86.8 kB
├ λ /dashboard 4.5 kB 88.4 kB
├ λ /api/users 0 B 0 B
├ ○ /contact 1.2 kB 85.1 kB
└ λ /search 3.8 kB 87.7 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
λ (Dynamic) server-rendered on demand
First Load JS shared by all 83.9 kB
├ chunks/framework-*.js 45.2 kB
├ chunks/main-*.js 28.1 kB
└ other shared chunks 10.6 kB
The Symbol Breakdown
○ (Hollow circle) — Static. This page was rendered to HTML at build time. No server computation happens at request time. The HTML file sits on a CDN and is served instantly. This is the cheapest and fastest option. If you see this symbol for all your marketing pages, documentation, and legal pages — you are doing it right.
● (Filled circle) — SSG with generateStaticParams. This page was pre-rendered at build time using dynamic route parameters. For example, /blog/[slug] generated individual HTML files for each blog post. The filled circle means data was fetched during the build process. These pages can also have a revalidate value, making them ISR pages that refresh in the background.
λ (Lambda) — Dynamic. This page is server-rendered on every request. Something in the route opted it out of static generation — calling cookies(), headers(), searchParams, using cache: 'no-store', or setting dynamic = 'force-dynamic'. Every time a user hits this route, Next.js runs your server code. This costs server compute on every request.
Size Columns Explained
Size is the JavaScript unique to that route — the code that only loads when you navigate to this specific page. Keep this as small as possible.
First Load JS is the total JavaScript the user downloads the first time they land on this page — shared framework code plus route-specific code. This is the number that matters for Core Web Vitals. If this number is over 100 kB for most routes, you have a bundle size problem.
+------------------------------------------------------------------+
| BUILD OUTPUT READING GUIDE |
+------------------------------------------------------------------+
| |
| Symbol Meaning Server Cost CDN Cacheable? |
| ------ ------- ----------- --------------- |
| ○ Static HTML None Yes (permanent) |
| ● SSG (params) Build only Yes (with TTL if ISR) |
| λ Dynamic SSR Every request No (unless custom) |
| |
| Goal: maximize ○ and ●, minimize λ |
| |
+------------------------------------------------------------------+
What Causes a Page to Become Dynamic (λ)?
Understanding why a page becomes dynamic is crucial for optimizing your build. Here are the triggers:
// Trigger 1: Using cookies() or headers()
// These are per-request values, so the page must render per-request
import { cookies } from 'next/headers';
export default async function Page() {
const cookieStore = await cookies(); // This forces dynamic rendering
const theme = cookieStore.get('theme');
return <div>Theme: {theme?.value}</div>;
}
// Trigger 2: Accessing searchParams
// Query strings differ per request, so the page cannot be static
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams; // This forces dynamic rendering
const results = await search(q);
return <div>{results.length} results for "{q}"</div>;
}
// Trigger 3: Using cache: 'no-store' on a fetch
// Tells Next.js this data must be fresh on every request
export default async function Page() {
const data = await fetch('https://api.example.com/live-data', {
cache: 'no-store', // This forces dynamic rendering
});
return <div>{JSON.stringify(await data.json())}</div>;
}
// Trigger 4: Explicitly opting in
// Sometimes you know the page needs to be dynamic
export const dynamic = 'force-dynamic'; // This forces dynamic rendering
export default async function Page() {
return <div>Always server-rendered</div>;
}
The rule of thumb: If Next.js cannot guarantee the output is the same for every user, it marks the route as dynamic. Your job is to push as many routes as possible toward static.
Standalone Output Mode
By default, next build produces output that depends on node_modules being present. This is fine for platforms like Vercel that manage your dependencies, but it creates bloated Docker images and complicates self-hosted deployments.
Standalone output mode solves this by producing a self-contained build that includes only the files your application actually needs to run.
Enabling Standalone Mode
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;
What Standalone Produces
After running next build with standalone enabled, you get this structure:
.next/
├── standalone/
│ ├── node_modules/ <-- Only the packages your app actually imports
│ ├── server.js <-- Minimal Node.js server (replaces next start)
│ ├── package.json
│ └── .next/
│ └── ... <-- Compiled application code
├── static/ <-- Static assets (CSS, JS, images)
└── ...
The standalone folder is a complete, runnable Node.js application. You can copy it to any machine with Node.js installed and run node server.js — no npm install needed.
The Docker Use Case
This is where standalone mode shines. Without it, your Docker image includes the entire node_modules folder (often 500 MB+). With standalone, you only copy what your app needs:
# Multi-stage Dockerfile for Next.js with standalone output
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production image — only the standalone output
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy only what standalone needs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
The result: a production Docker image that is typically 80-150 MB instead of 500 MB+. Faster pulls, faster deploys, smaller attack surface.
What Standalone Does NOT Include
Standalone does not copy public/ or .next/static/ into its folder automatically. You must copy those yourself (as shown in the Dockerfile above). This is the most common mistake when adopting standalone mode — your app runs but images, fonts, and static assets return 404.
+------------------------------------------------------------------+
| STANDALONE vs DEFAULT BUILD |
+------------------------------------------------------------------+
| |
| Default Build: |
| .next/ + node_modules/ (ALL of them) + public/ |
| --> 500 MB+ Docker image |
| --> Needs `next start` command |
| |
| Standalone Build: |
| .next/standalone/ (traced deps only) + .next/static/ + public/ |
| --> 80-150 MB Docker image |
| --> Runs with `node server.js` |
| |
+------------------------------------------------------------------+
Edge Runtime vs Node.js Runtime
Next.js lets you choose between two server runtimes for your routes: the full Node.js runtime (default) and the lightweight Edge runtime. This choice affects where your code runs, what APIs you can use, and how fast your responses are.
Node.js Runtime (Default)
The Node.js runtime is the traditional server environment. Your route handler or server component runs in a full Node.js process with access to the entire Node.js API — file system, Buffer, crypto, streams, native modules, and all npm packages.
// app/api/report/route.ts — Node.js runtime (default)
// Full Node.js access: file system, streams, native modules
import { readFile } from 'fs/promises';
import { NextResponse } from 'next/server';
export async function GET() {
// This requires Node.js — cannot run on Edge
const template = await readFile('./templates/report.html', 'utf-8');
return new NextResponse(template, {
headers: { 'Content-Type': 'text/html' },
});
}
Edge Runtime
The Edge runtime runs your code on a lightweight V8-based environment at CDN edge locations — physically closer to your users. It starts in milliseconds (no cold boot like Node.js) and has very low latency. But it comes with strict constraints: no file system access, no native Node.js modules, a maximum execution time (usually 30 seconds), and a size limit on the deployed code.
// app/api/greet/route.ts — Edge runtime
// Lightweight, fast cold start, runs at CDN edge locations
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const name = searchParams.get('name') || 'World';
return new Response(`Hello, ${name}!`, {
headers: { 'Content-Type': 'text/plain' },
});
}
// app/fast-page/page.tsx — Edge runtime for a page
// This page renders at the edge, not in a central Node.js server
export const runtime = 'edge';
export default async function FastPage() {
const data = await fetch('https://api.example.com/config', {
cache: 'no-store',
}).then(r => r.json());
return <div>Config: {data.region}</div>;
}
The Comparison
+------------------------------------------------------------------+
| EDGE vs NODE.JS RUNTIME |
+------------------------------------------------------------------+
| |
| Dimension Edge Runtime Node.js Runtime |
| --------- ------------ --------------- |
| Cold start ~0 ms (instant) 50-250 ms |
| Location CDN edge (global) Central server |
| Node.js APIs No (Web APIs only) Full access |
| Max exec time ~30 seconds No limit (configurable) |
| Bundle size ~1-4 MB limit No limit |
| npm packages Limited (no native) All packages |
| File system No Yes |
| Streaming Yes Yes |
| Database HTTP-based only Any (TCP, HTTP, etc.) |
| Best for Auth checks, A/B Heavy computation, |
| tests, redirects, database queries, |
| geo-personalization file processing |
| |
+------------------------------------------------------------------+
When to Use Each
Use Edge when:
- You need the lowest possible latency (geo-redirects, A/B testing, authentication checks)
- Your logic is simple and uses only Web APIs (fetch, Request, Response, crypto.subtle)
- You want to run code close to the user without a central server
Use Node.js when:
- You need native Node.js APIs (fs, child_process, Buffer manipulation)
- You are running heavy computation or long-running tasks
- You use npm packages that rely on native modules (sharp, bcrypt, prisma with certain adapters)
- You need direct TCP database connections
Default to Node.js unless you have a specific reason to use Edge. The Edge runtime's constraints catch many developers off guard. A common mistake is deploying to Edge and discovering your ORM or image processing library does not work there.
Common Build Errors and How to Fix Them
Build errors are the wall between your code and production. Here are the ones you will encounter most frequently and the systematic approach to fixing each.
Error 1: "Dynamic server usage" in a Static Page
Error: Dynamic server usage: cookies
This page is statically generated but uses dynamic server features.
Why it happens: You are using cookies(), headers(), or searchParams in a page or layout that Next.js expects to be static.
Fix: Either remove the dynamic API call or explicitly mark the route as dynamic:
// Option A: Mark the route as dynamic
export const dynamic = 'force-dynamic';
// Option B: Move the dynamic logic to a client component
// that reads cookies via document.cookie or a client-side library
Error 2: Module Not Found During Build
Module not found: Can't resolve 'fs'
Why it happens: A server-only module (like fs) is being imported in a client component or in code that the client bundle can see. The browser has no file system, so Webpack refuses to bundle it.
Fix: Ensure the import only runs on the server. Use Next.js conventions:
// Option A: Mark the file as server-only
import 'server-only';
import { readFileSync } from 'fs';
// Option B: Use dynamic import with server-side check
// (less recommended — Option A is cleaner)
Error 3: "Text content does not match server-rendered HTML"
Error: Hydration failed because the initial UI does not match
what was rendered on the server.
Why it happens: The HTML generated on the server differs from what React renders on the client. Common causes: using Date.now(), Math.random(), or browser-only APIs like window.innerWidth during the initial render.
Fix: Defer browser-only values to after hydration:
'use client';
import { useState, useEffect } from 'react';
export default function Clock() {
// Do not render the time during SSR — it will mismatch
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
// This only runs on the client, after hydration
setTime(new Date().toLocaleTimeString());
}, []);
// Show a placeholder during SSR, real value after hydration
return <span>{time ?? '--:--:--'}</span>;
}
Error 4: Build Timeout / Out of Memory
FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory
Why it happens: Your build process is generating too many pages, processing large assets, or your dependencies create an enormous bundle.
Fix:
# Increase Node.js memory for the build
NODE_OPTIONS="--max-old-space-size=8192" next build
# Or in package.json
# "build": "NODE_OPTIONS='--max-old-space-size=8192' next build"
Also investigate: Are you pre-rendering 100,000 pages at build time? Use generateStaticParams to return only the most important subset and let the rest be generated on-demand.
Error 5: ESM/CJS Module Compatibility
SyntaxError: Cannot use import statement outside a module
Why it happens: A dependency uses ESM syntax but the build expects CommonJS, or vice versa.
Fix: Use transpilePackages in your config:
// next.config.js
const nextConfig = {
transpilePackages: ['problematic-package'],
};
module.exports = nextConfig;
Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Five horizontal error cards with red left border, each showing an error name in white monospace and a one-line fix in emerald green (#10b981). Cards stacked vertically with spacing. Title: 'The 5 Build Errors You Will See'. Purple (#8b5cf6) numbering on each card."
Common Mistakes
1. Ignoring the build output entirely. The build report tells you exactly which pages are static and which are dynamic. If you expected a page to be static but it shows λ, you have an accidental dynamic dependency. Always review the route table after every build.
2. Not using standalone mode for Docker deployments.
Copying your entire node_modules into a Docker image is wasteful and slow. Standalone mode traces only the dependencies your app imports, cutting image size by 60-80%. If you are containerizing a Next.js app and not using output: 'standalone', you are paying unnecessary costs in storage, bandwidth, and deploy time.
3. Choosing Edge runtime without checking dependency compatibility. Edge runtime does not support Node.js-specific APIs. If your route uses Prisma with a non-Edge adapter, sharp for image processing, or any package with native C++ bindings, it will fail at runtime — not at build time. Always test Edge routes with your full dependency chain before deploying.
4. Forgetting to copy static assets in standalone Dockerfiles.
Standalone mode does not automatically include public/ or .next/static/. Your app will start and routes will render, but images, fonts, and client-side JavaScript will 404. This is the single most common standalone deployment bug.
5. Blaming the framework when the build fails with OOM.
Out-of-memory errors during build are almost always caused by pre-rendering too many pages or importing massive dependencies. Before increasing --max-old-space-size, check how many pages generateStaticParams is creating and whether you are importing unnecessary libraries in server components.
Interview Questions
1. "Walk me through the output of next build. What do the symbols mean?"
The build output shows a route table where each route is prefixed with a symbol indicating its rendering strategy. A hollow circle (○) means the page is fully static — pre-rendered at build time, served from CDN with zero server compute. A filled circle (●) means the page uses SSG with generateStaticParams — it was pre-rendered for specific dynamic route parameters at build time, and may use ISR if a revalidate value is set. A lambda (λ) means the page is server-rendered on every request because it uses dynamic features like cookies(), headers(), searchParams, or has cache: 'no-store' on a fetch call. The Size column shows route-specific JS, and First Load JS shows total JS a user downloads on first visit. The goal is to maximize static routes and minimize dynamic ones.
2. "What is standalone output mode and when would you use it?"
Standalone mode (output: 'standalone' in next.config.js) produces a self-contained build that bundles only the Node.js dependencies your application actually imports, plus a minimal server.js entry point. You would use it for Docker/container deployments and any self-hosted scenario where you do not want to ship the full node_modules folder. It reduces Docker image size from 500 MB+ to around 80-150 MB. The key caveat is that public/ and .next/static/ are not included automatically — you must copy them separately in your Dockerfile. Without standalone, you need next start and all of node_modules; with standalone, you just run node server.js.
3. "When would you choose Edge runtime over Node.js runtime for a route?"
I would choose Edge runtime when the route needs the absolute lowest latency and uses only Web-standard APIs — for example, authentication token validation, geo-based redirects, A/B test assignment, or simple API responses that call external HTTP services. Edge functions start instantly (no cold boot) and run at CDN edge nodes close to the user. I would stick with Node.js runtime for anything that needs the full Node.js API: file system access, native modules like sharp or bcrypt, direct TCP database connections, or long-running computations. The critical mistake is choosing Edge and discovering at runtime that a dependency requires Node.js APIs — so I always verify dependency compatibility before opting into Edge.
4. "A page you expected to be static shows up as dynamic (λ) in the build output. How do you debug this?"
I would trace the dependency chain starting from the page component. The most common causes are: calling cookies() or headers() anywhere in the component tree (including layouts that wrap the page), accessing searchParams in the page props, using cache: 'no-store' on a fetch, or having dynamic = 'force-dynamic' set in a parent layout. I would check each layout in the route hierarchy — a dynamic layout makes all child pages dynamic. If the dynamic call is only needed for a small part of the page, I would move that logic into a client component or a Suspense boundary with a server component that uses cookies(), so the rest of the page can remain static.
5. "Your Docker image for a Next.js app is 1.2 GB. How would you reduce it?"
First, I would enable output: 'standalone' in next.config.js. This alone typically cuts the image to 80-150 MB by eliminating unused node_modules. Second, I would use a multi-stage Dockerfile — one stage for building (with devDependencies), one slim stage for running (only the standalone output, static files, and public folder). Third, I would use a minimal base image like node:20-alpine instead of the full Debian-based image. Fourth, I would check the build output for oversized bundles and remove unnecessary dependencies. Finally, I would add a .dockerignore file to exclude .git, node_modules, .next (from the host), and test files from the build context.
Cheat Sheet
| Concept | Key Point |
|---|---|
| ○ symbol | Static page — pre-rendered at build time, served from CDN |
| ● symbol | SSG page — pre-rendered with generateStaticParams, possibly ISR |
| λ symbol | Dynamic page — server-rendered on every request |
| Size column | Route-specific JS bundle size |
| First Load JS | Total JS on first visit (shared + route-specific) |
| Standalone mode | output: 'standalone' — self-contained build for Docker |
| Standalone caveat | Must manually copy public/ and .next/static/ |
| Edge runtime | export const runtime = 'edge' — fast, limited APIs |
| Node.js runtime | Default — full API access, central server |
| Dynamic triggers | cookies(), headers(), searchParams, cache: 'no-store' |
| OOM fix | NODE_OPTIONS="--max-old-space-size=8192" |
| Hydration mismatch | Defer browser-only values to useEffect |
| Module not found (fs) | Add import 'server-only' or check client/server boundary |
| Goal | Maximize ○ and ●, minimize λ |
Prev: Lesson 7.4 -- Metadata & SEO Next: Lesson 8.2 -- Environment Variables
This is Lesson 8.1 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.