Server Components in Next.js give you fine-grained control over rendering and navigation, but sometimes you need to step out of the happy path. notFound() and redirect() are the control flow helpers that let you bail out gracefully—rendering a 404 page or sending users elsewhere without throwing errors. Today we're building the mental model and patterns you'll use to handle these cases in production.
Why notFound() and redirect() Matter
In a traditional server-rendered app, you'd manually check conditions, set HTTP status codes, and construct redirect responses. Next.js abstracts this with two lightweight functions that work only in Server Components, route handlers, and server actions. They're not error boundaries; they're intentional exits from the normal rendering pipeline.
The key insight: these aren't exceptions to catch. They're special control flow that Next.js recognizes and handles at the framework level. This keeps your component code clean and your intent explicit.
notFound() Renders the Nearest not-found.tsx
When you call notFound() from a Server Component, Next.js stops rendering that component and looks up the component tree for the nearest not-found.tsx file. If found, it renders that file and sets the HTTP status to 404. If no not-found.tsx exists, Next.js falls back to its default 404 page.
Real-world scenario: you're building a product details page. A user bookmarks a product URL, but that product gets deleted. You fetch the product in the Server Component, find nothing, and call notFound(). Your carefully crafted not-found.tsx renders instead of a blank page or error message.
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
interface ProductPageProps {
params: { id: string };
}
export default async function ProductPage({ params }: ProductPageProps) {
const product = await fetch(`/api/products/${params.id}`).then(r => r.json());
if (!product) {
notFound();
}
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}
And your not-found.tsx sits in the same segment or a parent segment:
// app/products/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Product Not Found</h2>
<p>The product you're looking for doesn't exist.</p>
<a href="/products">Back to Products</a>
</div>
);
}
The beauty here is that your page component doesn't need error state, loading boundaries, or conditional rendering. The happy path is what you write; notFound() handles the exception transparently.
redirect() Works in Components and Actions
redirect() is more versatile. You can call it from a Server Component during render or from a server action after form submission. It takes a path and performs a client-side redirect on the browser—not an HTTP redirect header. This matters for user experience: the user sees the navigation happen in their browser, and the new URL appears in the address bar immediately.
Common pattern: a post-login redirect. Your authentication form is submitted via a server action. The action validates credentials, writes a session cookie, and then calls redirect('/dashboard'). The browser navigates to the dashboard, and the user sees that page in their context.
// app/login/page.tsx
'use server';
import { redirect } from 'next/navigation';
import { verifyCredentials } from '@/lib/auth';
import { setSessionCookie } from '@/lib/session';
export async function loginAction(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const user = await verifyCredentials(email, password);
if (!user) {
return { error: 'Invalid credentials' };
}
await setSessionCookie(user.id);
redirect('/dashboard');
}
You can also use redirect() in a Server Component. For example, if a user accesses a draft post and lacks permission, redirect them to the homepage:
// app/posts/[slug]/page.tsx
import { redirect } from 'next/navigation';
import { getPost } from '@/lib/posts';
import { getCurrentUser } from '@/lib/auth';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
const user = await getCurrentUser();
if (post.draft && post.authorId !== user?.id) {
redirect('/');
}
return <article>{post.title}</article>;
}
The redirect happens before the component renders any JSX. The browser navigation is smooth, and you avoid rendering content the user shouldn't see.
permanentRedirect() for SEO-Safe Moves
permanentRedirect() is a variant that signals to search engines and clients that the move is permanent (HTTP 308 in server actions, or a client-side permanent redirect tag in Server Components). Use this when you're restructuring your app and old URLs should be forgotten by crawlers.
Example: you're moving from a monolithic route structure to a new one. Old product URLs were /product/123, and now they're /products/123. Use permanentRedirect() to tell search engines to update their indexes:
// app/product/[id]/page.tsx
import { permanentRedirect } from 'next/navigation';
export default async function OldProductPage({ params }: { params: { id: string } }) {
permanentRedirect(`/products/${params.id}`);
}
This ensures your SEO ranking transfers to the new URL. Temporary redirects (redirect()) don't have this guarantee; search engines may keep indexing the old URL.
Do Not Call Them Inside try/catch
This is the gotcha. notFound(), redirect(), and permanentRedirect() throw special errors internally. If you wrap them in a try/catch, they'll be caught and your redirect won't happen. You'll get confusing behavior: maybe an error, maybe nothing.
Bad pattern:
// ❌ Don't do this
export default async function Page() {
try {
const data = await fetchData();
if (!data) {
redirect('/fallback'); // Caught by try/catch, doesn't work
}
} catch (error) {
console.error(error);
}
}
Good pattern:
// ✅ Do this
export default async function Page() {
const data = await fetchData();
if (!data) {
redirect('/fallback'); // Executes properly
}
try {
// Wrapped logic, not the redirect itself
return <Content data={data} />;
} catch (error) {
console.error(error);
}
}
If you need to handle errors before a potential redirect, structure your logic so the redirect call is never inside the try block.
Common Mistakes
- Calling them in Client Components:
notFound()andredirect()only work in Server Components, route handlers, and server actions. If you import them in a Client Component, you'll get a runtime error. Use the'use server'directive if you need them. - Wrapping them in try/catch: As mentioned, this silently breaks the redirect. Keep these calls at the top level of your logic.
- Calling redirect() without a path:
redirect()requires a destination. An empty or undefined path will throw. Validate your redirect target before calling. - Confusing notFound() with throwing an error:
notFound()is not an error. It's a control flow signal. Don't use it for actual exceptions; let those propagate to your error boundary. - Over-using permanentRedirect(): Only use this for true permanent moves. Temporary redirects should use
redirect(). MisusingpermanentRedirect()can confuse search engines.
Key Takeaways
notFound()stops rendering and displays the nearestnot-found.tsx, setting HTTP 404.redirect()performs a client-side navigation and works in Server Components and server actions.permanentRedirect()signals permanent moves to search engines and should be used for structural URL changes.- Never wrap these functions in try/catch blocks; they rely on throwing special errors that Next.js handles at the framework level.
- Use these for intentional control flow, not error handling. Keep your happy path clean by letting
notFound()andredirect()handle the edge cases transparently. - These functions only work in Server Components, route handlers, and server actions—never in Client Components.






Comments
Loading comments…