All topics
Next.js

6 min read

Rendering Strategies

What actually forces a route into dynamic rendering, choosing which params to pre-render, and streaming the slow parts instead of gating the whole page on them.

Principles

Static is the default until something forces dynamic

A route stays statically rendered at build time unless it reads a dynamic API (cookies, headers, `searchParams`) or an explicitly uncached fetch forces it. Know what actually triggers the switch instead of assuming a route is dynamic because it feels dynamic.

`generateStaticParams` decides what gets built, not what's allowed to exist

Pre-render the paths that matter for SEO and traffic. Params outside that list still render on demand (unless `dynamicParams` is disabled), so choose the list based on value, not an attempt at exhaustiveness.

Streaming ships the shell before the data

Wrapping a slow section in `<Suspense>` lets the static shell paint immediately while the slow part streams in, instead of the whole page waiting on the slowest query it contains.

Dynamic doesn't mean slow, uncached doesn't mean necessary

A route can be dynamically rendered per request while its individual fetches are still cached. Conflating 'this page is dynamic' with 'every fetch on it must be uncached' throws away caching that costs nothing to keep.

`dynamicParams = false` turns an unbuilt path into a real 404

For a route where every valid path is known ahead of time, this catches typos and stale links as a proper 404 instead of quietly rendering — and caching — a page for an ID that no longer exists.

Patterns

What goes wrong, what to do instead, and why the difference matters.

Static shell with a streamed slow section

Avoid

tsx
export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);                // fast
  const recommendations = await getRecommendations(params.id); // slow
  // The whole page — including the fast, already-known product
  // info — waits on the slow recommendations call.
  return (
    <>
      <ProductInfo product={product} />
      <Recommendations items={recommendations} />
    </>
  );
}

Time-to-first-byte is gated by the slowest data dependency, even for content that had nothing to do with it.

Prefer

tsx
export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);
  return (
    <>
      <ProductInfo product={product} />
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations id={params.id} />
      </Suspense>
    </>
  );
}

async function Recommendations({ id }: { id: string }) {
  const items = await getRecommendations(id); // streams in on its own
  return <RecommendationList items={items} />;
}

The product info paints immediately; recommendations stream in behind a fallback the moment they're ready.

Choosing which params to pre-render

Avoid

ts
// Pre-renders every product in the catalog at build time,
// including thousands that get near-zero traffic.
export async function generateStaticParams() {
  const products = await getAllProducts(); // 40,000 rows
  return products.map((p) => ({ id: p.id }));
}

Build time scales with catalog size, most of it spent pre-rendering pages almost nobody visits.

Prefer

ts
// Pre-renders the top sellers; everything else renders on first
// request and is cached from then on.
export async function generateStaticParams() {
  const topSellers = await getTopSellingProducts({ limit: 500 });
  return topSellers.map((p) => ({ id: p.id }));
}

Build cost matches actual traffic value; long-tail pages still get built, just on first visit instead of every deploy.

Catching bad paths as a 404 instead of an infinite dynamic tail

Avoid

ts
// Any id renders on demand forever — a typo'd or deleted id just
// quietly triggers a fresh render (and a fresh data fetch) every time.
export async function generateStaticParams() {
  return getKnownIds().map((id) => ({ id }));
}

There's no distinction between 'a valid id that just wasn't pre-built' and 'a typo' — both silently render.

Prefer

ts
export const dynamicParams = false;

export async function generateStaticParams() {
  return getKnownIds().map((id) => ({ id }));
}
// Any id outside this list now 404s immediately instead of rendering.

The known-ids list is the actual source of truth for what exists; anything else is correctly a 404.

Review checklist

What I look for when reviewing a pull request that touches this area.

  • Each route's rendering mode (static/dynamic) is understood, not assumed — check what dynamic API or uncached fetch is actually forcing it.
  • `generateStaticParams` covers the paths worth the build-time cost, not the entire dataset by default.
  • Slow, non-critical sections are wrapped in `<Suspense>` so the shell isn't gated on them.
  • A route where every valid path is known sets `dynamicParams = false` to turn unknown paths into real 404s.
  • Dynamic rendering on a route isn't used as a reason to skip fetch-level caching within it.
  • Streaming fallbacks are real skeletons sized to the final content, not a generic spinner that causes layout shift.

Take the skill

Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.

Download
.claude/skills/nextjs-rendering-strategies/SKILL.md

Drop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.

---
name: nextjs-rendering-strategies
description: >-
  Next.js App Router rendering: what triggers static vs dynamic rendering,
  choosing generateStaticParams coverage, streaming slow sections with
  Suspense, and dynamicParams for correct 404s. Use when choosing or
  reviewing a route's rendering strategy.
argument-hint: "[route]"
license: MIT
metadata:
  author: John Felix Lim
  version: "1.0.0"
---
# Rendering Strategies

Apply when a route feels slower than it should, or when deciding how a new route should render. Identify what's actually forcing dynamic rendering before reaching for a blanket force-dynamic or force-static.

## Rules

1. **Static is the default until something forces dynamic** — A route stays statically rendered at build time unless it reads a dynamic API (cookies, headers, `searchParams`) or an explicitly uncached fetch forces it. Know what actually triggers the switch instead of assuming a route is dynamic because it feels dynamic.
2. **`generateStaticParams` decides what gets built, not what's allowed to exist** — Pre-render the paths that matter for SEO and traffic. Params outside that list still render on demand (unless `dynamicParams` is disabled), so choose the list based on value, not an attempt at exhaustiveness.
3. **Streaming ships the shell before the data** — Wrapping a slow section in `<Suspense>` lets the static shell paint immediately while the slow part streams in, instead of the whole page waiting on the slowest query it contains.
4. **Dynamic doesn't mean slow, uncached doesn't mean necessary** — A route can be dynamically rendered per request while its individual fetches are still cached. Conflating 'this page is dynamic' with 'every fetch on it must be uncached' throws away caching that costs nothing to keep.
5. **`dynamicParams = false` turns an unbuilt path into a real 404** — For a route where every valid path is known ahead of time, this catches typos and stale links as a proper 404 instead of quietly rendering — and caching — a page for an ID that no longer exists.

## Patterns

### Static shell with a streamed slow section

**Avoid** — Time-to-first-byte is gated by the slowest data dependency, even for content that had nothing to do with it.

```tsx
export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);                // fast
  const recommendations = await getRecommendations(params.id); // slow
  // The whole page — including the fast, already-known product
  // info — waits on the slow recommendations call.
  return (
    <>
      <ProductInfo product={product} />
      <Recommendations items={recommendations} />
    </>
  );
}
```

**Prefer** — The product info paints immediately; recommendations stream in behind a fallback the moment they're ready.

```tsx
export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);
  return (
    <>
      <ProductInfo product={product} />
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations id={params.id} />
      </Suspense>
    </>
  );
}

async function Recommendations({ id }: { id: string }) {
  const items = await getRecommendations(id); // streams in on its own
  return <RecommendationList items={items} />;
}
```

### Choosing which params to pre-render

**Avoid** — Build time scales with catalog size, most of it spent pre-rendering pages almost nobody visits.

```ts
// Pre-renders every product in the catalog at build time,
// including thousands that get near-zero traffic.
export async function generateStaticParams() {
  const products = await getAllProducts(); // 40,000 rows
  return products.map((p) => ({ id: p.id }));
}
```

**Prefer** — Build cost matches actual traffic value; long-tail pages still get built, just on first visit instead of every deploy.

```ts
// Pre-renders the top sellers; everything else renders on first
// request and is cached from then on.
export async function generateStaticParams() {
  const topSellers = await getTopSellingProducts({ limit: 500 });
  return topSellers.map((p) => ({ id: p.id }));
}
```

### Catching bad paths as a 404 instead of an infinite dynamic tail

**Avoid** — There's no distinction between 'a valid id that just wasn't pre-built' and 'a typo' — both silently render.

```ts
// Any id renders on demand forever — a typo'd or deleted id just
// quietly triggers a fresh render (and a fresh data fetch) every time.
export async function generateStaticParams() {
  return getKnownIds().map((id) => ({ id }));
}
```

**Prefer** — The known-ids list is the actual source of truth for what exists; anything else is correctly a 404.

```ts
export const dynamicParams = false;

export async function generateStaticParams() {
  return getKnownIds().map((id) => ({ id }));
}
// Any id outside this list now 404s immediately instead of rendering.
```

## Review checklist

- [ ] Each route's rendering mode (static/dynamic) is understood, not assumed — check what dynamic API or uncached fetch is actually forcing it.
- [ ] `generateStaticParams` covers the paths worth the build-time cost, not the entire dataset by default.
- [ ] Slow, non-critical sections are wrapped in `<Suspense>` so the shell isn't gated on them.
- [ ] A route where every valid path is known sets `dynamicParams = false` to turn unknown paths into real 404s.
- [ ] Dynamic rendering on a route isn't used as a reason to skip fetch-level caching within it.
- [ ] Streaming fallbacks are real skeletons sized to the final content, not a generic spinner that causes layout shift.

---

From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim