<form action={serverFn}>
Submits a form straight to a Server Function as FormData, with or without client JavaScript.
<form action={serverFunction}>…</form>- Added in Next.js v14.0.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$action | ((formData: FormData) => void | Promise<void>) | string | A Server Function, a client Action, or a URL string. When it is a function, React calls it with the form's FormData and manages the submission itself instead of navigating. |
$formActionoptional | ((formData: FormData) => void | Promise<void>) | string | Set on a nested button, input type=submit, or input type=image to override the form's action for that control only. |
Errors
A submission whose request body exceeds serverActions.bodySizeLimit — 1MB by default, measured on the raw multipart body — is rejected by the server.
Changelog
- PHP v14.0.0Server Actions stable and enabled by default; earlier versions required experimental.serverActions.
React extends the HTML <form> element so its action attribute accepts a function. Give it a Server Function and the submission becomes a POST to the current route carrying the form's FormData, which arrives as the function's argument. No route handler, no fetch, no preventDefault.
Because the fallback is a native form post, the flow works before hydration and with JavaScript disabled entirely — which is why this is the right shape for sign-in, checkout, and anything else that must not depend on a bundle loading.
Passing more than the fields
Use .bind(null, id) to prepend an argument; the Server Function then receives (id, formData). This works in Server and Client Components and survives progressive enhancement. The alternative — a hidden input — puts the value in the rendered HTML unencoded.
What trips people up
Object.fromEntries(formData) includes extra keys prefixed with $ACTION_, so read fields by name instead. Every value arrives as a string or a File: numbers need coercing, and an unchecked checkbox sends nothing at all rather than false. And nothing about .bind is a permission check — the bound id round-trips through the browser.
Example
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export default function Page() {
async function createInvoice(formData: FormData) {
'use server'
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
const customerId = String(formData.get('customerId') ?? '')
const amount = Number(formData.get('amount') ?? 0)
await db.invoice.create({
data: { customerId, amount, ownerId: session.user.id },
})
revalidatePath('/invoices')
}
return (
<form action={createInvoice}>
<input type="text" name="customerId" required />
<input type="number" name="amount" required />
<button type="submit">Create invoice</button>
</form>
)
}When to use it
- Create a record from a form on a Server Component page with no client JavaScript.
- Attach the row id to a delete button inside a rendered list.
- Give one form separate Save draft and Publish buttons via formAction.
Frequently asked questions
How do I pass an id to a Server Action from a form?
Bind it with `action.bind(null, id)`, which prepends the id so the action receives `(id, formData)` and keeps working without JavaScript. A hidden input also works, but the value is then visible unencoded in the rendered HTML.
Does a form action work if JavaScript is disabled?
Yes — React falls back to a native form POST to the same route, so the Server Function still runs. That is the main reason to prefer `<form action>` over an onClick handler for sign-in and checkout flows.
Why does Object.fromEntries(formData) contain $ACTION_ keys?
React adds its own fields to the multipart body to identify the action being invoked, and `fromEntries` copies them along with yours. Read the fields you expect by name, or strip keys beginning with `$ACTION_` before validating.
More examples
Binding an extra argument
`.bind(null, userId)` produces a new Server Function reference whose first parameter is already filled in, so the id never needs a hidden input. It is still client-supplied data, which is why the action compares it against the session rather than trusting it.
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
// The bound argument arrives first, FormData second.
export async function updateUser(userId: string, formData: FormData) {
const session = await auth()
// The client supplied userId, so it proves nothing on its own.
if (session?.user?.id !== userId) {
throw new Error('Forbidden')
}
await db.user.update({
where: { id: userId },
data: { name: String(formData.get('name') ?? '') },
})
}
// app/client-component.tsx
'use client'
import { updateUser } from './actions'
export function UserProfile({ userId }: { userId: string }) {
const updateUserWithId = updateUser.bind(null, userId)
return (
<form action={updateUserWithId}>
<input type="text" name="name" />
<button type="submit">Update User Name</button>
</form>
)
}Two actions in one form
`formAction` on a nested button overrides the form's own action for that button only, so both controls submit the same fields to different Server Functions. The form-level `action` stays the default for any submission that does not name its own — including implicit submission, which activates the form's first submit button, `Save draft`. Note that Enter inside a `<textarea>` inserts a newline instead of submitting; if you want a keyboard shortcut there, listen for ⌘/Ctrl+Enter and call `form.requestSubmit()`.
// app/posts/editor.tsx
import { saveDraft, publish } from './actions'
export function Editor({ postId }: { postId: string }) {
const saveThis = saveDraft.bind(null, postId)
const publishThis = publish.bind(null, postId)
return (
<form action={saveThis}>
<textarea name="body" rows={20} required />
<button type="submit">Save draft</button>
<button type="submit" formAction={publishThis}>
Publish
</button>
</form>
)
}Reading files and checkboxes correctly
`formData.get` returns `FormDataEntryValue | null`, so a missing checkbox is `null` rather than `false` and a numeric input is the string `"30"`. Uploads arrive as `File` objects and count against the 1MB body limit, which is the usual reason a working form starts failing once a real image is attached.
// app/actions.ts
'use server'
export async function saveSettings(formData: FormData) {
// Every value is a string or a File — never a number or a boolean.
const retentionDays = Number(formData.get('retentionDays') ?? 0)
// An unchecked checkbox sends nothing at all, so test for presence.
const notify = formData.get('notify') !== null
const avatar = formData.get('avatar')
const avatarName = avatar instanceof File && avatar.size > 0 ? avatar.name : null
// Multiple inputs sharing a name need getAll.
const tags = formData.getAll('tags').map(String)
await db.settings.update({
where: { id: 'singleton' },
data: { retentionDays, notify, avatarName, tags },
})
}
Discussion