Next.js 16 — Streaming Soft-404s and ISR Blockers
Verified: 2026-06-11. Two related Next.js 16 App Router production failures, found and fixed across PewSearch, ITW, and churchwiseai-web on 2026-06-09/10. Both are invisible in dev and in "build passes" checks — they only show in HTTP status codes and the prerender manifest.
Problem 1 — Streaming locks the status at 200 (soft-404)
When ANY Suspense boundary is in the render path (loading.tsx, a layout <Suspense>, or the streaming shell of a complex layout), a dynamically-rendered route STREAMS. The HTTP status is locked at 200 before notFound() / redirect() / permanentRedirect() resolve, so every invalid dynamic URL returns 200 + a "Not Found" body. permanentRedirect() is demoted to a client-side NEXT_REDIRECT digest inside the 200 body, which Googlebot ignores.
Consequence: Googlebot records soft-404s, keeps junk URLs, wastes crawl budget, and the junk volume drags down sitewide quality — real pages get deindexed. Found via the founder's GSC Pages report on ITW (46 Soft 404 + 7,270 crawled-not-indexed); same shapes confirmed live on PewSearch. Refs: vercel/next.js discussion #76501.
Disproven non-fixes (each tested on ITW via rebuild + local prod-server curl)
- Removing
loading.tsx(8 files) — still 200. - Removing the layout
<Suspense>— still 200. - Disabling the proxy — still 200.
export const dynamic = 'force-dynamic'— PewSearch[vanity]already had it and still 200'd.- "Call
notFound()before any await" only works if NO streaming boundary exists anywhere upstream — unreliable in real layouts.
The canonical fix — validate in proxy/middleware BEFORE render
Rewrite certain misses to a tiny static route with an explicit status (static = no streaming = the 404 sticks). From pewsearch/web/src/middleware.ts:
function rewriteTo404(request: NextRequest): NextResponse {
return NextResponse.rewrite(new URL("/_not-found-404", request.url), {
status: 404,
});
}
// Verdict handling — ONLY 404 on a CERTAIN miss:
switch (verdict.verdict) {
case "missing": return rewriteTo404(request);
case "redirect": return NextResponse.redirect(new URL(verdict.to, request.url), 308); // retired slug
case "exists":
case "unknown": // any DB error / ambiguity → fall through, never wrong-404
default: return null;
}
/_not-found-404/page.tsx is a tiny static page that calls notFound() (real 404 + styled UI). Existence checks (pewsearch/web/src/lib/proxy-existence.ts) use the SAME slug resolution the page uses — set-based routes via pure functions (unslugifyBook, getTypeBySlug, …), DB-backed routes via one scoped Supabase REST HEAD + count=exact (no supabase-js in the edge bundle).
Safety contract (218K+ real church pages): only 404 when CERTAIN invalid; on ANY query error or ambiguity return unknown and fall through to normal rendering. Retired slugs → 308 to canonical, never 404. Scope the middleware to exact path shapes; deeper/unrelated paths fall through untouched.
Problem 2 — ISR blockers (X-Vercel-Cache: MISS everywhere)
Three independent gotchas, proven on PewSearch PR #55 then re-confirmed on churchwiseai-web PR #846:
- A dynamic API (
headers()/cookies()) in the ROOT layout forces EVERY route dynamic. Prerender manifest ends up with ZERO routes; allrevalidateexports ANDnext.configs-maxageheader rules become silently inert. Both repos hadawait headers()in RootLayout for brand/subdomain chrome toggling. Fix: remove it; defaultexport const revalidate = 3600in the root layout (pages with their own config override). - A dynamic-params route with
export const revalidatedoes NOT enter the prerender manifest withoutgenerateStaticParams. An emptyexport function generateStaticParams() { return []; }registers on-demand ISR (seepewsearch/web/src/app/denominations/[slug]/page.tsx). usePathname()reports the BROWSER URL on middleware-rewritten requests, not the rewritten path — so a pathname-based chrome-hider works for direct/s/foobut NOT for{slug}.pewsearch.com→/s/{slug}rewrites (SSR renders the chrome). Fix: a marker element rendered by the rewritten segment's LAYOUT (layouts DO follow the rewrite) + CSS — PewSearch:body:has(.pro-website-page) .site-chrome { display: none }; CWA:<BrandBareMarker>in 7 brand segment layouts +body:has(.brand-bare) .cwa-chrome { display: none }. Do NOT pair with awindow.locationcheck in the component — hydration mismatch.
Secondary traps when pages flip from dynamic to static:
- Any client component calling
useSearchParams()without a Suspense boundary crashes the build of newly-prerendered pages — self-wrap the component (CWA's only unwrapped consumer wasBrandSalesChatbot). - Client-only host-gated widgets (ChatWidget, CookieConsent) must move behind a client host gate (
<ApexOnly>) since the server no longer knows the host.
ISR blocker checklist (run when cache is MISS everywhere)
Get-Content .next/prerender-manifest.jsonafter a build — countroutes/dynamicRoutes. Zero = something forces global dynamic.- Grep the root layout (and every layout above the route) for
headers()/cookies()/connection(). - Dynamic-params route with
revalidatemissing from the manifest → add emptygenerateStaticParams. - After the fix builds: any
useSearchParams()without Suspense will fail the build — fix, don't suppress. - If chrome/widgets depended on request headers, convert to layout-marker +
:has()CSS or a client host gate.
How to verify
- Local prod server (
pnpm build && pnpm start) — it reproduces the streaming bug AND runs middleware. Vercel previews are 401-protected, so curl can't verify them. curl junk URLs (expect real 404) AND a broad sweep of valid URLs (expect 200 — never wrong-404 a real page). prerender-manifest.json, not response headers — localnext startstill emitss-maxagefor revalidate-tagged routes even when Vercel will treat them as dynamic.- Prod ISR: repeat-request the same URL →
X-Vercel-Cache: MISS → HIT+ risingAge. Beware: a 200 right after merge can be the OLD deployment; gate on response CONTENT (real<title>), never status/size alone. - Chrome hiding on rewrites: Playwright computed-style check (
display: none) against the REAL subdomain/brand host — preview hosts can't exercise hostname middleware. Local subdomain testing ofendsWith()host checks is impossible (local Host carries:port);--host-resolver-rulestests silently leak to real prod if a redirect target domain isn't mapped.
Who fixed what
| Repo | Change | Where |
|---|---|---|
| sermon-illustrations (ITW) | Soft-404 proxy fix (first proof of the pattern) | src/proxy.ts, commit e7f9ce2, 2026-06-09 |
| pewsearch | Soft-404 middleware fix (churches/directory/vanity/care, slug_redirects → 308) | commit 31a75a4, 2026-06-09 |
| pewsearch | Coverage extended to /directory/<state> + /denominations/<slug>; ISR root-cause found | PR #54, 2026-06-09/10 |
| pewsearch | ISR unblocked: 0 → 92 prerender routes + 6 on-demand patterns; chrome via :has() marker | PR #55, 2026-06-10 |
| churchwiseai-web | Soft-404: audit found CWA/wiseaiagency already clean; only SermonWise /templates fixed via scoped middleware | PR #836, 2026-06-09 |
| churchwiseai-web | ISR unblocked: 0 → 900 prerender routes + 13 dynamic patterns; BrandBareMarker + ApexOnly; BrandSalesChatbot Suspense self-wrap | PR #846, 2026-06-10 |
| sermon-illustrations (ITW) | ISR: root layout audited clean (no headers()), no change needed | — |
All live-verified 2026-06-09/10: junk-URL probes return hard 404 on all three prod sites; PewSearch and CWA serve X-Vercel-Cache: HIT / PRERENDER. Related memory: project_nextjs16_streaming_soft404, project_nextjs_isr_blockers_and_rewrite_pathname. DECISION_LOG entries: 2026-06-09 (ITW soft-404 root cause, fixes shipped) and 2026-06-10 (PR #54/#55/#846).