<form action={serverFn}>

Submits a form straight to a Server Function as FormData, with or without client JavaScript.

Signature
<form action={serverFunction}>…</form>
  • Added in Next.js v14.0.0

Parameters

ParameterTypeDescription
$action((formData: FormData) => void | Promise<void>) | stringA 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>) | stringSet 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

Example · typescript
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.

Example · typescript
// 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()`.

Example · typescript
// 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.

Example · typescript
// 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

  • Be the first to comment on this lesson.