Resolving Next.js ISR Fallback Pages for Missing CMS Content

When a Next.js 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. This 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:

flowchart TD
  A["getStaticProps fetches slug"] --> B{"HTTP status"}
  B -->|"404"| N["return notFound: true"]
  B -->|"5xx / network error"| C["catch block"]
  C --> N
  B -->|"200"| D{"Payload valid? has publishedAt?"}
  D -->|"soft 404 / empty"| N
  D -->|"valid"| P["return props + revalidate: 60"]
  N --> R["Render 404.tsx, never cache broken state"]
  P --> S["Render article, cache at edge"]
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);
    // Fail to notFound so a broken state is never cached
    return { notFound: true };
  }
};

{ notFound: true } renders 404.tsx and stops the framework from caching an empty or malformed payload. 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.

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
// pages/_app.tsx or middleware.ts
import { NextResponse } from 'next/server';

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.

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.