How to Fix: redirect() error from action caught in try/catch on client
A Next.js server action that calls redirect() is not failing in the usual sense. It is intentionally throwing a special framework-controlled signal, and when your client code wraps the action call in try/catch, that redirect signal gets intercepted like a normal error. The result looks broken: instead of navigating, your UI catches an exception.
Understanding the Root Cause
In the App Router, redirect() does not return a value. Internally, it throws a special control-flow exception that Next.js uses to stop execution and trigger navigation. This is similar to how notFound() works.
When a server action runs and hits redirect(‘/target’), Next.js expects that special thrown value to bubble up untouched so the framework can convert it into a redirect response.
The problem in this issue appears when the action is invoked from client code inside a try/catch block:
try {
await myAction(formData)
} catch (err) {
console.error(err)
}
Because redirect() is implemented as a thrown signal, the client-side catch sees it first. Once caught, the framework loses the chance to complete the redirect flow normally.
So the root cause is not that redirect() is malfunctioning. The real issue is that control-flow exceptions from Next.js should not be swallowed by generic error handling.
This behavior is especially easy to hit when you:
- call a server action manually from a Client Component,
- wrap the action invocation in broad try/catch logic,
- use redirect() inside the action after success.
Step-by-Step Solution
The safest fix is to avoid catching redirect-triggering actions on the client. Let the action complete so Next.js can process the redirect.
1. Keep redirect logic inside the server action
'use server'
import { redirect } from 'next/navigation'
export async function submitAction(formData: FormData) {
const value = formData.get('name')
// perform validation, database work, etc.
redirect('/success')
}
2. Do not wrap the action call in a generic client try/catch if you expect redirect()
If your client component does this, remove the catch for the redirect path:
'use client'
import { submitAction } from './actions'
export default function MyForm() {
async function onSubmit(formData: FormData) {
await submitAction(formData)
}
return (
<form action={onSubmit}>
<button type="submit">Submit</button>
</form>
)
}
Even better, use the server action directly as the form action when possible:
import { submitAction } from './actions'
export default function MyForm() {
return (
<form action={submitAction}>
<button type="submit">Submit</button>
</form>
)
}
This pattern works well because Next.js can manage the action lifecycle and the redirect behavior more predictably.
3. If you must handle errors, separate expected validation results from redirect behavior
Instead of throwing and catching everything, return structured error state for recoverable problems and reserve redirect() for successful navigation.
'use server'
import { redirect } from 'next/navigation'
export async function submitAction(formData: FormData) {
const email = formData.get('email')?.toString() || ''
if (!email) {
return { error: 'Email is required' }
}
// save data
redirect('/success')
}
'use client'
import { useState } from 'react'
import { submitAction } from './actions'
export default function MyForm() {
const [error, setError] = useState('')
async function onSubmit(formData: FormData) {
const result = await submitAction(formData)
if (result?.error) {
setError(result.error)
}
}
return (
<form action={onSubmit}>
{error ? <p>{error}</p> : null}
<input name="email" />
<button type="submit">Submit</button>
</form>
)
}
With this design:
- validation errors are returned as normal data,
- redirect() remains untouched and can bubble correctly,
- the client only handles actual UI state instead of framework control flow.
4. If you absolutely need a client-side try/catch, rethrow redirect-related framework signals
In advanced cases, broad catches may still exist. The rule is simple: do not swallow redirect exceptions. If caught, they must be rethrown so Next.js can finish navigation.
async function onSubmit(formData: FormData) {
try {
await submitAction(formData)
} catch (err) {
throw err
}
}
That example is functionally pointless, which is exactly why most implementations should just remove the catch entirely for redirecting actions.
5. Prefer redirect on the server, router navigation on the client
If your workflow genuinely requires client-controlled error handling and post-submit branching, another clean option is:
- return success data from the server action,
- then use client navigation with router.push().
'use server'
export async function submitAction(formData: FormData) {
const ok = true
if (!ok) {
return { error: 'Something went wrong' }
}
return { success: true }
}
'use client'
import { useRouter } from 'next/navigation'
import { submitAction } from './actions'
export default function MyForm() {
const router = useRouter()
async function onSubmit(formData: FormData) {
const result = await submitAction(formData)
if (result?.error) {
return
}
router.push('/success')
}
return (
<form action={onSubmit}>
<button type="submit">Submit</button>
</form>
)
}
Use this approach when the client should remain in charge of the post-submit flow.
Common Edge Cases
1. Catching everything in a shared form helper
If multiple forms call a shared helper that wraps every action in try/catch, redirects may fail across the app. Audit reusable submission utilities carefully.
2. Mixing returned error objects and thrown exceptions
If one branch returns { error: '...' } and another branch throws, your UI can become inconsistent. Pick a clear contract: return validation errors, but let redirect() and genuine unexpected failures propagate appropriately.
3. Redirect after partial side effects
If you write to a database and then redirect, make sure the mutation completed successfully before calling redirect(). Since redirect stops execution immediately, any code after it will never run.
await db.insert(data)
redirect('/done')
console.log('never runs')
4. Expecting redirect inside event handlers to behave like router.push()
redirect() is a server-oriented navigation mechanism in this context. If you need full control from a client event flow, router.push() is usually the better fit.
5. Confusing redirect errors with real application failures
In logs, the thrown redirect signal can look alarming. But it often represents successful framework control flow, not a broken action. The real bug is intercepting it incorrectly.
FAQ
Why does redirect() throw instead of returning normally?
Next.js uses a thrown internal signal to abort the current render/action flow immediately and hand control to the router layer. That is why redirect() behaves like an exception even when it represents success.
Should I ever catch errors around a server action that redirects?
Only if you can guarantee you will not swallow the redirect signal. In most cases, the best practice is to avoid client-side try/catch around redirecting actions and return structured data for expected validation problems instead.
What is the best pattern for forms that need both validation messages and navigation?
Return validation issues as plain objects from the server action. On success, either call redirect() on the server with no client catch, or return a success payload and navigate with router.push() on the client.
The practical fix for this GitHub issue is straightforward: do not catch a server action redirect on the client unless you rethrow it. Let Next.js handle its own redirect control flow, and move ordinary form errors into returned state instead of exception-based handling.