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:
- Unvalidated CMS responses:
getStaticPropsassumes a successful fetch withoutnotFoundor error branching, violating Next.js serialization rules. - Aggressive CDN caching: the edge caches the initial fallback or error response, bypassing later
revalidatecycles and serving broken markup to everyone. - 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:
- Deploy a dynamic route with
fallback: 'blocking'orfallback: true. - Request
/blog/archived-slugor/products/discontinued-id. - Watch the CMS return
{ "status": 404, "data": null }, an empty array, or a401from an expired preview token. - 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:
// 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.
// 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.
// 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:
- Mock CMS failures: use
mswornockto return404,500, and empty JSON for specific slugs. - Assert status codes: unpublished slugs must return
404, not200with empty markup. - Check hydration: Playwright or Cypress to confirm no hydration mismatch on the fallback-to-
notFoundtransition. - Audit cache headers:
Cache-ControlandCDN-Cache-Controlmust match your revalidation strategy and never lock in an error state.
// __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
publishedtodraftorarchived. - 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.
Gotchas & Edge Cases
- Catch-all 404s during outages. Returning
notFoundfrom a catch block turns every regeneration during a CMS outage into a deleted page. Rethrow infrastructure errors; returnnotFoundonly 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.