How to Fix: Ensure the component tree is only rendered once during SSG/revalidations #67680. Why hasn’t it been merged in the release?

7 min read

The bug is expensive because SSG and ISR revalidation can render the same React component tree more than once per regeneration cycle, which means duplicated data fetching, unnecessary backend traffic, slower rebuilds, and confusing production behavior when you expect a single static render.

What happens during revalidation

The issue discussed in this Next.js pull request appears when a statically generated route is regenerated after its cache becomes stale. During that regeneration window, the server can end up rendering the component tree multiple times instead of once. If your page performs database reads, CMS calls, signed API requests, logging, metrics writes, or expensive transforms inside server components, those operations may execute repeatedly for a single revalidation event.

This is why developers notice spikes in request counts during Incremental Static Regeneration. Even though the final output is one HTML payload and one updated cache entry, the path to producing that output may involve duplicate render work.

Understanding the Root Cause

At a technical level, the problem sits at the intersection of React Server Component rendering, static generation, and the internal orchestration Next.js uses to collect HTML, RSC payloads, metadata, and cache artifacts.

In affected builds, the framework may trigger more than one pass over the same component tree while preparing the static result. That can happen when separate phases of the rendering pipeline independently request data needed for:

  • the HTML output,
  • the RSC payload,
  • metadata resolution,
  • cache population during revalidation,
  • or fallback handling for stale entries.

If those phases are not sharing the same render result correctly, the tree is recomputed. In practice, that means functions inside server components run more than once, including fetch() calls that are not fully deduplicated by request memoization.

This is especially visible when:

  • the page uses dynamic data sources during static generation,
  • multiple nested server components each fetch remote data,
  • the route has revalidate enabled,
  • or the page does side-effecting work during render, which it should avoid but many real apps still do.

The reason developers ask why the fix has not appeared in a release is usually simple: a pull request being open, approved, or even green does not guarantee merge readiness. Framework changes affecting the render pipeline are high risk. Maintainers may delay merging because of:

  • regression risk across App Router and Pages Router,
  • interactions with caching, prefetching, or streaming,
  • failing internal tests not visible from the issue summary,
  • release branch timing,
  • or a need to validate behavior against edge runtimes and production canaries.

So the short answer is: the issue is real, the fix target is sensitive, and release inclusion depends on framework stability, not only on the existence of a PR.

Step-by-Step Solution

Until the upstream fix is merged and released, the practical solution is to make your render path idempotent, reduce duplicate work, and move unstable side effects outside the render tree.

1. Confirm the duplicate render behavior

Add temporary instrumentation to the page and the server-side data layer.

let renderCount = 0
export default async function Page() {
  renderCount++
  console.log('Page render count:', renderCount)

  const data = await getProductData()
  return <div>{data.name}</div>
}
export async function getProductData() {
  console.log('Fetching product data')

  const res = await fetch('https://example.com/api/products/1', {
    next: { revalidate: 60 }
  })

  if (!res.ok) throw new Error('Failed to fetch product')
  return res.json()
}

Trigger a revalidation cycle and inspect logs. If you see repeated fetches during one regeneration event, you are hitting the issue pattern.

2. Remove side effects from the component tree

If your server components perform writes, analytics submissions, queue publishing, or mutation-like behavior during render, move that logic elsewhere. Rendering must be safe to repeat.

// Bad: side effect inside render path
export default async function Page() {
  await logPageGeneration()
  const data = await getData()
  return <PageView data={data} />
}
// Better: render remains read-only
export default async function Page() {
  const data = await getData()
  return <PageView data={data} />
}

Use route handlers, background jobs, webhooks, or explicit server actions for mutations instead of doing them while generating static output.

3. Centralize and memoize shared data access

When several nested components fetch the same resource, wrap the loader with React cache or a single shared function so duplicate render passes do less damage.

import { cache } from 'react'
export const getProductData = cache(async (id) => {
  const res = await fetch(`https://example.com/api/products/${id}`, {
    next: { revalidate: 60 }
  })

  if (!res.ok) {
    throw new Error('Failed to fetch product data')
  }

  return res.json()
})

This does not fix the framework bug itself, but it reduces repeated backend calls within the same request lifecycle.

4. Use Next.js fetch caching correctly

Make sure your fetch options match your rendering goal. Incorrect cache directives can cause more recomputation than necessary.

const res = await fetch('https://example.com/api/catalog', {
  next: { revalidate: 300 }
})

Avoid forcing dynamic behavior unless you truly need it:

// This disables static optimization and changes behavior
const res = await fetch('https://example.com/api/catalog', {
  cache: 'no-store'
})

If a route is expected to be static with timed regeneration, prefer next.revalidate and stable request keys.

5. Consolidate data fetching higher in the tree

If multiple child components independently fetch related resources, fetch once in the page or layout and pass the result down.

export default async function Page() {
  const [product, reviews, inventory] = await Promise.all([
    getProduct(),
    getReviews(),
    getInventory()
  ])

  return <ProductScreen product={product} reviews={reviews} inventory={inventory} />
}

This reduces the surface area affected by duplicate renders and makes logging easier.

6. Guard expensive non-fetch work with your own cache layer

If the page performs CPU-heavy transformations, introduce a persistent cache in Redis, memory, or your platform cache.

export async function getComputedCatalogVersion() {
  const cached = await redis.get('catalog:computed')
  if (cached) return JSON.parse(cached)

  const raw = await getCatalogData()
  const computed = expensiveTransform(raw)

  await redis.set('catalog:computed', JSON.stringify(computed), 'EX', 300)
  return computed
}

This is useful when duplicate rendering amplifies expensive processing beyond just network requests.

7. Track the upstream fix and verify your version

Check the linked pull request, release notes, and canary changelog before assuming the fix should already exist in stable. Framework fixes often land in canary first.

npm info next version
npm info next dist-tags
npm install next@canary

Test canary in a staging environment only. Render-pipeline fixes can change cache semantics, so validate pages with revalidation, metadata, streaming, and nested layouts.

8. Add regression monitoring

Even after mitigation, measure revalidation behavior explicitly:

  • count upstream API calls per regenerated path,
  • log render invocations for key pages,
  • track ISR regeneration duration,
  • compare stable versus canary builds.
console.log(JSON.stringify({
  route: '/products/[id]',
  phase: 'revalidate',
  ts: Date.now()
}))

Without observability, duplicate rendering can remain invisible until costs increase.

Common Edge Cases

1. Duplicate renders still happen even with fetch deduplication

fetch() memoization helps only when requests are identical and share the same execution context. If request options differ, headers differ, or non-fetch work happens around the fetch, duplication still hurts.

2. Metadata generation triggers extra work

If generateMetadata loads the same data as the page, you can accidentally double your backend calls. Reuse a shared cached loader.

import { cache } from 'react'
const getPost = cache(async (slug) => {
  const res = await fetch(`https://example.com/api/posts/${slug}`, {
    next: { revalidate: 120 }
  })
  return res.json()
})
export async function generateMetadata({ params }) {
  const post = await getPost(params.slug)
  return { title: post.title }
}
export default async function Page({ params }) {
  const post = await getPost(params.slug)
  return <article>{post.title}</article>
}

3. External APIs treat repeated requests as abuse

Some vendors rate-limit, bill per request, or invalidate signatures if called repeatedly. If your page revalidates often, duplicate renders can become an operational problem quickly.

4. Side effects hidden in utility functions

You may think the page is read-only, but a helper may write logs, update counters, or touch session state. Audit the full call stack, not just the page component.

5. Dynamic route params create cache fragmentation

If request keys vary by locale, cookies, headers, or search params, your mitigation may appear ineffective because the cache is split across many variants.

6. Mixing static and dynamic directives

Using revalidate together with APIs that force dynamic rendering can produce surprising behavior. Check for things like reading request headers, cookies, or using no-store in nested loaders.

FAQ

Why is the component tree rendered more than once during SSG or revalidation?

Because different internal rendering phases may independently evaluate the same tree when producing static artifacts such as HTML, RSC payloads, and metadata. If those phases do not fully share results, duplicate execution happens.

Why has the pull request not been merged into a release yet?

Changes in the Next.js render pipeline are high risk. A PR can remain unmerged because maintainers are validating regressions, waiting for broader test coverage, targeting a later release window, or refining behavior before shipping it to stable.

What is the safest workaround right now?

Treat server rendering as repeatable: keep render code free of side effects, share cached loaders, use correct fetch caching, and test the latest canary if you want to verify whether the upstream fix changes your specific route behavior.

The practical takeaway is clear: you cannot always control when the framework reruns a server component tree, but you can control whether that rerun is cheap, deterministic, and safe. That is the right production posture until the upstream fix is fully merged and released.

Leave a Reply

Your email address will not be published. Required fields are marked *