The Problem
I migrated a client search bar from a plain <form> with a server action to the new next/form <Form> component on Friday. The old code submitted, the action ran, redirect('/results?q=' + query) fired, and the browser landed on the results page. Standard pattern.
After the swap the action still ran. The database still got the query logged. But the URL never changed and the page stayed where it was. No console error, no thrown exception in the server logs, just a form that visibly submitted and then sat there.
The reproduction was as small as it gets:
'use client'
import Form from 'next/form'
export function SearchBar() {
return (
<Form action={searchAction}>
<input name="q" />
<button type="submit">Search</button>
</Form>
)
}
'use server'
import { redirect } from 'next/navigation'
export async function searchAction(formData: FormData) {
const q = formData.get('q')?.toString() ?? ''
await logSearch(q)
redirect(`/results?q=${encodeURIComponent(q)}`)
}
Click submit on a plain HTML form with the same action: redirect works. Click submit inside <Form>: action runs, redirect throws the special NEXT_REDIRECT sentinel internally, and the navigation is silently swallowed.
Why It Happens
next/form is not a passthrough wrapper around <form>. It is a client component that intercepts the submit event, serialises the form into URL search params, and calls the App Router client navigation API directly. That gives you the prefetch-on-hover behaviour, soft navigation, and instant transitions that make search and filter forms feel native.
That interception model has a side effect. When the action prop is a server action rather than a string path, <Form> runs the action through the standard useActionState plumbing, gets the result back, and then expects to perform a soft navigation to the URL encoded in the form data. The redirect() call inside your server action throws NEXT_REDIRECT to signal "stop rendering, the response is a 307 to this location". On a plain form that signal becomes an HTTP-level redirect the browser follows. Inside <Form> the signal is caught by the action runtime, the result is returned to the client transition, and the client transition then performs its own pre-planned navigation — which is the original form's URL serialisation, not the URL you redirected to.
The Next.js team treats this as documented behaviour. The Form component reference notes near the bottom that when action is a server function "the form will not navigate to a URL automatically; you must handle navigation in the action's response". The "must handle navigation" line is doing a lot of work. It means redirect() from next/navigation is the wrong primitive in this context, because the client <Form> consumes the response before the redirect can fire.
The other ways this manifests: search forms that show the spinner and reset, filter UIs that update the table but leave the URL stale, and login forms that complete the auth call but never push to /dashboard.
The Fix
Two clean options. The choice depends on whether you want the prefetch behaviour or not.
Option 1 — Keep <Form>, return a redirect-shaped state and navigate on the client. This is the right call when you want the prefetch and soft-navigation benefits of <Form>. Have the server action return a tagged result, then use useActionState and an effect to drive the navigation from the client:
'use client'
import { useActionState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Form from 'next/form'
import { searchAction } from './actions'
export function SearchBar() {
const router = useRouter()
const [state, formAction] = useActionState(searchAction, null)
useEffect(() => {
if (state?.redirectTo) {
router.push(state.redirectTo)
}
}, [state, router])
return (
<Form action={formAction}>
<input name="q" />
<button type="submit">Search</button>
</Form>
)
}
'use server'
export async function searchAction(_prev: unknown, formData: FormData) {
const q = formData.get('q')?.toString() ?? ''
await logSearch(q)
return { redirectTo: `/results?q=${encodeURIComponent(q)}` }
}
The server action no longer throws NEXT_REDIRECT. It returns plain data, the client picks it up, and router.push performs the soft navigation. You keep the App Router transition, you keep the loading UI, and the URL updates as expected.
Option 2 — Use the URL form of action and let <Form> navigate. If the form is genuinely just a search or filter, drop the server action entirely. <Form> was built for this case:
'use client'
import Form from 'next/form'
export function SearchBar() {
return (
<Form action="/results">
<input name="q" />
<button type="submit">Search</button>
</Form>
)
}
The q input becomes ?q=... automatically, the navigation is soft, the destination page reads searchParams. If logging the search is not blocking, fire it from the destination page's server component with the searchParams value, or send it from a useEffect on mount. You lose the per-submission server callback, but you gain a much smaller client bundle and the navigation works without orchestration.
What does not work, no matter how clean it looks. Wrapping redirect() in a try/catch inside the action does not help: NEXT_REDIRECT is rethrown by the framework after your catch finishes. Returning the result of redirect() does not help either; the function's return type is never and its throw still happens after the return statement parses. The permanentRedirect() variant behaves identically. The only client-driven navigation that works inside a <Form>-bound server action is one you trigger explicitly from the client, which is what Option 1 does.
Verify the fix. Submit the form with Network panel open and "Preserve log" enabled. You should see one POST to the action endpoint, a 200 with the serialised state, then a GET to the new URL kicked off by router.push. If you see the POST succeed but no GET, the useEffect is not running — usually because the action returned a value the dependency array does not recognise as changed (return a new object every time, do not reuse a memoised constant).
The Lesson
next/form intercepts submits and runs its own client navigation. A redirect() call inside a server action passed to <Form> gets swallowed, because the client transition expects a data response, not a thrown sentinel. Return the destination URL from the action and call router.push in a useEffect, or skip the server action and let <Form> navigate to a URL the destination page handles. The <form> versus <Form> distinction matters more than the casing suggests.
If your Next.js 16 migration has a backlog of forms that submit but do not navigate, that is a few hours of work I can take off your hands. See my services. For a related server action gotcha after upgrading, read React useActionState pending stuck.
Migrating forms to next/form and hitting walls? Get it shipped.