A server function can receive a form submission and run mutation code on the server, but its arguments remain untrusted input. The function is an entry point, not proof that the caller may approve any case ID it submits. A correct approval reads authenticated identity on the server, validates fields, checks case permission, and commits through a service with an operation ID. Form submission can occur twice or lose its response after the database commits. A unique token per logical user intent, retained across retry, allows the server to return the prior result rather than applying a second effect. Path or data revalidation follows a successful commit so the queue can show the new authoritative status.
Next.js Server Functions, Validation, and Operation Identity
Working case
Reviewer 81 submits approval for case 62. The database transition succeeds, but the browser connection fails and the page still shows a pending button. A second submit with a fresh token sends another notification. A different reviewer changes the hidden case field to 47 and receives success because the function trusts form data. The repair checks the current session and case-level authority on each call, validates a stable operation token, and makes the approval plus deduplication record atomic. A replay of the same token returns the previous approved result. Only after commit does the function revalidate the affected queue, and the client displays the returned state rather than assuming the click succeeded.
Implementation boundary
"use server";
import { revalidatePath } from 'next/cache';
import { requireReviewer, permitService } from './services';
export async function approvePermit(formData: FormData) {
const caseField = formData.get('caseId');
const tokenField = formData.get('operationId');
if (typeof caseField !== 'string' || !/^[1-9]\d{0,8}$/.test(caseField) ||
typeof tokenField !== 'string' || !/^[a-zA-Z0-9-]{20,80}$/.test(tokenField)) {
return { kind: 'invalid' as const };
}
const reviewer = await requireReviewer();
const result = await permitService.approveOnce({
reviewerId: reviewer.id, caseId: Number(caseField), operationId: tokenField
});
if (result.kind === 'approved') revalidatePath('/permits');
return result;
}Define the function in a server-only module. Parse FormData with exact type and format checks; a FormData value can be a string or File, and an empty string must not become a valid identifier. Resolve reviewer identity inside the call. The repository service verifies permission and performs the transaction under a uniqueness rule for operation ID and reviewer. Bound the token lifetime and require a new token only for a genuinely new intent. Return or surface validation and conflict results without echoing private details. After commit, revalidate the affected path or tag using the API matching the project's Next.js cache model. Keep the form usable before client JavaScript, then add pending and retry UI that preserves the same token after a lost response.
Cost and boundaries
A permission check and deduplication transaction add work to each write. Retaining m operation IDs costs O(m) storage and an index, but it prevents duplicate audit events and notifications. Revalidation can fan out to many readers; a broad path invalidation may cause a burst of database reads. An optimistic button state can improve responsiveness but needs rollback and must never be presented as server confirmation. The form payload is small; the expensive parts are lock contention on a case, downstream notifications, and stale cache repair. Measure logical approvals versus HTTP submissions, conflict frequency, time to confirmed status, and revalidation fanout.
Failure trace
Submit the form with JavaScript disabled. Send an empty case ID, decimal, negative ID, File in place of a string, expired operation ID, and unauthorized case. None may mutate the database. Commit an approval, drop the response, and replay the same token; inspect one status transition and one notification. Submit a new token for the already approved case and verify the domain conflict rule. Race two reviewers with permission to approve the same case and confirm one authoritative outcome. Fail revalidation after commit and verify the UI can still recover by loading the committed record.
Verification
- Every call checks server identity and case-level permission.
- A repeated token maps to one committed operation.
- The affected view refreshes only after a successful commit.
Practice drill
Create an approval form for case 62 and a server function that receives its FormData. Add exact parsing, session lookup, permission check, and a service call with an operation ID. Build a fake transactional service that records IDs, returns prior results on replay, and rejects conflicting second intents. Simulate response loss and a double click. Measure mutation count and notification count separately. Then verify the queue view after revalidation and a hard reload. Record the expected user message for validation, forbidden, conflict, and transport failure, without exposing another reviewer's case.
Decision note
A server function is a mutation boundary; authorize, validate, deduplicate, commit, then refresh the affected view.
Common Mistakes
- Trusting a hidden case field or client-disabled button.
- Generating a new operation ID for a retry of the same intent.
- Assuming revalidation itself is a database transaction.
Related lessons
Next.js App Router Delivery Boundaries; Next.js Server and Client Component Data Boundary; Next.js Cache Scope and Private Read Invalidation; Next.js Streaming, Suspense, and Error Recovery; API Mutation and Failure Contracts; SvelteKit Form Actions, Validation, and Mutation Replay.
Apply and check
Build Project: Next.js permit delivery and cache boundary and review Web Development: Next.js App Router boundaries quiz.
