Posts

Fixing ISR on OpenNext and Cloudflare Workers: Runtime-Only Pages Without a Public API

March 16, 2026

In this post, I'll document a bug that looked like "ISR is broken on Cloudflare" but was actually three separate problems stacked together.

I had an interactive blog post that originally fetched data from a public API in the browser. That worked, but it was not the architecture I wanted. I wanted the Worker to fetch the data on the server, render the page with data already embedded, and avoid exposing a public endpoint that could be abused.

The stack was:

  • Next.js Pages Router
  • OpenNext for Cloudflare
  • Cloudflare Workers
  • D1 for the underlying data

After I moved the data fetching into the page generation path, the post deployed but got stuck showing:

Margin rates data is loading
Data will be available shortly. Please refresh in a moment.

Later, when I pushed further toward runtime-only ISR, I also hit 500 errors on the page's /_next/data/...json route.

This post is about what actually went wrong, why the obvious fixes did not work, and the architecture that finally did.

The Goal

The goal sounds simple:

  1. Keep the client dumb.
  2. Fetch data from D1 on the server.
  3. Let the first request generate the page.
  4. Cache it with ISR so later requests are fast.

In practice, Cloudflare Workers and OpenNext add a few constraints that matter a lot:

  • Build-time code does not have the same environment as the deployed Worker.
  • Worker runtime is not a normal Node.js filesystem environment.
  • Some React/MDX tooling assumes dynamic code evaluation, which workerd does not allow.

If you ignore those three facts, you can get a deployment that "works" locally and still fails in production.

Where the First Attempt Failed

My first pass was conceptually straightforward:

export async function getStaticProps() {
  const data = await getMarginRatesData();

  return {
    props: { data },
    revalidate: 43200,
  };
}

The problem was that getStaticProps() runs during build for pre-rendered pages. During next build, there is no live Cloudflare Worker request context and no Worker binding for my D1 database. So the data loader returned null.

That meant I shipped a pre-rendered page with null data already baked in.

With a 12-hour revalidation window, that is a bad failure mode:

  • deploy happens
  • page is generated with null
  • null gets cached
  • users keep seeing the loading fallback
  • the page does not recover quickly because the next regeneration is far away

This is the first important lesson:

If your data source only exists in the Worker runtime, do not assume build-time generation can see it.

Why Runtime-Only ISR Was Still Not Enough

The next idea was correct in principle: stop pre-rendering that post at build time, and let the Worker generate it on the first real request.

That means:

  • do not include the slug in getStaticPaths()
  • use fallback: "blocking"
  • set runtimeOnly = true for the interactive post

That solved the "build bakes null data into the page" problem. But the route still broke for two different reasons.

1. Runtime filesystem assumptions

The page code was still trying to read content from disk at runtime. That is fine in a local Node.js process. It is not a safe assumption on Cloudflare Workers.

This is the pattern I would avoid:

const fileContents = fs.readFileSync(`posts/${slug}.md`, "utf8");

For Workers, treat post content as a build artifact, not as a runtime filesystem dependency.

2. MDXRemote and dynamic code evaluation

The harder issue was more subtle.

My interactive post body was authored in markdown and passed through next-mdx-remote. That package serializes content into a compiledSource string and evaluates it at render time. Under the hood, that relies on dynamic code evaluation.

That is exactly the kind of thing Cloudflare's workerd runtime rejects.

So runtime-only ISR generated a different failure:

  • first request hit the Worker
  • the page generation path tried to render the interactive post body
  • next-mdx-remote attempted runtime evaluation
  • the Worker returned 500
  • /_next/data/...json failed too, because the underlying page generation failed

This was the second important lesson:

Cloudflare-safe ISR is not just about cache configuration. Your entire page generation path has to be runtime-safe.

The Architecture That Actually Worked

The final working setup has four rules.

1. Use runtime-only ISR for data-backed interactive posts

For pages that depend on Worker-only data sources like D1 bindings, I treat them as runtime-generated pages.

The route uses fallback: "blocking" so the first request waits for a full server render instead of returning a 404 or an incomplete client-side shell:

export const getStaticPaths: GetStaticPaths = async () => {
  const paths = getPrerenderPostSlugs().map((slug) => ({
    params: { slug },
  }));

  return { paths, fallback: "blocking" };
};

That one line matters a lot. Without it, runtime-only posts do not exist from Next's point of view.

2. Split interactive posts from normal markdown posts

I stopped treating data-backed posts as "just another markdown file with a special case".

Each interactive post now lives in its own folder:

lib/post-definitions/<slug>/
  meta.ts
  data.ts
  body.md
  blocks.tsx
  definition.ts

That separation matters because interactive posts have extra responsibilities:

  • server-side data loading
  • ISR configuration
  • interactive React blocks
  • runtime-only generation rules

Keeping that logic in one place made the route simpler and made future interactive posts easier to add.

3. Compile prose at build time, render HTML at runtime

This was the biggest fix.

Instead of evaluating MDX on the Worker, I compile interactive body.md files ahead of time into generated HTML segments. The Worker then renders plain HTML plus React blocks.

The flow looks like this:

body.md
  -> build script
  -> generated manifest / generated body segments
  -> Worker renders HTML + block components

That means the Worker does not need:

  • runtime filesystem access to markdown files
  • runtime MDX compilation
  • runtime new Function() evaluation

It only needs to render prebuilt content and fetch live data.

This change also made the authoring experience better. Interactive posts are still markdown-first, but the runtime path is much safer for Cloudflare.

4. Treat missing data as a transient failure, not a long-lived cache state

I kept the normal ISR window at 12 hours for successful renders, but added a short retry interval if data comes back null.

The resolver logic looks like this:

const FAST_REVALIDATE_SECONDS = 60;

function resolveRevalidateSeconds(
  data: unknown | null,
  configuredRevalidate?: number,
  hasDataLoader = false,
): number | undefined {
  const baseRevalidate =
    configuredRevalidate ?? (hasDataLoader ? 43200 : undefined);

  if (baseRevalidate === undefined) {
    return undefined;
  }

  return data != null ? baseRevalidate : FAST_REVALIDATE_SECONDS;
}

This is a small change with a big impact. If generation fails once, the page retries in 60 seconds instead of staying broken for half a day.

The New Page Model

Once the dust settled, the page generation model became much cleaner:

  1. The interactive post is registered as runtimeOnly.
  2. It is excluded from the build-time prerender list.
  3. The first real request hits the Worker.
  4. The Worker loads prebuilt post content from generated artifacts.
  5. The Worker fetches live data from D1.
  6. The Worker returns fully rendered HTML.
  7. OpenNext caches the generated page according to ISR rules.

In other words, the Worker is responsible for data freshness, but it is not responsible for compiling markdown or discovering post files from disk.

That distinction is what made the setup reliable.

Practical Advice If You Are Doing This Yourself

If you are building data-backed posts on OpenNext and Cloudflare Workers, these are the rules I would follow from day one:

1. Do not depend on Worker bindings during build

If your loader needs env.DB, env.FINANCE, or request-scoped Worker context, assume that build-time generation will not have it.

Use one of these patterns instead:

  • runtime-only ISR with fallback: "blocking"
  • SSR with CDN caching if ISR becomes too awkward
  • a build-time snapshot only if you explicitly want build-time data

2. Do not read post files from disk during Worker rendering

If a runtime render path depends on fs.readFileSync() for page content, frontmatter, or templates, fix that before debugging anything else.

Workers are not a good place to pretend you still have a normal application filesystem.

3. Do not use runtime MDX evaluation for Worker-generated content

If the render path needs compiledSource, eval, or new Function(), assume you are heading toward a runtime failure.

Compile content ahead of time, then render plain HTML and explicit React components.

4. Make your null-data path explicit

When server-side data loading fails, decide what happens:

  • return 404
  • return partial content
  • return a placeholder
  • retry quickly

What you should not do is silently cache a broken render for hours.

5. Keep interactive posts as a first-class content type

Do not scatter interactive-post logic across random route files, markdown special cases, and one-off helpers.

Give them a dedicated structure with:

  • metadata
  • data loader
  • markdown body
  • interactive blocks
  • ISR settings

That pays off as soon as you add the second one.

What This Fixed in Practice

After the refactor:

  • the page no longer ships null data from build time
  • the Worker can generate the page on first request
  • the page no longer crashes on runtime MDX evaluation
  • the client no longer needs to call a public API directly
  • the content authoring flow stays markdown-first

Most importantly, the failure modes are now understandable.

Before, "ISR is broken" could mean any of these:

  • build had no access to Worker bindings
  • runtime tried to read files from disk
  • runtime used MDX evaluation that Cloudflare blocks
  • null data got cached for too long

Now each one has a clear boundary and a clear fix.

Final Takeaway

The main thing I learned is that ISR on Cloudflare Workers is less about flipping the right Next.js option and more about respecting the runtime boundary.

If your page generation path is truly Worker-safe, OpenNext can do the job well.

If your page generation path still assumes:

  • build-time access to Worker-only bindings
  • runtime filesystem reads
  • runtime code evaluation for MDX

then ISR will look flaky even though the real problem is architectural.

For my setup, the stable pattern was:

  • runtime-only ISR for data-backed pages
  • blocking fallback
  • precompiled content artifacts
  • direct server-side data loading from D1
  • fast retry when data is missing

That got me the behavior I wanted from the start: private server-side data loading, cached page generation, and no public data API exposed to the client.