Next.js, passing an id to a server action

By

Learn two ways to pass an id to a Next.js server action: a hidden input read from FormData, or binding the id to the action with bind().

~~~

To pass an id to a Next.js server action, you have two options: add it as a hidden input field in the form, or bind it to the action with bind(). Let’s see both.

The problem comes up whenever you render a list of items. A server action attached to a form only receives the form’s data, so row-specific data like the item’s id must travel with the form somehow.

Option 1: a hidden input field

Simplest thing is adding the id as a hidden input field in the form.

Here’s a todo list example:

'use client'

import { use } from 'react'
import type { Todo } from '@/app/types.ts'
import { deleteTodo } from '@/app/todos/actions/deleteTodo'

export function TodosList({ promise }: { promise: Promise<Todo[]> }) {
  const rows = use(promise)

  return (
    <div>
      {rows.map((row) => {
        return (
          <div key={row.id}>
            {row.text}

            <form action={deleteTodo}>
              <input type='hidden' name='id' value={row.id} />
              <button type='submit'>x</button>
            </form>
          </div>
        )
      })}
    </div>
  )
}

In the server action we get the id value from the FormData object:

'use server'

import { sql } from '@vercel/postgres'
import { revalidatePath } from 'next/cache'

export async function deleteTodo(formData: FormData) {
  const id = Number(formData.get('id'))

  // mutate data
  await sql`DELETE FROM todos WHERE id = ${id}`

  // revalidate cache
  revalidatePath('/todos')
}

Note the Number() call. FormData values are always strings, even when the input value started as a number. If your code expects a real number, convert it explicitly.

One thing to keep in mind: the hidden input value ends up in the rendered HTML. Anyone can open the devtools, change it, and submit. Treat it like any user input and verify permissions in the action, for example that the todo belongs to the current user.

Option 2: bind the id to the action

An alternative solution is to bind the id to the server action, using bind().

First we modify the server action to accept an id parameter:

'use server'

import { sql } from '@vercel/postgres'
import { revalidatePath } from 'next/cache'

export async function deleteTodo(id: number) {
  // mutate data
  await sql`DELETE FROM todos WHERE id = ${id}`

  // revalidate cache
  revalidatePath('/todos')
}

NOTE: if you also need FormData, append that after that id parameter in the function parameters:

//...

export async function deleteTodo(id: number, formData: FormData) {
  //...
}

Now before using the form action, call bind() to bind the id argument to it:

'use client'

import { use } from 'react'
import type { Todo } from '@/app/types.ts'
import { deleteTodo } from '@/app/todos/actions/deleteTodo'

export function TodosList({ promise }: { promise: Promise<Todo[]> }) {
  const rows = use(promise)

  return (
    <div>
      {rows.map((row) => {
        const deleteTodoWithId = deleteTodo.bind(null, row.id)

        return (
          <div key={row.id}>
            {row.text}

            <form action={deleteTodoWithId}>
              <button type='submit'>x</button>
            </form>
          </div>)
      }
      ))}
    </div>
  )
}

bind(null, row.id) returns a new function with row.id locked in as the first argument. The first parameter is the this value, which we don’t need here, so we pass null.

Which one should you use?

Both work fine. I lean towards bind() because the action keeps a typed signature (id: number) and you skip the FormData parsing. The hidden input is a good fit when the action already reads other fields from the form anyway.

Tagged: Next.js · All topics
~~~

Related posts about next: