
Next.js unstable_rethrow
If you've ever wrapped a data fetch in a try/catch block and watched notFound() silently stop working, you've already run into the exact problem unstable_rethrow exists to solve. Next.js implements several of its most useful APIs — notFound(), redirect(), and a handful of request-time data accessors — by throwing a special, framework-recognized error under the hood. That's an implementation detail most of the time, right up until your own error handling gets in the way of it.
unstable_rethrow is a small, unglamorous function, and its own documentation page is barely a few paragraphs long. But the problem it fixes is one of the more confusing bugs you can hit in an App Router codebase, precisely because nothing throws a visible error when it happens — a 404 page just quietly fails to render, and a redirect just quietly doesn't redirect. This article goes deeper into why that happens, exactly which APIs are affected, and how to structure your error handling so you never have to reach for unstable_rethrow in the first place — and when you genuinely do need it.
The Problem: Framework Control Flow Is Just Exceptions
Under the hood, notFound() doesn't return anything useful — it throws. Next.js's rendering pipeline catches that specific throw further up the component tree, recognizes it as "render not-found.js for this segment," and does exactly that. redirect() and permanentRedirect() work the same way: they throw an error carrying redirect instructions, which Next.js intercepts before it ever reaches your terminal or your users as a stack trace.
This is a clean design as long as nothing else in the call stack is also using try/catch to handle its own errors. But JavaScript's exception model doesn't know the difference between "an error your code should handle" and "a signal the framework needs to intercept." A catch block catches everything that passes through it, framework-internal or not.
// app/posts/[id]/page.tsx
import { notFound } from "next/navigation";
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
try {
const post = await fetch(`https://api.example.com/posts/${id}`).then(
(res) => {
if (res.status === 404) notFound();
if (!res.ok) throw new Error(res.statusText);
return res.json();
},
);
return <article>{post.title}</article>;
} catch (err) {
// notFound()'s internal throw lands here too — and gets swallowed.
console.error("Failed to load post:", err);
return <p>Something went wrong.</p>;
}
}
Run this against a missing post and you won't see a 404 page. You'll see "Something went wrong" — because the catch block caught Next.js's internal signal along with your own application errors, logged it, and moved on as if it were a regular failure. The not-found.js boundary never gets a chance to render, because as far as the rendering pipeline is concerned, the throw never escaped your component.
The Fix: Rethrow What Isn't Yours to Catch
unstable_rethrow takes the caught error, checks whether it's one of Next.js's internal control-flow signals, and if so, rethrows it immediately so it can continue propagating up to the framework. If the error isn't one of those signals, it's a no-op — your own error handling continues exactly as before.
import { notFound, unstable_rethrow } from "next/navigation";
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
try {
const post = await fetch(`https://api.example.com/posts/${id}`).then(
(res) => {
if (res.status === 404) notFound();
if (!res.ok) throw new Error(res.statusText);
return res.json();
},
);
return <article>{post.title}</article>;
} catch (err) {
unstable_rethrow(err);
// Only genuine application errors reach this line now.
console.error("Failed to load post:", err);
return <p>Something went wrong.</p>;
}
}
Notice the placement: unstable_rethrow(err) runs as the very first line inside the catch block, before any of your own error-handling logic. That ordering matters — if you log or transform the error first and call unstable_rethrow afterward, you've already done unwanted work on a value that was never meant to reach your code in the first place.
It works the same way with promise-based error handling:
const post = await fetch(`https://api.example.com/posts/${id}`)
.then((res) => {
if (res.status === 404) notFound();
return res.json();
})
.catch((err) => {
unstable_rethrow(err);
console.error(err);
return null;
});
Every API This Actually Matters For
The docs list two categories of APIs whose internal throws unstable_rethrow protects, and it's worth knowing both, because they fail differently.
Always-throwing control-flow APIs — these throw unconditionally as part of their normal operation, any time they're called:
Conditionally-throwing request-time APIs — these only throw under specific circumstances, generally when a statically-rendering route segment tries to access request-time data it isn't allowed to have without opting into dynamic rendering:
cookies()headers()searchParams(in a route segment that hasn't opted into dynamic rendering)fetch(..., { cache: 'no-store' })fetch(..., { next: { revalidate: 0 } })
That second category is easy to overlook, because the throw doesn't happen every time you call cookies() or headers() — it happens specifically when the surrounding segment is trying to render statically and one of these calls forces it to bail into dynamic rendering. Whether Partial Prerendering (PPR) is enabled changes exactly when and how this bail-out throw fires, since PPR already splits a route into a static shell and dynamic holes — which is one more reason the framework needs a clean way to distinguish "this is Next.js doing its job" from "this is my code's error."
A Realistic Case: Wrapping a Data Layer
The scenario where this actually bites people in practice usually isn't a single inline fetch — it's a shared data-access function called from several pages, each with its own error handling:
// lib/posts.ts
import { notFound } from "next/navigation";
export async function getPostOrNotFound(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
if (res.status === 404) notFound();
if (!res.ok) throw new Error(`Failed to fetch post: ${res.statusText}`);
return res.json();
}
// app/posts/[id]/page.tsx
import { getPostOrNotFound } from "@/lib/posts";
import { unstable_rethrow } from "next/navigation";
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
try {
const post = await getPostOrNotFound(id);
return <article>{post.title}</article>;
} catch (err) {
unstable_rethrow(err);
console.error(err);
return <ErrorFallback />;
}
}
This is the exact shape of code the docs flag as a good candidate for unstable_rethrow — a caller that legitimately wraps a shared function's errors for logging or fallback UI, but where that shared function might internally call notFound(). Multiply this by every page that reuses getPostOrNotFound, and you can see why a single missed unstable_rethrow call, deep in one caller's catch block, quietly breaks 404 handling for that one route while every other route works fine — a bug that's genuinely unpleasant to track down precisely because there's no error message pointing at it.
The Alternative: Don't Catch What Isn't Yours
The docs make an understated but important point: you may be able to avoid unstable_rethrow entirely by restructuring so the function that calls notFound() or redirect() isn't wrapped in a try/catch at all — and instead only the parts that can genuinely fail with your errors get wrapped, by the caller, closer to where the distinction matters.
// lib/posts.ts — no try/catch here at all
import { notFound } from "next/navigation";
export async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
if (res.status === 404) notFound(); // allowed to propagate unimpeded
if (!res.ok) throw new Error(`Failed to fetch post: ${res.statusText}`);
return res.json();
}
// app/posts/[id]/page.tsx
import { getPost } from "@/lib/posts";
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Let notFound() propagate to Next.js untouched.
// Genuine network/parsing errors are caught by the nearest error.js boundary automatically.
const post = await getPost(id);
return <article>{post.title}</article>;
}
If you don't wrap the call in try/catch at all, there's nothing to accidentally swallow — the nearest error.js boundary handles genuine failures, and notFound() reaches the framework exactly as intended. This is worth defaulting to. Reach for try/catch (and therefore unstable_rethrow) specifically when you need custom handling — a fallback UI instead of the generic error boundary, retry logic, or logging with extra context — for errors that might be mixed in with framework control-flow throws.
Common Mistakes
Calling it too late in the catch block. unstable_rethrow needs to run before any other logic in the catch block, not after. If you log the error, transform it, or run a finally-style cleanup step first, you've already treated a framework signal as if it were your own error.
// Wrong: logs the notFound() signal as if it were an application error
} catch (err) {
console.error(err); // fires even for notFound()
unstable_rethrow(err);
}
// Right: rethrow first, then handle only what's actually yours
} catch (err) {
unstable_rethrow(err);
console.error(err);
}
Assuming it's needed everywhere. If a function never calls notFound(), redirect(), or the request-time APIs listed above — directly or transitively — wrapping its errors in try/catch doesn't need unstable_rethrow at all. Adding it reflexively to every catch block in a codebase is unnecessary defensive coding; it only matters where framework control-flow throws can plausibly pass through.
Forgetting cleanup ordering. If your catch block (or a finally) needs to clear a timer, close a connection, or run some other cleanup regardless of what kind of error occurred, that cleanup needs to happen either before the call to unstable_rethrow or inside a finally block — because once unstable_rethrow rethrows, nothing after it in that same catch block will run.
} catch (err) {
clearInterval(pollTimer); // cleanup that must always run
unstable_rethrow(err);
console.error(err); // only reached for non-framework errors
} finally {
// alternative: cleanup that's guaranteed to run either way
}
Treating it as stable. The function is explicitly named with an unstable_ prefix and the docs mark it as subject to change and not recommended for production use as-is. Track it if your app relies on it heavily, and prefer the encapsulation approach above where it's a reasonable substitute.
Key Takeaways
| Situation | What to do |
|---|---|
Your code calls notFound()/redirect() and is not wrapped in try/catch | Nothing needed — the throw propagates to Next.js untouched |
Your code wraps a call that might invoke notFound()/redirect() in try/catch | Call unstable_rethrow(err) as the first line of the catch block |
You control the function that calls notFound()/redirect() | Prefer not wrapping it in try/catch at all, and let the caller decide |
| You need cleanup regardless of error type | Run it before unstable_rethrow, or inside a finally block |
| Function never calls any framework control-flow API | unstable_rethrow isn't needed in that catch block |
unstable_rethrow is a narrow tool for a narrow, easy-to-miss failure mode: your own try/catch accidentally intercepting a signal that was never meant for you. The safest default is still to avoid the collision in the first place — keep notFound() and redirect() calls out of functions you also wrap in broad error handling. When that's not practical, unstable_rethrow is the one-line fix that keeps your error handling and Next.js's control flow from stepping on each other.


