next
semver
>=13.0.0postconditions13functions10last verified2026-04-17coverage score100%Postconditions — what we check
- GET · route-handler-no-error-handlingerrorWhenAn async operation in the route handler throws an error and there is no try-catch wrapping the operation. This includes database calls, external API calls, authentication checks, and any other async operations inside the handler function body.Throws
The unhandled error propagates as a 500 Internal Server Error response. In development, the error message and stack trace may be included in the response body. In production, a generic error message is returned but the error is logged server-side.Required handlingCaller MUST wrap async operations in try-catch and return appropriate error responses. The standard pattern: export async function GET(request: NextRequest) { try { const data = await fetchDataFromDB(); return Response.json({ data }); } catch (error) { console.error('Route handler error:', error); return Response.json( { error: 'Internal server error' }, { status: 500 } ); } } For Server Actions, errors thrown propagate to the client as action errors. Use try-catch inside Server Actions to return structured error state instead of throwing, which allows client-side error handling with useActionState.costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible - redirect · redirect-inside-try-catcherrorWhenredirect(url) is called inside a try-catch block. Since redirect() internally throws a RedirectError (Error with digest="NEXT_REDIRECT;..."), the catch block intercepts the throw and the redirect never executes. The request continues rendering normally after the catch block, typically causing unexpected behavior (duplicate renders, missing redirects on auth flows). This is one of the most common Next.js App Router bugs — developers call redirect() inside a try block thinking it "might fail", not knowing that redirect() itself IS the throw. Common buggy pattern: try { const user = await getUser(id); if (!user) redirect('/login'); // ← NEVER EXECUTES redirect! } catch (error) { // This catch intercepts the redirect throw console.error(error); // ← logs "Error: NEXT_REDIRECT" instead! }Throws
RedirectError — Error object with digest: "NEXT_REDIRECT;push|replace;<url>;<statusCode>;" where statusCode is 307 (temporary) or 308 (permanent). Type: `Error & { digest: string }` where digest matches /^NEXT_REDIRECT;/ Use `import { isRedirectError } from 'next/dist/client/components/redirect-error'` to check if a caught error is a redirect (and re-throw it if so). CONSTANT: REDIRECT_ERROR_CODE = "NEXT_REDIRECT" (from redirect-error.d.ts)Required handlingCall redirect() OUTSIDE the try block. The confirmed correct pattern from official Next.js docs and source: // ✅ CORRECT — redirect outside try-catch async function fetchTeam(id: string) { const res = await fetch('https://...'); if (!res.ok) return undefined; return res.json(); } export default async function Profile({ params }) { const { id } = await params; const team = await fetchTeam(id); if (!team) { redirect('/login'); // ← outside any try-catch } return <div>{team.name}</div>; } // ✅ If try-catch is required, re-throw redirect errors: try { const result = await doSomething(); redirect('/success'); } catch (error) { if (isRedirectError(error)) throw error; // ← re-throw redirects! console.error('Actual error:', error); } // ❌ WRONG — redirect inside try-catch (redirect silently swallowed) try { const user = await getUser(); if (!user) redirect('/login'); } catch (error) { // This catches the redirect throw! }costhighin prodsilent failureusers seelost datavisibilitysilent - permanentRedirect · permanent-redirect-inside-try-catcherrorWhenpermanentRedirect(url) is called inside a try-catch block. Like redirect(), permanentRedirect() internally throws a RedirectError (digest starts with "NEXT_REDIRECT;"). The catch block intercepts the throw and the 308 redirect never executes. This is especially problematic for URL canonicalization and SEO redirects where missing the redirect causes duplicate content or broken navigation.Throws
RedirectError — Error object with digest: "NEXT_REDIRECT;push|replace;<url>;308;" (status code 308 = permanent redirect) Same error type as redirect() — digest starts with REDIRECT_ERROR_CODE ("NEXT_REDIRECT") Use isRedirectError() to detect and re-throw.Required handlingCall permanentRedirect() OUTSIDE any try-catch block, or re-throw if caught: // ✅ CORRECT if (isOldUrl) { permanentRedirect('/new-canonical-url'); // outside try-catch } // ✅ If inside try-catch, re-throw: try { await processRedirect(); permanentRedirect('/destination'); } catch (error) { if (isRedirectError(error)) throw error; handleActualError(error); }costmediumin prodsilent failureusers seelost datavisibilitysilent - notFound · not-found-inside-try-catcherrorWhennotFound() is called inside a try-catch block. Since notFound() internally throws an Error with digest="NEXT_HTTP_ERROR_FALLBACK;404", the catch block intercepts the throw and the 404 page is never rendered. The request continues rendering normally, typically serving a 200 response with undefined/empty data instead of the proper 404 page. This is a critical security issue for resource authorization: if a user requests a resource that belongs to another user, calling notFound() inside a try-catch silently renders the page with null data rather than returning 404. Common buggy pattern in resource guard: try { const post = await db.post.findFirst({ where: { id, userId } }); if (!post) notFound(); // ← NEVER executes notFound! return <PostPage post={post} />; } catch (error) { // Catches the notFound throw — continues rendering with post=undefined }Throws
HTTPAccessFallbackError — Error object with digest: "NEXT_HTTP_ERROR_FALLBACK;404" Type: `Error & { digest: string }` where digest = "NEXT_HTTP_ERROR_FALLBACK;404" CONSTANT: HTTP_ERROR_FALLBACK_ERROR_CODE = "NEXT_HTTP_ERROR_FALLBACK" Use `import { isHTTPAccessFallbackError } from 'next/dist/client/components/http-access-fallback/http-access-fallback'` to check if a caught error is a not-found error. Note: Same error family handles forbidden() and unauthorized() (different status codes).Required handlingCall notFound() OUTSIDE the try block. The confirmed correct pattern: // ✅ CORRECT export default async function Page({ params }) { const { id } = await params; const user = await fetchUser(id); if (!user) { notFound(); // ← outside any try-catch } return <UserProfile user={user} />; } // ✅ If try-catch is required, re-throw notFound errors: try { const post = await db.post.findUnique({ where: { id } }); if (!post) notFound(); } catch (error) { if (isHTTPAccessFallbackError(error)) throw error; // ← re-throw! console.error('DB error:', error); } // ❌ WRONG — notFound inside try-catch try { const resource = await getResource(id); if (!resource) notFound(); // silently swallowed! } catch (e) { ... }costhighin prodsilent failureusers seelost datavisibilitysilentSources[5] - cookies · cookies-not-awaitederrorWhencookies() is called without await in a Next.js 15+ application. Since cookies() became async in Next.js 15, not awaiting it returns a Promise object rather than the actual cookie store. Operations on the unresolved Promise (like .get('token')) return undefined, causing auth checks to silently fail and all cookie reads to return undefined. This is an extremely common upgrade bug when migrating from Next.js 14 to 15. TypeScript may not catch this because the old synchronous API was compatible with both sync and async access patterns. Common buggy pattern (works in Next.js 14, silently broken in 15+): const token = cookies().get('session-token')?.value; // cookies() returns Promise, .get() is undefined on Promise, token is undefined if (!token) redirect('/login'); // ← redirect fires on every request!Throws
Does not throw — silently returns undefined for all .get()/.has() calls because you're operating on a Promise object, not the resolved ReadonlyRequestCookies. This is a type confusion bug, not an exception. In strict TypeScript mode, the type checker should catch this if the project properly uses the Next.js 15 type definitions (cookies() returns Promise<ReadonlyRequestCookies>).Required handlingALWAYS await cookies() in Next.js 15+: // ✅ CORRECT (Next.js 15+) import { cookies } from 'next/headers' export default async function Page() { const cookieStore = await cookies(); const theme = cookieStore.get('theme'); return '...'; } // ✅ In Server Actions: export async function handleAction() { 'use server' const cookieStore = await cookies(); cookieStore.set('session', token); } // ❌ WRONG (Next.js 14 pattern, broken in 15+) const cookieStore = cookies(); // returns Promise, not store! const token = cookieStore.get('auth-token'); // undefined!costhighin prodsilent failureusers seelost datavisibilitysilentSources[6] - cookies · cookies-set-in-server-componenterrorWhencookieStore.set() or cookieStore.delete() is called in a Server Component (not in a Server Action or Route Handler). Setting cookies is not supported during Server Component rendering — it requires a response phase where Set-Cookie headers can be set, which only occurs in Route Handlers and Server Actions.Throws
Error thrown at runtime: "Cookies can only be modified in a Server Action or Route Handler." Next.js enforces this restriction to prevent incorrect cookie modification during the React render phase.Required handlingMove cookie.set() and cookie.delete() calls into Server Actions or Route Handlers. Reading cookies is fine in Server Components: // ✅ Reading in Server Component — OK const cookieStore = await cookies(); const theme = cookieStore.get('theme'); // ✅ Writing in Server Action — OK export async function updateTheme(theme: string) { 'use server' const cookieStore = await cookies(); cookieStore.set('theme', theme); } // ❌ Writing in Server Component — throws at runtime export default async function Page() { const cookieStore = await cookies(); cookieStore.set('visited', 'true'); // ← throws! }costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisibleSources[6] - headers · headers-not-awaitederrorWhenheaders() is called without await in a Next.js 15+ application. The result is a Promise object rather than the actual ReadonlyHeaders instance. Calling .get() on the unresolved Promise returns undefined for all header reads, including Authorization headers used for API authentication. Critical auth security bug: if the Authorization header is read without await, auth tokens are undefined and downstream auth checks may pass incorrectly or fail in unexpected ways. Common buggy pattern (works in Next.js 14, silently broken in 15+): const authorization = headers().get('authorization'); // headers() returns Promise, .get() is undefined, authorization is undefined if (!authorization) return Response.json({ error: 'Unauthorized' }, { status: 401 });Throws
Does not throw — silently returns null/undefined for all .get()/.has() calls. This is a type confusion bug, not a runtime exception. TypeScript with proper Next.js 15 types should catch this at compile time.Required handlingALWAYS await headers() in Next.js 15+: // ✅ CORRECT (Next.js 15+) import { headers } from 'next/headers' export default async function Page() { const headersList = await headers(); const userAgent = headersList.get('user-agent'); return '...'; } // ✅ Forwarding auth header in Route Handler: export async function GET() { const headersList = await headers(); const authorization = headersList.get('authorization'); const res = await fetch('https://api.example.com/data', { headers: { authorization } }); return Response.json(await res.json()); } // ❌ WRONG (Next.js 14 pattern — broken in 15+) const authorization = headers().get('authorization'); // undefined!costhighin prodsilent failureusers seelost datavisibilitysilentSources[7] - POST · server-action-missing-auth-checkerrorWhenA Server Action (`'use server'` function) performs data mutation without verifying the user's authentication and authorization before the mutation. Server Actions are reachable via direct POST HTTP requests — any user can call them directly with crafted requests, not just through the application UI. Without auth checks, Server Actions are unauthenticated mutation endpoints. Common buggy pattern (no auth check): export async function deletePost(formData: FormData) { 'use server' const id = formData.get('id') as string; await db.post.delete({ where: { id } }); // ← no auth check! revalidatePath('/posts'); }Throws
Does not throw — the mutation succeeds for any caller, including malicious direct POST requests. This is an authorization bypass vulnerability, not an exception. The impact is unauthorized data modification or deletion.Required handlingEVERY Server Action that modifies data MUST verify authentication and authorization before performing the mutation: // ✅ CORRECT — auth check before mutation export async function deletePost(formData: FormData) { 'use server' const session = await auth(); if (!session?.user) { throw new Error('Unauthorized'); } const id = formData.get('id') as string; // Also verify ownership — not just authentication const post = await db.post.findFirst({ where: { id, userId: session.user.id } }); if (!post) notFound(); await db.post.delete({ where: { id } }); revalidatePath('/posts'); } // ❌ WRONG — no auth check export async function deletePost(formData: FormData) { 'use server' const id = formData.get('id') as string; await db.post.delete({ where: { id } }); // anyone can delete! }costhighin prodsilent failureusers seelost datavisibilitysilent - POST · server-action-redirect-in-try-catcherrorWhenA Server Action calls redirect() inside a try-catch block. Since redirect() throws a RedirectError, the catch block intercepts it and the redirect never executes. The action returns normally after the catch, sending no redirect to the client. This is the most common try-catch antipattern in Server Actions — developers wrap the entire action body in try-catch for error handling, then call redirect() or notFound() as control flow inside it.Throws
RedirectError (digest: "NEXT_REDIRECT;...") — thrown by redirect() but caught by the surrounding try-catch. The redirect is silently suppressed.Required handlingCall redirect() and notFound() AFTER the try-catch block, or re-throw them if caught: // ✅ CORRECT — redirect after try-catch export async function createPost(formData: FormData) { 'use server' const session = await auth(); if (!session?.user) throw new Error('Unauthorized'); let postId: string; try { const post = await db.post.create({ data: { title: formData.get('title') as string } }); postId = post.id; } catch (error) { console.error('DB error:', error); return { error: 'Failed to create post' }; } // redirect OUTSIDE the try-catch revalidatePath('/posts'); redirect(`/posts/${postId}`); } // ❌ WRONG — redirect inside try-catch export async function createPost(formData: FormData) { 'use server' try { const post = await db.post.create({ ... }); redirect(`/posts/${post.id}`); // ← swallowed by catch! } catch (error) { console.error(error); // logs "NEXT_REDIRECT" error! } }costhighin prodsilent failureusers seelost datavisibilitysilent - unstable_cache · unstable-cache-context-api-insideerrorWhenheaders() or cookies() is called inside an unstable_cache() callback function. The cache callback runs in a different execution context than the request — it may run at a future time or in a different request's context. Calling request-scoped APIs inside the cache scope will either throw or return stale/incorrect data from a different request. Common buggy pattern: const getCachedData = unstable_cache( async (userId) => { const cookieStore = await cookies(); // ← called inside cache scope! const token = cookieStore.get('token')?.value; return fetchData(userId, token); }, ['user-data'] );Throws
May throw an error about accessing request-scoped APIs outside request context, or silently return cookies/headers from a different request (stale data from cache generation time). Behavior depends on whether the function is being called for cache population or cache retrieval.Required handlingRead headers/cookies OUTSIDE the cache callback, then pass the values as arguments to the cached function: // ✅ CORRECT — read cookies outside cache scope, pass as argument export async function getUserData(userId: string) { const cookieStore = await cookies(); const token = cookieStore.get('auth-token')?.value; const getCachedData = unstable_cache( async (uid: string, authToken: string) => { // Now token is passed in as an argument — no request API needed return fetchData(uid, authToken); }, [userId, 'user-data'], { tags: ['user-data'], revalidate: 60 } ); return getCachedData(userId, token ?? ''); } // ❌ WRONG — cookies() inside cache scope const getCachedData = unstable_cache( async (userId) => { const cookieStore = await cookies(); // ← not allowed inside cache! return fetchData(userId, cookieStore.get('token')?.value); }, ['user-data'] );costhighin prodsilent failureusers seelost datavisibilitysilentSources[9] - revalidatePath · revalidate-after-redirecterrorWhenrevalidatePath() or revalidateTag() is called AFTER redirect() in a Server Action. Since redirect() throws a control-flow exception, any code after redirect() never executes. revalidatePath() called after redirect() will never run, meaning the cache is not invalidated and stale data is served after the redirect. Common buggy pattern: export async function updatePost(formData: FormData) { 'use server' await db.post.update({ ... }); redirect('/posts'); // ← throws here revalidatePath('/posts'); // ← NEVER EXECUTES! }Throws
redirect() throws RedirectError — all code after redirect() is unreachable. revalidatePath() called after redirect() is dead code.Required handlingALWAYS call revalidatePath() or revalidateTag() BEFORE redirect() in Server Actions: // ✅ CORRECT — revalidate before redirect export async function updatePost(formData: FormData) { 'use server' const session = await auth(); if (!session?.user) throw new Error('Unauthorized'); await db.post.update({ where: { id: formData.get('id') as string }, data: { title: formData.get('title') as string } }); revalidatePath('/posts'); // ← revalidate BEFORE redirect redirect('/posts'); // ← throws (that's ok, happens after revalidate) } // ❌ WRONG — revalidate after redirect export async function updatePost(formData: FormData) { 'use server' await db.post.update({ ... }); redirect('/posts'); // throws revalidatePath('/posts'); // dead code — never runs! }costmediumin prodsilent failureusers seelost datavisibilitysilent - revalidateTag · revalidate-tag-after-redirecterrorWhenrevalidateTag() is called after redirect() in a Server Action. Same dead code problem as revalidatePath() — redirect() throws, so revalidateTag() never executes. Tagged cache entries remain stale after the redirect.Throws
redirect() throws RedirectError — code after redirect() is unreachable.Required handlingCall revalidateTag() BEFORE redirect(): // ✅ CORRECT export async function publishPost(postId: string) { 'use server' await db.post.update({ where: { id: postId }, data: { published: true } }); revalidateTag('posts'); // ← before redirect revalidateTag('feed'); // ← multiple tags ok redirect('/posts'); } // ❌ WRONG export async function publishPost(postId: string) { 'use server' await db.post.update({ ... }); redirect('/posts'); // throws revalidateTag('posts'); // dead code! }costmediumin prodsilent failureusers seelost datavisibilitysilent - revalidateTag · revalidate-tag-deprecated-single-argwarningWhenrevalidateTag(tag) is called with only one argument (no profile/second argument). In Next.js 16, the single-argument form is deprecated. The two-argument form revalidateTag(tag, profile) is now required, where profile is typically 'max' for stale-while-revalidate semantics. The old form causes blocking revalidation (cache miss on next request) instead of the preferred stale-while-revalidate behavior.Throws
Does not throw — the deprecated form still works but produces different (less efficient) cache behavior: blocking revalidation instead of stale-while-revalidate. TypeScript compilation may warn about this signature.Required handlingUse the two-argument form with 'max' profile for stale-while-revalidate: // ✅ CORRECT (Next.js 16+) revalidateTag('posts', 'max'); // ✅ For immediate expiration (e.g., webhooks): revalidateTag('posts', { expire: 0 }); // ⚠️ DEPRECATED (blocks next request until cache refreshed) revalidateTag('posts'); // single arg is deprecatedcostlowin proddegraded serviceusers seedegraded performancevisibilityvisibleSources[11]
Sources
Every postcondition cites at least one of these. Numbered to match the footnotes above.
- [1]nextjs.org/docs/apphttps://nextjs.org/docs/app/building-your-application/routing/route-handlers
- [2]nextjs.org/docs/apphttps://nextjs.org/docs/app/getting-started/mutating-data
- [3]nextjs.org/docs/apphttps://nextjs.org/docs/app/api-reference/functions/redirect
- [4]nextjs.org/docs/apphttps://nextjs.org/docs/app/api-reference/functions/permanentRedirect
- [5]nextjs.org/docs/apphttps://nextjs.org/docs/app/api-reference/functions/not-found
- [6]nextjs.org/docs/apphttps://nextjs.org/docs/app/api-reference/functions/cookies
- [7]nextjs.org/docs/apphttps://nextjs.org/docs/app/api-reference/functions/headers
- [8]nextjs.org/docs/apphttps://nextjs.org/docs/app/guides/data-security
- [9]nextjs.org/docs/apphttps://nextjs.org/docs/app/api-reference/functions/unstable_cache
- [10]nextjs.org/docs/apphttps://nextjs.org/docs/app/api-reference/functions/revalidatePath
- [11]nextjs.org/docs/apphttps://nextjs.org/docs/app/api-reference/functions/revalidateTag
Need a different package?
Request a profile