Resolving Next.js ISR Fallback Pages for Missing CMS Content

Part of Next.js ISR Implementation, this guide handles the case where an ISR route requests a slug the CMS can’t resolve — unpublished, deleted, or returning a soft 404 — the fallback mechanism enters an undefined state: an indefinite skeleton, an unhandled serialization error, or a permanently cached empty page. The guide isolates the three causes and gives the exact getStaticProps and edge patterns that return a clean 404 instead.

The ISR lifecycle gap

ISR runs on a contract between build-time routing and request-time delivery. With fallback enabled in getStaticPaths, the first visitor to an ungenerated route triggers a server fetch, Next.js compiles the page, caches the HTML at the edge, and serves it. That assumes deterministic data. When the CMS returns a 404, a null payload, or malformed JSON, the framework may render a loading skeleton forever, throw during serialization, or cache an empty response at the CDN.

Three architectural misalignments cause it:

  1. Unvalidated CMS responses: getStaticProps assumes a successful fetch without notFound or error branching, violating Next.js serialization rules.
  2. Aggressive CDN caching: the edge caches the initial fallback or error response, bypassing later revalidate cycles and serving broken markup to everyone.
  3. Draft/unpublished routing: editors publish slugs to calendars or sitemaps while the payload stays in draft, so ISR fetches incomplete or restricted data.

How Data Fetching & Caching Strategies intersect with the ISR lifecycle determines whether these stalls degrade Core Web Vitals in production.

Reproducing the failure

Request a slug that exists in routing but is unpublished, deleted, or restricted:

  1. Deploy a dynamic route with fallback: 'blocking' or fallback: true.
  2. Request /blog/archived-slug or /products/discontinued-id.
  3. Watch the CMS return { "status": 404, "data": null }, an empty array, or a 401 from an expired preview token.
  4. Check server logs for Error: getStaticProps returned undefined, TypeError: Cannot read properties of null, or hydration warnings.

The root issue: Next.js needs a deterministic, serializable return shape from getStaticProps. A soft 404 (HTTP 200 with empty data) or missing required fields breaks serialization, triggers hydration errors, or caches malformed HTML that persists until a manual purge.

Fallback architecture

Handle getStaticProps failures explicitly

Validate the response and signal missing content to the router so the framework never tries to render incomplete data. Every CMS response should resolve to exactly one of two outcomes — render props or notFound:

Every CMS response resolves to exactly one outcomegetStaticProps branches on the CMS status and payload: a 404 or soft 404 returns notFound, a 5xx or network error throws so the last good page survives, and a valid payload returns props with a revalidate window.getStaticPropsfetch slugHTTPstatusthrowkeep last good pagenotFound: true404.tsx, 404 statusPayload valid?publishedAt setprops +revalidate: 605xx404200validsoft 404
Missing content becomes a real 404; an unreachable CMS becomes a thrown error, which ISR answers by keeping the last good page.
TypeScript
// pages/blog/[slug].tsx
import { GetStaticPaths, GetStaticProps } from 'next';

interface PostData {
  slug: string;
  title: string;
  content: string;
  publishedAt: string;
}

export const getStaticPaths: GetStaticPaths = async () => {
  const res = await fetch(`${process.env.CMS_API_URL}/posts?status=published`);

  if (!res.ok) {
    throw new Error(`Failed to fetch paths: ${res.status}`);
  }

  const posts = await res.json();

  return {
    paths: posts.map((post: { slug: string }) => ({
      params: { slug: post.slug },
    })),
    fallback: 'blocking',
  };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const slug = params?.slug as string;

  try {
    const res = await fetch(`${process.env.CMS_API_URL}/posts/${slug}`);

    // Hard 404 from the CMS
    if (res.status === 404) {
      return { notFound: true };
    }

    if (!res.ok) {
      throw new Error(`CMS request failed: ${res.status}`);
    }

    const data = await res.json();

    // Soft 404 / empty payload
    if (!data || !data.publishedAt) {
      return { notFound: true };
    }

    return {
      props: { post: data },
      revalidate: 60,
    };
  } catch (error) {
    console.error(`[ISR] Failed to fetch post ${slug}:`, error);
    // Rethrow: during background regeneration Next.js keeps serving the
    // last good page; returning notFound here would 404 a page that exists.
    throw error;
  }
};

{ notFound: true } renders 404.tsx and stops the framework from caching an empty or malformed payload. The catch block deliberately does not return notFound: a CMS outage is not evidence that the content is gone. Throwing during a background regeneration leaves the previously generated page in place; throwing during the very first generation of a route under fallback: 'blocking' produces a 500 for that one request, which is correct and is not cached. This follows the deterministic data-resolution approach in Next.js ISR Implementation.

Deterministic fallback UI

With fallback: true, the first request gets a shell while the server fetches. If the CMS fails mid-window, the client router must transition without a hydration crash.

TSX
// components/PostFallback.tsx
import { useRouter } from 'next/router';

export const PostFallback = () => {
  const router = useRouter();
  const isFallback = router.isFallback;

  if (isFallback) {
    return (
      <div className="skeleton-loader" aria-live="polite">
        <div className="skeleton-title" />
        <div className="skeleton-content" />
      </div>
    );
  }

  // If fallback completes but data is missing, Next.js routes to 404.tsx.
  // This component only renders the loading state.
  return null;
};

With fallback: 'blocking', the server waits for data; on notFound it serves 404.tsx with a 404 status and no loading state — generally preferred for content routes where SEO and first-load performance matter.

Fallback modes compared for CMS-driven routesHow fallback false, true and blocking, and the App Router's dynamicParams, behave for a slug that was not generated at build time.ModeUnbuilt slug, first requestMissing contentSEO on first hitfallback: false404 immediately404correct, but new posts need a rebuildfallback: trueskeleton, then client fetch404 after loadcrawler may index the skeletonfallback: blockingwaits for CMS, full HTML404 statusfull HTML, one slow responsedynamicParams: truesame as blockingnotFound()App Router default
For content routes, blocking (or dynamicParams true) gives crawlers a real status code on the first request.

Don’t cache the error at the edge

Edge networks cache ISR responses aggressively. A cached missing-content payload serves the broken state to every subsequent visitor until expiry or manual purge. Control headers and bypass cache for error states.

TypeScript
// middleware.ts
export function middleware(req: Request) {
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {

export function middleware(req: Request) {
  const res = NextResponse.next();

  // Don't let 404s or error pages stick at the edge
  if (req.nextUrl.pathname.startsWith('/blog/') || req.nextUrl.pathname.startsWith('/products/')) {
    res.headers.set('Cache-Control', 'public, max-age=0, s-maxage=60, stale-while-revalidate=300');
  }

  return res;
}

Configure the CDN to respect stale-while-revalidate and bypass cache for x-nextjs-cache: MISS or REVALIDATED. For header semantics, see the MDN Cache-Control reference. With this in place, unpublishing content serves a fresh 404 on the next request instead of a stale broken page.

Configuration Reference

Setting Value for CMS content Why
fallback (Pages) 'blocking' Real HTML and a real status code on the first request.
dynamicParams (App) true Same behaviour in the App Router; unknown slugs render on demand.
revalidate on 404 results inherited from the route A notFound result is also cached for the window, so a slug published later appears after at most one window, or immediately with on-demand revalidation.
Edge s-maxage for 404 60 s or less Keeps a recently unpublished slug from staying cached as a 404 after it is republished.
CMS_API_URL query status=published Never generate paths for drafts.

The row about 404 caching is the one that surprises teams. When getStaticProps returns notFound: true together with a revalidate value, Next.js caches the 404 just like a page. If an editor publishes an entry whose slug was previously requested and cached as missing, the page appears only after that window expires, unless the publish webhook also revalidates the path.

Validation and testing

Verify fallbacks behave under failure before they ship:

  1. Mock CMS failures: use msw or nock to return 404, 500, and empty JSON for specific slugs.
  2. Assert status codes: unpublished slugs must return 404, not 200 with empty markup.
  3. Check hydration: Playwright or Cypress to confirm no hydration mismatch on the fallback-to-notFound transition.
  4. Audit cache headers: Cache-Control and CDN-Cache-Control must match your revalidation strategy and never lock in an error state.
TSX
// __tests__/isr-fallback.test.ts
import { render, waitFor } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/cms/posts/missing-slug', () => {
    return HttpResponse.json({ error: 'Not Found' }, { status: 404 });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('ISR fallback returns 404 for missing CMS content', async () => {
  const { container } = render(<BlogPost slug="missing-slug" />);
  await waitFor(() => {
    expect(document.title).toContain('404');
    expect(container.querySelector('.skeleton-loader')).toBeNull();
  });
});

These belong in Automated Testing for Headless Integrations so routing gaps fail CI, not production.

Content governance

Technical guards need matching editorial workflows:

  • Webhook cache purging: purge or revalidate via webhook when status moves from published to draft or archived.
  • Slug validation gates: warn editors when a slug is already routed in production but has no published payload.
  • Preview isolation: route draft content only to authenticated preview endpoints (/api/preview); never let draft slugs hit production ISR routes.
  • Sitemap syncing: filter unpublished and soft-deleted slugs from automated sitemaps so crawlers don’t trigger fallback requests.

Aligning CMS publishing workflows with the ISR routing contract is what eliminates broken fallback states and keeps cache behavior predictable.

Unpublishing an entry without leaving a stale pageAn editor unpublishes an entry, the webhook revalidates the path and purges the CDN, and the next request regenerates the route, which now returns notFound and a 404 status.EditorCMSRevalidate routeISR routeunpublish entrywebhook: entry.unpublishrevalidatePath(/blog/slug)fetch slug on next request404notFound: true404 cached for window
Unpublish must fire the same webhook path as publish; otherwise the old page survives until its window expires.

Gotchas & Edge Cases

  • Catch-all 404s during outages. Returning notFound from a catch block turns every regeneration during a CMS outage into a deleted page. Rethrow infrastructure errors; return notFound only for confirmed missing content.
  • Localized slugs. A slug that exists in English but not in German returns a CMS 404 for the German route. That is correct, unless your model uses fallback locales, in which case fetch with the fallback chain before deciding.
  • Preview tokens in production fetches. An expired preview token returns 401, which the code above treats as an error and rethrows. Make sure production fetches never use the preview token at all, so a token rotation cannot break published routes.
  • Scheduled publishing. Entries scheduled for a future date exist in the CMS but return 404 from the delivery API. Pre-generating their paths from a calendar produces cached 404s; generate paths only from published entries.

Frequently Asked Questions

Why does my ISR page show a loading skeleton forever?

With fallback: true, the skeleton stays until the client-side fetch for page props completes. If getStaticProps throws or returns an unserializable value, that fetch fails and the router never leaves the fallback state. Switch to fallback: 'blocking' for content routes and make every branch return props or notFound.

Is a 404 from notFound cached by ISR?

Yes. It is cached for the route’s revalidate window like any page. Revalidate the path on publish, so an entry that previously 404’d appears immediately.

How do I handle a CMS that returns 200 with an empty body for missing entries?

Treat it as a soft 404: validate the payload for the fields a published entry always has, such as publishedAt or sys.id, and return notFound when they are missing. The status code alone is not a reliable signal with such APIs.