
Next.js Form Component
If you've spent any time with the App Router, you've probably already reached for Server Actions to handle form submissions — a user fills something out, a function runs on the server, data gets mutated. That's the right tool for mutations. But a huge share of forms on the web aren't mutations at all. They're queries. A search bar. A filter panel. A "jump to page" control. These forms don't change anything — they just want to turn whatever the user typed into a new URL, so the destination page can read it back out of searchParams and render accordingly.
Next.js has a component built specifically for that second case: <Form>, exported from next/form. It's easy to conflate with Server-Action-powered forms because both live under the same "forms" umbrella in the docs, but they solve different problems and behave differently under the hood. This article is entirely about the string-action side of <Form> — GET-style navigations driven by search params — and only touches the Server Action variant at the end, for contrast. If you're looking for how to build a form that creates a database row, that's a separate article; this one is about forms that navigate.
The problem <Form> is solving
Before <Form> existed, a "search that updates the URL" pattern in the App Router usually looked like this: a Client Component wrapping a plain <form>, an onSubmit handler that calls event.preventDefault(), reads the input value out of a ref or controlled state, builds a query string by hand, and calls router.push() from useRouter. It works, but it's boilerplate you write over and over, and it quietly loses two things a native form submission gives you for free: it doesn't work without JavaScript, and it doesn't automatically warm up the destination route before the user gets there.
<Form> starts from the native HTML <form> element — the one that's supported the "type something, hit submit, browser navigates with a query string" pattern since the 1990s — and layers Next.js's routing model on top of it. The result is a component that looks and acts almost exactly like <form method="get">, except three things happen automatically that a native form doesn't give you:
- Prefetching. As soon as the
<Form>scrolls into the viewport, Next.js prefetches the destination path — the same mechanism<Link>uses — which means shared layout andloading.jsUI for that route are already sitting in the client cache before the user even finishes typing. - Client-side navigation. Submitting doesn't trigger a full-page reload. It's a soft navigation through the App Router, so shared layouts don't remount and client state elsewhere on the page survives.
- Progressive enhancement. Because
<Form>renders a real<form>under the hood withactionresolved to an actual URL, it still works if JavaScript hasn't loaded yet or fails outright — the browser just does what browsers have always done with GET forms.
That's the pitch. Now let's get into how it actually behaves, because the details matter more than the summary.
The fork in the road: string vs. function
<Form> has exactly one prop that changes its entire personality: action. And it behaves completely differently depending on whether you pass it a string or a function.
// app/ui/search.tsx
import Form from "next/form";
export default function Search() {
return (
<Form action="/search">
<input name="query" />
<button type="submit">Submit</button>
</Form>
);
}
Pass a string, and <Form> behaves like a native GET form: whatever's typed gets serialized into the URL as search params, and submitting navigates the browser (via client-side routing) to that URL. Submit the form above with "query" set to hello world, and you land on /search?query=hello+world.
Pass a function — specifically a Server Action — and <Form> stops being a navigation primitive entirely. It becomes a thin wrapper around React's own <form action={fn}> support, executing the action on submit rather than navigating anywhere. I'll come back to this mode near the end, but it's worth internalizing up front: this is really two different components wearing the same name, and almost every prop, caveat, and behavior below applies to the string mode only.
Setting up a search form
Here's the canonical example straight from the pattern the docs describe — a search bar that submits to a results page.
// app/page.tsx
import Form from "next/form";
export default function Page() {
return (
<Form action="/search">
<input name="query" />
<button type="submit">Submit</button>
</Form>
);
}
There's no onSubmit, no useState for the input, no manual URLSearchParams construction. The name attribute on the input is doing the same job it would in a plain HTML form — it becomes the search param key. Submit with query=next.js, land on /search?query=next.js.
On the receiving end, page.js for /search reads the value back out through the searchParams prop, which in the App Router is a Promise you await:
// app/search/page.tsx
import { getSearchResults } from "@/lib/search";
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const results = await getSearchResults((await searchParams).query);
return <div>{/* render results */}</div>;
}
That's the entire round trip: type into an input, submit, land on a URL with the value encoded, read it server-side, render. No client state to manage, and the URL itself becomes the source of truth — which means the search is shareable, bookmarkable, and survives a refresh, all for free.
The props that control string-mode behavior
When action is a string, <Form> accepts four props beyond the standard HTML form attributes:
| Prop | Type | Default | What it does |
|---|---|---|---|
action | string | — (required) | The URL or path to navigate to on submit. An empty string "" navigates to the same route with updated search params. |
replace | boolean | false | If true, replaces the current history entry instead of pushing a new one — the back button won't step back through prior searches. |
scroll | boolean | true | Whether to scroll to the top of the new route on navigation (and restore scroll position on back/forward). |
prefetch | boolean | true | Whether to prefetch the destination path once the form is visible in the viewport. |
A couple of these are worth dwelling on because their defaults have real UX implications.
replace defaults to false. This means every search submission pushes a new history entry. If a user refines a search five times, hitting the back button five times will step back through every intermediate query — which is often not what you want for something like a live filter panel. If your form represents "adjusting the current view" rather than "navigating somewhere new," set replace={true} so the back button takes the user to wherever they were before they started filtering, not through a stack of filter states.
The empty-string action is a specific, useful trick. <Form action=""> re-submits to the current route with whatever new search params the form produces. This is the pattern you want for in-page filter controls — a form living on /products that adjusts ?category= and ?sort= without ever navigating away from /products.
Loading UI and the prefetch/navigation gap
Here's where <Form> earns its keep over a hand-rolled router.push() implementation. Because the destination path is known in advance (it's the literal string you passed to action), Next.js can prefetch it — including layout.js and loading.js for that route — the moment the <Form> becomes visible.
// app/search/loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}
If that loading.js exists, submitting the form immediately shows it while the actual search results resolve on the server — no blank screen, no janky full-page flash. This is the same streaming mechanism that powers Suspense boundaries elsewhere in the App Router, just triggered by a form submission instead of a page load.
There's a real gap worth knowing about, though: prefetching warms up the shared UI for the destination route, but it can't warm up content that depends on the search param value itself, because that value doesn't exist until the user actually submits. In other words, loading.js covers the structural shell — but if your results page has its own nested Suspense boundaries around the actual data fetch, you'll want those too, so the shell shows up instantly even before loading.js would have kicked in.
That's exactly the gap useFormStatus closes. It gives you a pending boolean tied to the enclosing form's submission state, which you can use to show feedback the instant the user clicks submit — before the network round trip, before loading.js, before anything else has a chance to render:
// app/ui/search-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export default function SearchButton() {
const status = useFormStatus();
return (
<button type="submit">{status.pending ? "Searching..." : "Search"}</button>
);
}
// app/page.tsx
import Form from "next/form";
import { SearchButton } from "@/ui/search-button";
export default function Page() {
return (
<Form action="/search">
<input name="query" />
<SearchButton />
</Form>
);
}
Note that useFormStatus requires 'use client' — it's a hook, and it has to live in a component that's actually inside the <Form> tree so it can read that specific form's pending state. Layering loading.js for the structural transition and useFormStatus for instant per-submission feedback together gives you a UI that never has a dead moment between "user clicked submit" and "results are visible."
What <Form> deliberately won't let you touch
A recurring theme with <Form>'s API is that several standard HTML form attributes are accepted but ignored, because honoring them would break the client-side navigation contract the component exists to provide:
method,encType,targetare not supported.<Form>always submits as aGET-style navigation in string mode; if you needPOST-with-encoding-type semantics, you want a native<form>or a Server Action, not<Form>.formMethod,formEncType,formTarget(the per-button override attributes) will fall back to native browser behavior rather than being honored by<Form>'s routing logic — which in practice means using them defeats the purpose of using<Form>at all.onSubmitworks, but callingevent.preventDefault()inside it cancels<Form>'s own navigation logic. If you need to run validation before letting the submission proceed, you have to do it without preventing default, or reimplement the navigation yourself once validation passes.keyon a string-action<Form>isn't supported. If you need to force a re-render or trigger a side effect on submission, that's a signal you actually want a function action, not a string one.- File inputs (
<input type="file">) submitted through a string-action<Form>behave like a native GET form always has — only the filename gets serialized into the URL, not the file's contents. If you need to actually upload a file, that has to go through a Server Action.
None of this is a bug list — it's <Form> protecting you from footguns. Every one of these restrictions exists because honoring the attribute would silently break either the prefetching or the client-side-navigation guarantee, and the component would rather refuse the prop than pretend to support it while quietly falling back to a full page reload.
There's one more caveat worth flagging because it's easy to miss: if you use formAction on a <button> to override where a specific submit button navigates to, that override does perform a client-side navigation — but it does not get the prefetching benefit, since Next.js only knows to prefetch the action prop set on the <Form> itself, not a per-button override discovered at submit time. And if your app uses basePath, you have to remember to prepend it manually to any formAction string, since that's a raw HTML attribute Next.js isn't rewriting for you the way it rewrites <Form action>.
The other mode: passing a function
I've spent this whole article on string actions because that's the distinctive, Next.js-specific behavior. But <Form> also accepts a Server Action as its action prop, and it's worth seeing briefly so the boundary between the two modes is completely clear.
// app/posts/create/page.tsx
import Form from "next/form";
import { createPost } from "@/posts/actions";
export default function Page() {
return (
<Form action={createPost}>
<input name="title" />
<button type="submit">Create Post</button>
</Form>
);
}
// app/posts/actions.ts
"use server";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
// create the post...
redirect(`/posts/${data.id}`);
}
In this mode, <Form> is essentially a re-export of React's native <form action={fn}> support — Next.js doesn't add its own routing behavior on top. Crucially, replace and scroll are silently ignored here, because there's no URL to navigate to at prefetch time; the "destination," if there is one, only becomes known once the action has actually run (typically via a redirect() call inside it, as above). That also means Next.js can't prefetch anything for a function-mode <Form> — there's nothing to prefetch until the action executes.
If you're building a form whose entire job is to change data — create a post, submit an order, update a profile — reach for a Server Action, whether you wrap it in <Form> or a plain <form>; the routing perks this component provides genuinely don't apply to that case. <Form> earns its keep specifically on the GET/search-params side of the fence.
A quick decision rule
Given how easy these two modes are to conflate, here's the rule I actually use when deciding which pattern a given form needs:
- Does submitting this form change anything on the server (a database row, a session, a file)? Use a Server Action — with
<Form>or a bare<form>, it doesn't matter much either way. - Does submitting this form just change what you're looking at — a filter, a search query, a page number? Use
<Form action="/path">(oraction=""for same-route filters) and let the prefetching and client-side navigation do the work you'd otherwise hand-roll withuseRouter.
Getting this distinction right up front saves you from two common mistakes: reaching for a Server Action to implement what's really just a search box (and losing free prefetching and shareable URLs in the process), or trying to force a string-action <Form> to perform a mutation (and running into the exact list of ignored props above).
Key takeaways
| If you need to... | Use |
|---|---|
| Search and land on a results URL | <Form action="/search"> |
| Filter the current page without navigating away | <Form action=""> |
| Prevent back-button from stepping through every search | <Form action="..." replace> |
| Show instant "submitting" feedback | useFormStatus inside a Client Component |
| Show a structural loading state for the destination route | loading.js on that route |
| Create, update, or delete data | A Server Action, not a string action |
| Upload actual file contents | A Server Action, not a string action |
<Form> is a small component with a deceptively large amount of behavior packed behind a single prop. Used for what it's built for — GET navigations driven by search params — it eliminates a whole category of boilerplate you'd otherwise write by hand with useRouter, and it gives you prefetching and progressive enhancement you'd have to build yourself. Used for anything else, its restrictions will fight you. Know which mode you're in, and it stays out of your way.


