skip to content

A Next.js Server Action deletePost needs the id of the post to delete, but that id is not one of the form's inputs. When the action is wired to <form action={...}>, what are your options for getting the id to it, and what is the tradeoff of each?

level: juniorimportance: should knowfreq 50%

answer

  1. FormData is the only default argument
  2. pre-apply the value to the function
  3. bound value comes before formData
  4. hidden input is visible and editable
  5. server still authorizes the id

basics

~20 s

Bind the id to the action with deletePost.bind(null, id) so it arrives as the first argument and the FormData second, or render it as a hidden input and read it from FormData. Either way, re-check the id on the server.

solid answer

~40 s

Two idiomatic options. First, bind it: `deletePost.bind(null, post.id)` returns a new action whose signature is `(id, formData)` — the bound value travels with the request rather than being rendered as an editable field. Second, put it in the markup as `<input type="hidden" name="id" value={post.id} />` and read `formData.get('id')` inside the action; simple, but the value is visible in the page source and a user can change it before submitting. A third option exists when the form is rendered by a Server Component: an inline action can simply close over the id, and Next encrypts closed-over values before they reach the browser, so they are not sitting in plain sight in the HTML. Whichever you pick, the id arrived from a client, so the action must still authorize the delete rather than trust it.

code

tsx · 11 lines
tsx
import { deletePost } from './actions'

export function DeleteButton({ id }: { id: string }) {
  const deleteThisPost = deletePost.bind(null, id)

  return (
    <form action={deleteThisPost}>
      <button type="submit">Delete</button>
    </form>
  )
}

go deeper

for a junior

Know both mechanics: deletePost.bind(null, id) makes the id the first parameter with FormData last, and a hidden input arrives through formData.get('id') as a string.

for a middle

Explain the tradeoff rather than just the syntax — a hidden input is readable and editable in the page, a bound or closed-over value is not rendered as a field, and FormData yields only strings and Files.

for a senior

Make the point that none of these options authenticates anything: the value round-trips through a client, so the action itself must establish that this user may act on that record before it touches the database.

for a principal

Frame it as an interface question — which arguments belong in the submitted document versus baked into the action reference, and how you keep that convention consistent so reviewers can spot an unvalidated identifier at a glance.

## Why this comes up at all When you write `<form action={deletePost}>`, the framework calls your function with exactly one thing: a `FormData` object built from the form's named controls. That is a narrow channel. Real mutations usually need context the user never typed — a record id, a workspace slug, a revision number — and none of that is a form field. So every team hits the same question in the first week: how do I get an extra argument in? ## Option 1 — bind the argument `Function.prototype.bind` produces a new function with leading arguments pre-applied, and that works on a Server Action: ```tsx import { deletePost } from './actions' export function DeleteButton({ id }: { id: string }) { const deleteThisPost = deletePost.bind(null, id) return ( <form action={deleteThisPost}> <button type="submit">Delete</button> </form> ) } ``` The action's signature becomes `(id: string, formData: FormData)` — the bound value first, the form data last: ```ts 'use server' export async function deletePost(id: string, formData: FormData) { // ... } ``` The first argument to `bind` is the `this` value, which is irrelevant for a Server Action, hence the conventional `null`. The bound value is not rendered as a form control, so it is not something the user edits in a dev-tools panel by tabbing to a field. Bound values must be serializable, exactly like any other argument crossing the boundary. ## Option 2 — a hidden input ```tsx <form action={deletePost}> <input type="hidden" name="id" value={post.id} /> <button type="submit">Delete</button> </form> ``` and inside the action, `const id = String(formData.get('id'))`. This is the most obvious approach and it is perfectly workable, but it has two honest downsides. The value is part of the rendered HTML, plainly readable in view-source. And a hidden input is still an input: changing its value before submitting takes seconds in the browser's element inspector. Note also the type story. Everything that comes out of `FormData` is a string or a `File`. `formData.get('quantity')` is `"3"`, never `3`, and a missing key returns `null` rather than `undefined`. Bound arguments keep the type you bound them with, which is one small ergonomic advantage. ## Option 3 — close over the value When the form is rendered by a Server Component, an inline action can simply reference the value in scope: ```tsx export default async function Page({ params }) { const { id } = await params async function deletePost() { 'use server' await db.post.delete({ where: { id } }) } return <form action={deletePost}><button>Delete</button></form> } ``` This reads the best of the three. Closed-over values still have to reach the browser so the right call can be reconstructed, and Next encrypts them on the way out, so unlike a hidden input they are not readable in the page source. ## The rule that outranks all three None of this makes the value trustworthy. Bound, hidden or closed over, the id makes a round trip through a browser you do not control, so the action is the place where ownership and permission are established — every one of these options is a convenience for *passing* data, never a proof of *who* sent it. ## Choosing Prefer a closure when the form is rendered on the server and the action is local to it — it is the least ceremony. Prefer `bind` when the action lives in a shared module and the form is rendered by a client component. Reach for a hidden input when the value genuinely is part of the submitted document (a selected variant, a chosen tab) and you would be validating it anyway. Avoid stuffing structured objects into hidden inputs as JSON strings; that is a sign the argument wants to be bound instead.

  • With deletePost.bind(null, id), what is the exact signature the action must declare?
    `(id, formData)` — bound arguments come first, in bind order, and the `FormData` is always appended last by the framework. Forgetting this is a common bug: the action declares `(formData)` and then reads `formData.get(...)` off what is actually the id string. Bind more than one value and they stack in order, with `FormData` still last.
  • Why is `null` the first argument to bind here?
    That parameter sets `this` for the bound function. A Server Action is a plain module-level async function that never uses `this`, so there is nothing meaningful to supply and `null` is the conventional placeholder. It is not Next-specific at all — it is just how `Function.prototype.bind` is shaped.
  • You need to submit a list of selected ids rather than a single one. What is the clean way?
    Either bind the array — arrays of primitives serialize fine — or give each checkbox the same `name` and use `formData.getAll('ids')`, which returns every entry for that key. What you should avoid is JSON-stringifying an object into one hidden input: it hides the shape from the type system and still needs parsing and validating on the server.

saying these in an interview costs you the question

  • Thinks a hidden input value cannot be edited by the user
  • Declares the action as (formData) after binding an argument
  • Expects FormData values to come back as numbers or booleans
  • Treats a bound id as proof the user owns that record
  • Stuffs a JSON blob into a hidden input to pass an object

context