Plenty of mutations have no form: a checkbox, a "like" button, a drag that reorders a list. A Client Component imports the action from a 'use server' file and awaits it like any async function, provided the call sits inside a transition — React 7,897 supplies one for a form submission, and outside a form you write startTransition(async () => { await toggleNote(id, true); }) yourself. That wrapper keeps pending state and optimistic updates attached to the call, and an action may return a value, so const total = await incrementLike(id) is ordinary code.
One behavior surprises people: the client dispatches actions one at a time, so each re-rendered tree matches the result that produced it, and Promise.all around three actions queues them rather than parallelizing them. Parallel work belongs inside one action, in a Server Component (Parallel Requests), or in a Route Handler (Creating a Route Handler).
Which brings the security model back, with teeth. The page renders checkboxes only for notes the current user owns — which proves nothing about the endpoint. Glen's session, one curl 3,008 , and Ada's note:
curl -X POST http://localhost:3507/notes -H "Cookie: session=tok-glen" \
-H "Next-Action: 60546b21bc981b85d51ca048908fffef887efabbe7" \
-H "Content-Type: text/plain;charset=UTF-8" --data '["n2",true]'1:E{"digest":"3171403705","name":"Error","message":"Forbidden",...,"env":"Server"}
POST /notes 500 in 72ms (next.js: 3ms, application-code: 69ms)
└─ ƒ toggleNote("n2", true) in 7ms actions.jsThe request was authenticated — Glen is a real user — and still refused, because the action re-reads the note and compares its owner to the session:
export async function toggleNote(id, done) {
const user = await currentUser(); // from the session cookie
const note = findNote(id);
if (!user || !note || note.ownerId !== user.id) throw new Error("Forbidden");
setDone(id, done);
}Notice what the client may send: an ID and the change, never the owner. Send a reference, derive identity from the session, read everything else from a trusted source. With the experimental authInterrupts flag, throwing unauthorized() or forbidden() from next/navigation renders the matching segment instead.