next
semver
>=13.0.0postconditions22functions16last verified2026-06-24coverage 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] - forbidden · forbidden-inside-try-catcherrorWhenforbidden() is called inside a try-catch block. Like notFound() and unauthorized(), forbidden() works by throwing an HTTPAccessFallbackError with digest="NEXT_HTTP_ERROR_FALLBACK;403". The catch block intercepts the throw and the 403 page never renders — the request continues with incomplete/null data, leaking access to the unauthorized resource. Critical security issue: an authorization guard that wraps forbidden() in try-catch becomes a no-op authorization check. The user receives the resource (or a partially-rendered page) instead of a 403 response. Common buggy pattern in admin guards: try { const post = await db.post.findFirst({ where: { id } }); if (post.ownerId !== session.userId) forbidden(); // SWALLOWED! return <PostEditor post={post} />; } catch (error) { console.error(error); // catches the forbidden throw return <PostEditor post={null} />; // serves the editor anyway }Throws
HTTPAccessFallbackError — Error object with digest: "NEXT_HTTP_ERROR_FALLBACK;403" Type: `Error & { digest: string }` where digest = "NEXT_HTTP_ERROR_FALLBACK;403" CONSTANT: HTTP_ERROR_FALLBACK_ERROR_CODE = "NEXT_HTTP_ERROR_FALLBACK" Use `isHTTPAccessFallbackError(error)` to detect and re-throw. Note: If experimental.authInterrupts is disabled, forbidden() throws a DIFFERENT error first ("forbidden() is experimental...") — both must be re-thrown when caught.Required handlingCall forbidden() OUTSIDE the try block. If wrapping is unavoidable (e.g. error boundary around all auth logic), re-throw the fallback error: // CORRECT export default async function AdminPage({ params }) { const session = await getSession(); const post = await db.post.findUnique({ where: { id: params.id } }); if (post.ownerId !== session.userId) { forbidden(); // outside any try-catch } return <PostEditor post={post} />; } // CORRECT if try-catch is required: try { const result = await fetchProtectedData(); if (!canAccess(result)) forbidden(); } catch (error) { if (isHTTPAccessFallbackError(error)) throw error; // re-throw! console.error('Actual error:', error); } // Also enable in next.config: // module.exports = { experimental: { authInterrupts: true } }costcriticalin prodsilent failureusers seesecurity breachvisibilitysilent - unauthorized · unauthorized-inside-try-catcherrorWhenunauthorized() is called inside a try-catch block. Same control-flow throw as forbidden() and notFound() — the catch silently swallows the 401 throw, and the request continues rendering with incomplete data instead of returning a 401 response. Auth guards wrapped in broad try-catch become no-ops. Unauthenticated users receive content meant for authenticated users. Common buggy pattern: try { const session = await getSession(); if (!session) unauthorized(); // SWALLOWED! return <Dashboard userId={session.userId} />; // session is undefined here } catch (error) { console.error(error); return <Dashboard userId={null} />; // serves dashboard anonymously }Throws
HTTPAccessFallbackError — Error object with digest: "NEXT_HTTP_ERROR_FALLBACK;401" Type: `Error & { digest: string }` where digest = "NEXT_HTTP_ERROR_FALLBACK;401" CONSTANT: HTTP_ERROR_FALLBACK_ERROR_CODE = "NEXT_HTTP_ERROR_FALLBACK" Use `isHTTPAccessFallbackError(error)` to detect and re-throw. Same caveat as forbidden(): without experimental.authInterrupts enabled, an experimental-feature-disabled error throws first.Required handlingCall unauthorized() OUTSIDE try-catch, OR re-throw fallback errors when caught: // CORRECT export default async function DashboardPage() { const session = await getSession(); if (!session) { unauthorized(); // outside any try-catch } return <Dashboard userId={session.userId} />; } // CORRECT if try-catch is required: try { const session = await getSessionWithRefresh(); if (!session) unauthorized(); } catch (error) { if (isHTTPAccessFallbackError(error)) throw error; // re-throw! console.error('Session refresh failed:', error); }costcriticalin prodsilent failureusers seeauthentication failurevisibilitysilent - connection · connection-missing-awaiterrorWhenconnection() is called without await. Because connection() returns Promise<void>, the function body continues executing immediately without waiting for the dynamic-boundary signal. During prerendering this defeats the entire purpose of the call — Next.js cannot detect that this code must only run for a real request, so it may prerender the route statically with stale or incorrect data. Forgetting await on connection() is one of the most subtle Next.js bugs because it doesn't crash — it silently produces a route that "works" locally but renders incorrectly when deployed (e.g. random values appear frozen at build time, request-specific data is missing).Throws
No exception thrown — the Promise is silently dropped. The bug surfaces as static prerendering of code that should be dynamic. May produce "PrerenderInterruptedError" in newer Next.js versions if the call site is in a strict prerender scope.Required handlingALWAYS await connection(): // CORRECT import { connection } from 'next/server'; export default async function Page() { await connection(); // dynamic boundary signal const now = Date.now(); // this code only runs for real requests return <div>Current time: {now}</div>; } // WRONG export default async function Page() { connection(); // returns Promise, never awaited — bug! const now = Date.now(); // may run at build time, frozen return <div>Current time: {now}</div>; }costmediumin prodsilent failureusers seelost datavisibilitysilentSources[16] - connection · connection-inside-aftererrorWhenconnection() is called inside an after() callback. The after() phase executes AFTER the request has finished, so the "wait for a real request" semantic is contradictory. Next.js throws E827 explicitly to flag this mistake at runtime.Throws
Error with __NEXT_ERROR_CODE = "E827" and message: "Route ... used `connection()` inside `after()`. The `connection()` function is used to indicate the subsequent code must only run when there is an actual Request, but `after()` executes after the request, so this function is not allowed in this scope."Required handlingDo not call connection() inside after() callbacks. Move connection() before the after() schedule, or restructure the code so the dynamic opt-in happens during the request, not after it. // WRONG after(async () => { await connection(); // throws E827 await sendMetric(); }); // CORRECT await connection(); after(async () => { await sendMetric(); });costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible - draftMode · draft-mode-missing-awaiterrorWhendraftMode() is called without await, and the returned Promise object is accessed directly (e.g. `draftMode().isEnabled`). The result is reading the isEnabled getter on the Promise object itself, which returns undefined — every check for "is draft mode on" silently returns false, and the editor's preview never displays unpublished content. Common buggy upgrade pattern from Next.js 14 → 15: // worked in v14 (synchronous), broken in v15 (returns Promise) const { isEnabled } = draftMode(); // isEnabled is undefined! if (isEnabled) { return fetchDraft(id); } return fetchPublished(id); // ALWAYS hits this branch in v15Throws
No exception thrown — the bug is silent. TypeScript may catch it in strict mode (Promise has no isEnabled property), but JavaScript users and projects without strict types ship the bug to production.Required handlingALWAYS await draftMode(): // CORRECT (Next.js 15+) import { draftMode } from 'next/headers'; export default async function PostPage({ params }) { const { isEnabled } = await draftMode(); const post = isEnabled ? await fetchDraft(params.id) : await fetchPublished(params.id); return <Post post={post} />; } // WRONG (Next.js 14 syntax still in v15 code) const { isEnabled } = draftMode(); // isEnabled === undefinedcostmediumin prodsilent failureusers seelost datavisibilitysilent - after · after-error-swallowedwarningWhenAn async callback passed to after() throws an error and the caller assumes the error will surface in the request handler. It will not. Next.js executes after() callbacks in a post-response phase where the response has already been sent — there is no way to surface a 5xx to the client. Failures in after() callbacks are logged to the server but invisible to monitoring that only watches HTTP error rates. This breaks observability assumptions: a Stripe webhook send inside after() that fails will silently drop the event with no Sentry/Datadog alert tied to the originating request.Throws
No exception propagates to the caller. The after() task's error is caught internally and logged to stderr. The original request returns 200 even when the after() callback fails.Required handlingWrap after() callback bodies in try-catch and forward errors to your error tracker explicitly: // CORRECT import { after } from 'next/server'; import * as Sentry from '@sentry/nextjs'; export async function POST(req: Request) { const data = await req.json(); await db.event.create({ data }); after(async () => { try { await analytics.track('event_created', data); } catch (err) { Sentry.captureException(err, { tags: { source: 'after' } }); } }); return Response.json({ ok: true }); } // WRONG — silent failure after(async () => { await analytics.track('event_created', data); // throws? logged to stderr only, no Sentry, no alert });costmediumin prodsilent failureusers seelost datavisibilitysilentSources[17] - after · after-called-outside-request-scopeerrorWhenafter() is called from code that runs outside a Next.js request scope (e.g. a module-level side effect, a script run during build, or a callback that escapes the request lifecycle). Next.js cannot schedule the post-response work without a workStore, so it throws E468 at the call site.Throws
Error with __NEXT_ERROR_CODE = "E468" and message: "`after` was called outside a request scope."Required handlingOnly call after() inside a Route Handler, Server Action, or Server Component executing during a request. Move module-level side effects to instrumentation.ts or use the proper request-scoped API.costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible - updateTag · update-tag-after-redirecterrorWhenupdateTag() is called after redirect() in a Server Action. redirect() throws RedirectError, so any code after it is unreachable. The cache tag is never updated and clients receive stale data after the redirect completes. Same dead-code pattern as revalidateTag() and revalidatePath() — Next.js control-flow throws make ordering matter.Throws
redirect() throws RedirectError — updateTag() never executes. No new error; the bug is the silent omission.Required handlingCall updateTag() BEFORE redirect(): // CORRECT export async function publishPost(postId: string) { 'use server'; await db.post.update({ where: { id: postId }, data: { published: true } }); updateTag('posts'); // before redirect redirect('/posts'); } // WRONG export async function publishPost(postId: string) { 'use server'; await db.post.update({ ... }); redirect('/posts'); // throws updateTag('posts'); // dead code }costmediumin prodsilent failureusers seelost datavisibilitysilentSources[21] - updateTag · update-tag-outside-server-actionerrorWhenupdateTag() is called from a Route Handler or Server Component instead of a Server Action. updateTag() is restricted to Server Actions because read-your-own-writes semantics require the request to continue executing after the cache write — Route Handlers terminate before that opportunity. Calling it elsewhere throws or no-ops depending on Next.js version.Throws
In Next.js 16.x: Error indicating updateTag must be called from a Server Action. The exact message and code may evolve — caller MUST handle the throw or restructure to use revalidateTag() (which works in any context).Required handlingUse updateTag() only inside `'use server'` functions. If you need to invalidate cache from a Route Handler or webhook, use revalidateTag() instead (no read-your-own-writes guarantee but works anywhere): // CORRECT in Server Action 'use server'; export async function updatePost(id: string, data: PostData) { await db.post.update({ where: { id }, data }); updateTag(`post-${id}`); // read-your-own-writes return db.post.findUnique({ where: { id } }); // sees fresh value } // CORRECT in Route Handler (webhook) export async function POST(req: Request) { const event = await req.json(); await db.event.create({ data: event }); revalidateTag('events'); // use revalidateTag, not updateTag return Response.json({ ok: true }); }costmediumin prodimmediate exceptionusers seelost datavisibilityvisible
Sources
Every postcondition cites at least one of these. Grouped by source type; numbered to match the footnotes above.
Official documentation
- [1]nextjs.org/docs/app/building-your-applicationRoute Handlers
- [2]nextjs.org/docs/app/getting-startedMutating Data
- [3]nextjs.org/docs/app/api-referenceRedirect
- [4]nextjs.org/docs/app/api-referencePermanentRedirect
- [5]nextjs.org/docs/app/api-referenceNot Found
- [6]nextjs.org/docs/app/api-referenceCookies
- [7]nextjs.org/docs/app/api-referenceHeaders
- [8]nextjs.org/docs/app/guidesData Security
- [9]nextjs.org/docs/app/api-referenceUnstable Cache
- [10]nextjs.org/docs/app/api-referenceRevalidatePath
- [11]nextjs.org/docs/app/api-referenceRevalidateTag
- [12]nextjs.org/docs/app/api-referenceForbidden
- [13]nextjs.org/docs/app/api-referenceForbidden
- [14]nextjs.org/docs/app/api-referenceUnauthorized
- [15]nextjs.org/docs/app/api-referenceUnauthorized
- [16]nextjs.org/docs/app/api-referenceConnection
- [17]nextjs.org/docs/app/api-referenceAfter
- [18]nextjs.org/docs/app/api-referenceDraft Mode
- [19]nextjs.org/docs/app/api-referenceRoute Segment Config
- [20]nextjs.org/docs/messages/next-dynamic-api-wrong-contextNext Dynamic Api Wrong Context
- [21]nextjs.org/docs/app/api-referenceUpdateTag
Research notes
Curator notes from SOURCES.md captured when the profile was written so you can verify the reasoning, not just the rules.
Sources: next
Package: next | Version: 16.1.6 | Category: React framework Docs: https://nextjs.org/docs | https://github.com/vercel/next.js Requirement: Handle errors in API routes and Server Actions Created: 2026-02-25 | Status: ✅ COMPLETE
Need a different package?
Request a profile