"One Day Before Launch, The Site Crawls."
I vividly remember the day I launched my first e-commerce site built with Next.js. Feature development was flawless. Payments worked, the cart was smooth, and the design was pixel-perfect. I deployed it to an AWS EC2 t3.small instance and typed the domain with trembling hands.
Enter.
... ... ... (3 seconds) ... (5 seconds)
The screen appeared. After 5 full seconds.
"Wait, it was instant on localhost!" I checked the server resources—CPU was hitting 100%. With just one user (me). Even the simple Landing Page took over 2 seconds to load. Images were optimized, fonts subsetted. What the hell was wrong?
After pulling an all-nighter, I stared blankly at the build logs I had ignored.
Route (app) Size First Load JS
┌ λ / 5.4 kB 89 kB
├ λ /about 2.1 kB 84 kB
├ λ /blog/[slug] 3.5 kB 87 kB
└ λ /products 1.5 kB 83 kB
Every route had a λ (Lambda) symbol next to it.
I thought it was just the Next.js logo or something cool. But it was actually a warning: "Your site is running at worst-possible performance."
I thought I built a Static Site. In reality, I had built a site where every single page was Server-Side Rendered (SSR).
Decrypting the Code: The Performance Report Card
The summary output of npm run build isn't just noise. It is a Report Card that determines your server bills and user churn rate.
Understanding these symbols can boost performance by 10x without spending a dime.
1. ○ (Static) - The Best State
"Automatically rendered as static HTML (uses no initial props)"
- Meaning: "This page looks the same for everyone. So I baked it into an HTML file during build."
- Behavior: When a user visits, the server does nothing. It just flings a pre-made
index.htmlfile via Nginx (or CDN). - Performance: Fastest. TTFB (Time to First Byte) is around 10~50ms.
- Cost: Almost zero Server CPU. Only S3 bandwidth costs.
- Goal: Except for personalized pages (My Page, Cart), every page should be this.
2. ● (SSG / ISR) - The Good State
"Automatically generated as static HTML + JSON (uses getStaticProps)"
- Meaning: "It needed data (DB, API), but I fetched it all during build and baked it."
- Behavior: Uses
getStaticPropsorgenerateStaticParams. The result is still a static file. - Performance: Very Fast. Same as ○ (Static).
3. λ (Dynamic / SSR) - The Danger Zone
"Server-side renders at runtime (uses getInitialProps or getServerSideProps)"
- Meaning: "This page changes per request. I must run logic every time a user visits."
- Behavior:
- Request arrives.
- Node.js server wakes up (Potential Cold Start).
- Connect to DB & Query Data (Latency).
- Render React Components (CPU intensive).
- Generate HTML & Respond.
- Performance: Slow. (Min 200ms ~ Seconds). Highly dependent on DB performance.
- Cost: Where Lambda bills and EC2 CPU spikes come from. Traffic spikes = Server Crash.
My logs were all λ. Even the static Company Info page (/about).
Basically, I wasn't using Next.js properly; I was using a confusing, expensive version of PHP.
Finding the Culprit: Why Did I Become λ? (The De-optimization Trap)
"Wait, I never used getServerSideProps! I only used fetch in the App Router!"
I felt cheated. Didn't Next.js 13 advertise "Static by Default"? But Next.js has a trap called "Dynamic Functions." If you use any of specific functions anywhere in your code, Next.js demotes that entire page to Dynamic.
Culprit 1: cookies() and headers() (Most Common Mistake)
To show a different Global Navigation Bar (GNB) based on login status, I wrote this in layout.tsx:
// app/layout.tsx
import { cookies } from 'next/headers';
export default function RootLayout({ children }) {
const cookieStore = cookies(); // <-- THE CULPRIT!
const accessToken = cookieStore.get('accessToken');
return (
<html>
<body>
<Navbar isLoggedIn={!!accessToken} />
{children}
</body>
</html>
);
}
This code is a disaster.
RootLayout wraps every page. Calling cookies() here declares: "Every page in this site depends on the request (cookie)."
Consequently, /, /about, /blog—everything turns into λ (Dynamic). Static optimization is wiped out.
Culprit 2: searchParams Props
In a blog list page, I accessed searchParams to get the current page number.
// app/blog/page.tsx
export default function Blog({ searchParams }) { // <-- CULPRIT
const page = searchParams.page || '1';
// ...
}
Query strings like ?page=2 differ per request. Next.js sees this and says, "Oh, you use query strings? Can't pre-build that." -> Dynamic Switch.
Is this reasonable? Page 1 of a blog list is always the same. It doesn't need to be Dynamic.
Culprit 3: fetch with no-store
fetch('https://api.example.com/posts', { cache: 'no-store' });
"I want the latest posts," so I mindlessly disabled the cache. I gained data freshness but lost page load speed. This single line forces the entire page to SSR.
Escape Strategy: Returning to ○ (Static)
Knowing the problem, I tore down and rewrote the code. The goal: "Make everything static except personalized pages."
Strategy 1: Isolate cookies() to Client Components
Do NOT read cookies in Server Components (layout.tsx).
If you need to change UI based on login, use a Client Component with useEffect or a dedicated library (Auth.js SessionProvider).
// app/layout.tsx (Server Component)
// cookies() removed!
export default function RootLayout({ children }) {
return (
<html>
<body>
<AuthProvider> {/* Client Component */}
<Navbar />
</AuthProvider>
{children}
</body>
</html>
);
}
Now RootLayout can be static again. Authentication checks happen only in the browser (Client).
Strategy 2: Use generateStaticParams Aggressively
Using a dynamic route ([slug]) doesn't strictly mean λ. You just need to inform Next.js of "all possible paths" in advance.
// app/blog/[slug]/page.tsx
// This function is key!
export async function generateStaticParams() {
const posts = await getPosts(); // Get all slugs from DB
return posts.map((post) => ({ slug: post.slug }));
}
export default function Post({ params }) { ... }
With this, Next.js runs getPosts() at build time and pre-bakes HTML for blog/1, blog/2, blog/3... (●).
Strategy 3: Force Static (force-static)
The ultimate weapon.
"I read headers() (e.g., to check IP), but if it's empty during build, just use a default value! Please make it static!"
For these hybrid cases:
export const dynamic = 'force-static';
Adding this line to the top of the page forces a static build, making dynamic functions (cookies(), headers()) return empty/undefined during the build process instead of opting out of static generation.
The Result: Green Lights
After days of refactoring, I ran the build again.
Route (app) Size First Load JS
┌ ○ / 5.4 kB 89 kB
├ ○ /about 2.1 kB 84 kB
├ ● /blog/[slug] 3.5 kB 87 kB
├ ○ /login 1.5 kB 83 kB
└ λ /my-page 1.2 kB 80 kB
Finally!
Main, About, Blog, Login—all returned to ○ (Static) or ● (SSG). Only /my-page remained λ (Dynamic), which is expected as it's personalized.
Performance test after deployment?
- Initial Load: 5.2s -> 0.3s (17x faster)
- Server CPU: 100% -> 1%
- Cost: Downgraded from t3.small ($20/mo) to t4g.nano ($3/mo) and it's still napping.
Summary
Next.js doesn't make your site fast automatically. Used wrong, it can be slower than PHP.
- Always, ALWAYS check the
npm run buildlogs before deploying. - Feel fear when you see the λ (Lambda) symbol. Ask yourself: "Does this page really need to be redrawn from scratch for every visitor?"
cookies(),headers(), andsearchParamsare 90% likely to be the silent killers. Trap them insideSuspenseboundaries or banish them to the Client side.
Love the ○ and ●. Allow λ only when absolutely necessary. That is the only way to protect both your users' patience and your wallet.