Tackling Build-Time Bloat in Next.js Apps

Static Generation excels at performance, but large applications eventually hit a wall: build times that grow with every added page or asset. Netlify’s On-Demand Builders, paired with Next.js’ Incremental Static Regeneration (ISR), address this by deferring work until it’s actually needed. The result is faster deployments and more flexibility in how and when content updates.

The core problem is straightforward. When a static site grows to thousands of routes, every deployment regenerates all of them. Resource-heavy apps face a similar issue with asset optimization — resizing, re-encoding, and re-uploading files on each build. And since Jamstack serves from the edge, all that output must also be propagated across the CDN.

Data adds another wrinkle. Content changes whether we deploy or not, so static pages tend to go stale between builds. The standard answer has been incremental builds that only regenerate what changed. That works, but it requires careful cache invalidation so unchanged pages aren’t rebuilt.

ISR: Time-Based Rebuilding

Next.js introduced Incremental Static Regeneration to handle this on a per-route basis. Instead of rebuilding everything on every deploy, each route can declare how often it should regenerate. It’s conceptually like a max-age header, but for your pages.

Adopting ISR is minimal from a code perspective:

export async function getStaticProps() {
  const { limit, count, pokemons } = await fetchPokemonList()
  
  return {
    props: {
      limit,
      count,
      pokemons,
    },
    revalidate: 3600 // seconds
  }
}

Adding the revalidate property to your getStaticProps method makes the page rebuild at the specified interval. This approach separates content updates from code deploys. If your content lives in a CMS, you can fix typos or refresh products in seconds, without triggering a full build.

However, ISR has its own limitations:

  1. Full builds on deploy: Incremental regeneration only kicks in after deployment and only for data, not code.
  2. Time-based invalidation: Unnecessary rebuilds happen when nothing changed, while critical updates can be delayed until the next revalidation window.

Deferring Builds with Netlify On-Demand Builders

Netlify’s On-Demand Builders (ODB) extend this concept across frameworks like Eleventy and Nuxt. Instead of building everything at deploy time, you classify parts of your app:

  • Critical: Built at deploy time, like normal.
  • Deferred: Not built at deploy time. The first request triggers a build, and the output is then cached like any other static asset.

Setting up a builder requires just a dependency and a function:

yarn add -D @netlify/functions

With the package installed, create a file in netlify/functions/ as you would for a regular Netlify Function. The difference is in how you wrap the handler:

import type { Handler } from '@netlify/functions'
import { builder } from '@netlify/functions'

const myHandler: Handler = async (event, context) => {
  return {
    statusCode: 200,
    body: JSON.stringify({ message: 'Built on-demand! 🎉' }),
  }
}
export const handler = builder(myHandler)

The builder() method connects the function to build tasks, enabling on-demand generation. That’s all needed to defer part of your site until it’s first requested.

Applying ODB in Next.js

For a better Next.js experience on Netlify, two plugins matter. The Netlify Build Plugin Cache Next.js improves caching and needs manual installation. The Essential Next.js plugin adjusts the framework’s architecture to fit Netlify and is auto-installed for new projects.

The practical shift comes in how you define pre-rendered paths. In a Proof-of-Concept that fetches Pokémon data from the PokéAPI, the getStaticPaths function changes significantly. Previously, the code fetched all Pokémon names and set fallback: false so unknown routes returned a 404:

export async function getStaticPaths() {
  const { pokemons } = await fetchPkmList()
  return {
    paths: pokemons.map(({ name }) => ({ params: { pokemon: name } })),
    fallback: false,
  }
}

With On-Demand Builders, no paths are pre-rendered. Instead, the route renders on demand and keeps the user waiting until it’s ready:

export async function getStaticPaths() {
  return {
    paths: [],
    fallback: 'blocking',
  }
}

The fallback: 'blocking' mode ensures that when a page is requested, it’s built and served immediately rather than showing a fallback. This works well for this demo, but pre-rendering traffic-heavy pages is recommended for production scenarios.

Measuring the Difference

To benchmark the two approaches, the demo capped requests to 1000 Pokémon pages for consistency. Both versions were deployed to separate branches to keep the comparison clean.

export const fetchPkmList = async () => {
  const resp = await fetch(`${API}pokemon?limit=${LIMIT}`)
  const {
    count,
    results,
  }: {
    count: number
    results: {
      name: string
      url: string
    }[]
  } = await resp.json()
  return {
    count,
    pokemons: results,
    limit: LIMIT,
  }
}

The ODB setup was extreme: zero pages were pre-rendered for the dynamic route. That yields the clearest picture of the build-time gains, even if it isn’t a real-world recommendation.

StrategyNumber of PagesNumber of AssetsBuild timeTotal deploy time
Fully Static Generated100210052 minutes 32 seconds4 minutes 15 seconds
On-Demand Builders2052 seconds52 seconds

With lightweight pages and lean image assets, the deploy-time savings were substantial. For applications with medium to large route counts, deferring builds is worth serious consideration. Deployments become faster and more frequent, and end-user performance remains identical after the first request — the rendered page is cached on the edge, same as a fully static page.

Beyond ODB: Distributed Persistent Rendering

Netlify didn’t stop at On-Demand Builders. The company also published a Request for Comments on Distributed Persistent Rendering (DPR), outlining a future direction that pairs ODB-style builds with solid caching.

Under DPR, you’d no longer need to fully build every page for a deployment. Consider an e-commerce site with 10,000 product pages and a two-hour build time. With DPR, the 500 highest-traffic pages build at deploy time, always fresh. The remaining 9,500 pages would be deferred to a post-build hook that triggers their generation asynchronously. They get cached immediately, and users never experience a missing page.

The implementation is still conceptual, but the trajectory is clear. Faster builds, fewer full recompilations, and the flexibility to prioritize what actually needs regenerating. Lifecycle improvements like DPR show how Jamstack tooling is maturing to handle large-scale apps without sacrificing user experience.

Comparing Caching And Data-Fetching Strategies

For e-commerce or other sites that can't reasonably defer content generation to the edge, the choice typically narrows to three primary approaches: static generation with a forced rebuild every time the data changes, caching within your own backend which itself gets refreshed in a different way, or a mix of the two. Each offers a distinct trade-off between control and operational complexity.

A forced, scheduled rebuild is simple and, at small scale, satisfies both marketing and development teams. You set a timer to invalidate and re-generate the data. This, however, stops working at scale when the time or cost requirement of that regeneration becomes prohibitive. The scheduled process dictates your deployment cadence, so intermittent changes between those intervals will simply not appear.

Using ISR Within The Data Layer

An alternative approach is to keep the front-end purely static, then shift the data-fetching problem upstream to your existing API layer or database tier. Using Incremental Static Regeneration (ISR) along with a background worker to revalidate a local cache works well here. Your front-end becomes redundant, with your data service doing the regeneration in separate, isolated steps.

The revalidation events triggered by CRUD operations can be extremely granular. Consider updating a single product: the worker notices, re-fetches that one API record, updates the product view in your backend cache, and returns the original static files at the edge. This completes the "on-demand" loop without adding a heavy requirement to your build pipeline or forcing your API rates up.

Ongoing problems here are twofold. You now have to coordinate logic across two systems (generation pipelines and API services). Also, the editor experience can lag, since you still can't escape the inherent delay between a database transaction and its rendering into a static view. Despite this, the model is sound for teams that value ease over granular control.

The Architectural Triad

As complexity grows, architects mix two of the three strategies above. Crucially, one solves for the transient products in your range (like a fixed-date offer), while the other covers the long-tail pages that change rarely. ISR is now used as a way to keep recently accessed pages updated near-real-time through revalidation on tag changes, while older items rely on a schedule.

The "experiments" that complicate caching logic, and the way you branch for authorized users, are better isolated in the middleware layer to avoid breaking your deterministic build. Stripping personalized data out of the static core—by placing it behind a flag or dynamically fetching it on the client—keeps the architecture on a single path.

Local First, Dynamic Where It Matters

Deciding which of these to use does not need to be prompted by scale. Shifting your page generation strategy away from a one-off, full-rebuild approach pays off as soon as two or three content editors are editing concurrently with a marketing push. The cost of implementing any of the variants above is modest, and it affords you a shorter path to making updates public.

Note that keeping foundational pages (like landing or SEO content) fully static is rarely a misfire; it gives the fastest TTFB you can offer. The mix introduces a few edge cases around cache headers and request routing. Keeping validation for the different conditional headers at a single, well-used entry point outperforms maintaining ad-hoc timeouts. The tooling has matured enough that the missing piece is less about your host—whether you use Netlify, Vercel, or a personal CI—and more about your effective data validation strategy.

The simple check: if a data roundtrip takes more than a small, fixed budget, your architecture suffers whether you're "static" on paper or not. Keeping generation updates out of the critical path, favoring a decomposed and cache-friendly approach, means you can lose the monolith and nobody on the content team hits the brakes.

Smashing Editorial