Skip to content
C.W.K.
Stream
Lesson 02 of 08 · published

Form Actions

~20 min · form, action, FormData

Level 0Curious
0 XP0/68 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

The action prop on <form>

The simplest form pattern: pass a Server Action to the form's action prop. Submission triggers the action with the form's FormData. The page re-renders with whatever revalidatePath invalidated.

Reading FormData

FormData entries are strings (or files). Use formData.get('name') as string for typed access. For files, type-narrow with formData.get('avatar') as File.

Passing extra arguments with .bind()

You often want to pass an id or context that's not in the form. Use .bind(null, id) — the bound argument arrives before the FormData. The framework safely encrypts the bound value before sending the action reference to the client.

Treat every FormData entry and every bound value as untrusted. Narrow strings and files through a schema, validate upload size and type, and recheck ownership for a bound resource ID. Encryption protects transport details; it does not establish permission. Form field names are part of the server action input contract, so a UI-only rename can silently drop a value. Compare FormData keys before and after submission in tests. A hidden input and .bind() differ in interface intent, not in authority. Bind values the user should not edit, but never rely on their obscurity. The action must remain safe when called directly. Submit text, a file, and a bound ID with JavaScript enabled and disabled. Tamper with the identifier, exceed the file limit, and omit fields. The server should reject each case without writing or leaving temporary upload data behind.

Code

Form + Server Action·tsx
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';

export async function addTodo(formData: FormData) {
  const text = formData.get('text') as string;
  await db.todo.create({ data: { text, done: false } });
  revalidatePath('/todos');
}

// app/todos/page.tsx
import { addTodo } from '@/app/actions';

export default async function TodosPage() {
  const todos = await db.todo.findMany();
  return (
    <>
      <ul>{todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
      <form action={addTodo}>
        <input name="text" required />
        <button>Add</button>
      </form>
    </>
  );
}
Bind extra arguments·tsx
'use server';
export async function updateTodo(id: string, formData: FormData) {
  const text = formData.get('text') as string;
  await db.todo.update({ where: { id }, data: { text } });
  revalidatePath('/todos');
}

function TodoItem({ todo }: { todo: { id: string; text: string } }) {
  const updateWithId = updateTodo.bind(null, todo.id);
  return (
    <form action={updateWithId}>
      <input name="text" defaultValue={todo.text} />
      <button>Save</button>
    </form>
  );
}

External links

Exercise

Build a tiny todo app with one Server Action for create and one for delete. Use .bind to pass the todo id to the delete action.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.