Back to Blog
engineering

The root loading.tsx that turned every redirect into a 200

July 16, 2026
The root loading.tsx that turned every redirect into a 200

The root loading.tsx that turned every redirect into a 200

Last week, our SEO consolidation shipped in code and did nothing on the wire. /faq/draft-work should have 308'd to /tools/draft-work#faq-…. Instead it returned a normal 200 OK and rendered the FAQ page as usual. Same story for notFound() on invalid slugs — Google was seeing 200 on a soft-404 page instead of a real 404. The redirect logic was correct. The response header was lying.

The problem

Linkgo (linkgo.dev) is a Next.js 15 app. As part of an organic-search recovery, we'd merged code that permanently redirects zero-demand FAQ pages onto their parent tool page, so the whole <details> block renders there as a canonical anchor. About 497 URLs were queued up for that redirect. In dev, the redirect worked. In prod, it didn't. Curl was blunt:

$ curl -sSI https://linkgo.dev/faq/draft-work
HTTP/2 200
content-type: text/html

permanentRedirect('/tools/draft-work#faq-draft-work') sits at the top of app/faq/[slug]/page.tsx, above any data fetch. There is no way that code returns 200 by design. But it did — every time, deterministically. notFound() had the same problem: invalid slugs also came back as 200 with a rendered FAQ shell, which Google Search Console had been quietly labeling as soft-404 for weeks.

Why it happened

Next.js redirect(), permanentRedirect(), and notFound() all work by throwing a special error object that the framework catches and translates into an HTTP status. That translation is a header write. Once headers have been committed to the wire, nothing downstream can change them — a thrown redirect after commit is silently no-op-ed to a 200 body.

We had, at the root of the app, this file:

// app/loading.tsx
export default function RootLoading() {
  return <GlobalNavSkeleton />
}

A file called loading.tsx at the root of a Next app/ tree wraps every route in a Suspense boundary. That boundary streams the loading fallback down to the browser as soon as the request is received. For any route marked force-dynamic — which our FAQ and tool pages are, because they read fresh data — the fallback goes out with 200 OK on the very first byte, before the route handler has had a chance to run. By the time permanentRedirect(...) throws further down, the response is already 200-committed. Next has nowhere to put the 308.

The subtlety is that this is not documented as a caveat of loading.tsx. It's a downstream consequence of streaming Suspense boundaries meeting force-dynamic pages meeting redirect/notFound semantics. Locally, on hot-reload, timing sometimes hides it. Under prod load, it's 100%.

What we tried first

We spent a day looking at the wrong things. The redirect predicate was audit-logged, so we knew it was firing. Next up we suspected middleware — nothing there. Then a stale route cache — nope. Then dynamic segment collision — no. We even tried redirect() vs. permanentRedirect() under a temp diagnostic branch just to see if the 307/308 distinction mattered. It did not. Both returned 200.

The turning point was building a fresh create-next-app (Next 15.5.19, our exact version) and porting exactly one file at a time into the empty app until the redirect broke. Bisection took about twenty minutes. The offender was app/loading.tsx. Add it back — 200. Delete it — 307. That was the entire signal.

The fix

- // app/loading.tsx
- export default function RootLoading() {
-   return <GlobalNavSkeleton />
- }
+ (file removed)

One deletion. The moment the file was gone in a preview deploy:

  • /faq/draft-work returned 308 Permanent Redirect to /tools/draft-work#faq-draft-work.
  • Invalid slugs returned 404 Not Found.
  • The 497 queued consolidation redirects went live on the next deploy.
  • Sitewide soft-404 impressions in Search Console started decaying within a few days.

The trade-off was real. The root loading.tsx was there for a reason — a global skeleton that appeared during any client navigation. Deleting it means the initial-nav loading affordance is gone. Our answer was to be surgical: add loading.tsx back only in route segments that never call redirect(), permanentRedirect(), or notFound(). Marketing pages, the homepage, static docs — fine. Anything under /faq/[slug], /tools/[slug], /category/[slug], or any dynamic segment that might redirect — leave it alone.

Before and after

Before After
/faq/<zero-demand-slug> 200 (rendered thin FAQ) 308 → /tools/[tool]#faq-…
/faq/<invalid-slug> 200 (soft-404 shell) 404
Consolidated URLs live 0 of 497 497 of 497
Global nav skeleton on cold nav present absent (add per-segment where needed)

Verification was mundane: a spreadsheet of ~50 slugs, curl each one, confirm the status. /faq/draft-work → 308, /faq/definitely-not-a-real-thing → 404, /tools/draft-work → 200, all rendering correctly with the merged FAQ block SSR'd into the page.

What we learned

Three things, in decreasing order of transferability.

  • Streaming boundaries and force-dynamic pages compose badly with redirect(). Any Suspense boundary that streams to the wire before the wrapped route runs will hard-cap the response at 200. If you're in a Next.js app that uses force-dynamic on segments that also throw redirects, audit every loading.tsx above them.
  • Trust curl over trust the framework. For weeks the redirect predicate showed up cleanly in application logs. The audit said "redirect fired". The HTTP response header said "200". When a log and a header disagree, the header wins — that's what the internet actually sees.
  • Bisect against a fresh scaffold. When a bug is deterministic in your app and impossible in a demo, the fastest path is to reconstruct the demo one file at a time. Not the fun way to spend an afternoon, but it converts "I don't know" into "I know exactly which file" in a bounded number of steps.

What's next

The fix unblocks the FAQ → tool-page consolidation, but it doesn't solve the underlying reason we had 4,000+ thin FAQ pages to consolidate in the first place. That's a separate lever — a demand-based indexability gate that only surfaces FAQs with demonstrated search demand, with a grace window for new ones. That work shipped alongside this fix and is worth its own post; the short version is that reversibility (noindex,follow, never 410) matters more than we initially thought when Google is doing the demoting rather than the crawling.

We're also adding a loading.tsx linter check to CI: if the file appears above any segment that calls redirect/permanentRedirect/notFound, fail the build. Cheaper than another twenty-minute bisection.


Try Linkgo

Linkgo is a curated directory of AI tools, agents, MCPs, and models — browse the catalog at linkgo.dev.

Website

Share

Frequently Asked Questions

Why were redirects in the Next.js app returning a 200 status instead of 308 or 404?

The root cause was the presence of a root-level loading.tsx file that streamed a loading fallback immediately, committing a 200 status before redirect or notFound errors could set the correct HTTP status. This meant redirect() and notFound() calls threw after headers were sent, resulting in a 200 response despite correct redirect logic.

How does the root loading.tsx file affect HTTP status codes in Next.js 15 apps?

A root loading.tsx wraps every route in a Suspense boundary that streams fallback content with a 200 status as soon as the request starts. For force-dynamic pages, this commits the 200 status early, preventing later redirect or notFound calls from changing the status code, causing all redirects to appear as 200 OK.

What was the effective fix to ensure redirects and 404s returned correct HTTP statuses?

Removing the root-level loading.tsx file restored proper HTTP status behavior, allowing redirects to return 308 and invalid slugs to return 404. The team then reintroduced loading.tsx only in route segments that do not use redirect, permanentRedirect, or notFound to avoid the issue.

What lessons were learned about debugging redirect issues in Next.js?

Key lessons include trusting the actual HTTP response over application logs, since logs showed redirects firing but headers returned 200; understanding that streaming Suspense boundaries can interfere with status codes; and using bisection by porting files into a fresh app to isolate the problematic file quickly.

Why is it important to avoid loading.tsx in segments that call redirect or notFound?

Because loading.tsx streams fallback content immediately with a 200 status, placing it above segments that throw redirect or notFound errors causes the response status to be locked at 200. Avoiding loading.tsx in these segments ensures that the framework can correctly set 3xx or 404 status codes as intended.

Continue reading