Skip to content
Chapter 43Lesson 6

Quiz - Server Actions

Quiz progress

0 / 0

A delete button in a Client Component already has the loaded invoice in hand and calls await archiveInvoice(invoice), passing the whole Drizzle row. The build is fine; the call fails at runtime. Why, and what’s the fix?

A Server Action argument is serialized across the wire, and a Drizzle row carries a custom prototype that won’t serialize — so the call throws. Pass the plain invoice.id and let the action re-read what it needs.

The row is too large to fit in the POST body, so the framework rejects it. Trim it to the columns you need and the same row object will serialize.

Passing the full row is fine to send, but the action can’t trust it — the failure is the action re-reading a stale row. Pass the row and a version field so the action can detect staleness.

You’re sorting validation rules for createInvoice into “lives in the Zod schema” versus “lives in the action body, after the parse.” One rule keeps feeling like schema work but isn’t: “this invoice number isn’t already taken.” Where does it go, and what’s the deciding question?

The action body. The deciding question is whether answering the rule needs IO — a database read, an external call, request state. “Already taken” needs a database lookup, so it can’t live in Zod; a schema that needs a live database can’t be unit-tested or run at the edge.

The schema, via an async .refine that runs the database lookup during parse — keeping every check in one place is the whole point of deriving the schema from the table.

The schema, because “uniqueness” is a shape rule like max or email; only rules that compare two submitted fields belong in the action body.

A user fills out a long invoice form and submits; the slug collides with an existing row and Postgres raises a unique violation. If the action throws on that instead of returning a Result, what does the user experience — and what’s the rule?

The throw sails past the form to the nearest error.tsx, so the user lands on a full-page error screen with everything they typed gone — to fix a one-word collision. The rule: return the expected, throw the unexpected. A fixable conflict is expected, so it returns a Result.

The form’s useActionState catches the throw and renders its message inline, so the experience is fine either way — throw and return are interchangeable for failures the form shows.

Next.js automatically converts a thrown unique violation into a 409 the form renders as a banner, so throwing is actually the cleaner path for conflicts.

After writing the same parse-authorize-return preamble for the third action, a teammate proposes a generic safeAction(schema, fn) wrapper to remove the boilerplate. The course says don’t. Why is safeAction rejected while the authedAction auth wrapper is allowed?

safeAction bundles several unrelated concerns and hides the action’s seams behind a custom DSL the framework can’t statically analyze. The auth wrapper clears the bar a carve-out must clear: a single concern, identical at every call site, where a missed check is a security incident, not a style nit.

safeAction is rejected only because it’s slower at runtime; authedAction is allowed because auth checks are cached, so the wrapper overhead disappears.

They’re judged the same — both are wrappers, so both are banned. The auth check in the later chapter is written inline in every action body, not as a wrapper.

To stop a double-clicked “Create invoice” from writing two rows, you add an idempotency key. A colleague suggests deriving the key on the server by hashing the submitted fields, so you don’t need a form change. Why is that wrong?

A content hash treats two legitimately distinct submissions that happen to be identical — two real invoices, same customer, amount, and day — as duplicates and silently drops the second. The key identifies intent, not content, so it must be generated once on the form (a crypto.randomUUID() in a hidden input) and ride the same submission on retry.

A server-side hash is fine for correctness but too slow to compute per request; the form-generated UUID is preferred purely because it avoids the hashing cost.

Hashing the inputs is actually the recommended approach — it guarantees that any two identical payloads collapse to one row, which is exactly what idempotency means.

Quiz complete

Score by topic