A form can submit successfully but still leave the user wondering whether anything was saved. Or the request can reach the server and fail because the Action reads the wrong argument. React Server Actions remove some request plumbing, but they do not remove the need to understand the request, the mutation, or the response.
This guide follows one small operation from input to server and back. The example targets React 19 and the Next.js 15 App Router. It saves a harmless display-name preference in a cookie, so you can inspect the entire flow without setting up a database. The code is for a disposable demo rather than production. Its source blocks were checked with TypeScript parsing, and the Action's validation and cookie-write decisions were exercised with a mock cookie store. The full Next.js request and browser interaction still require an integration test.
What actually happens when a form calls a Server Action?
React's current documentation uses Server Function for an async function marked with 'use server'. A Server Function used as a form action is commonly called a Server Action. Next.js uses that term throughout its forms guide. Both describe server execution initiated through a framework-managed request, not a function secretly running in the browser. See React's Server Functions reference.
For a form using <form action={saveName}>, the sequence is:
- The browser collects successful named controls into a
FormDatapayload. - React and Next.js submit a network request that identifies the Server Function and carries the serializable arguments.
- The server runs the function. It must validate the payload and check permissions before any protected mutation.
- The function returns serializable data or performs a framework-supported navigation.
- React updates the relevant UI with the result. Next.js may also render updated Server Component content as part of the response.
You do not write a separate Route Handler for this interaction, but the endpoint still exists. The 'use server' directive defines a callable server boundary, not an authorization boundary. React explicitly treats Server Function arguments as untrusted.
Build a small form example with Next.js 15 and React 19
Use a separate scratch directory so the portfolio's React 18 dependencies remain unchanged. These installation commands are a reproduction recipe, not commands executed for this article. The exact Next.js 15 patch shown matches this portfolio's declared Next.js version; use maintained security patches when adapting the example.
mkdir server-actions-demo
cd server-actions-demo
npm init -y
npm install next@15.5.28 react@19 react-dom@19
npm install --save-dev typescript@5 @types/node@22 @types/react@19 @types/react-dom@19
npm pkg set scripts.dev="next dev" scripts.build="next build" scripts.start="next start"
mkdir app
The six files below go in app/ (not in the portfolio's blog content folder). The form uses browser-native validation for immediate feedback and independently validates the same limits on the server. Its cookie stores a non-sensitive preference, not an account identity, session, or authorization claim.
1. Validate the actual FormData value
FormData.get() can return a string, a File, or null. Casting it to string in TypeScript does not change its runtime value. Keep the runtime check explicit.
// app/name-validation.ts
export type ValidationResult =
| { ok: true; value: string }
| { ok: false; message: string }
export function validateName(
input: FormDataEntryValue | null
): ValidationResult {
if (typeof input !== 'string') {
return { ok: false, message: 'Enter a display name.' }
}
const value = input.trim()
if (value.length < 2 || value.length > 40) {
return { ok: false, message: 'Use 2 to 40 characters.' }
}
if (/[\u0000-\u001F\u007F]/.test(value)) {
return { ok: false, message: 'Control characters are not allowed.' }
}
return { ok: true, value }
}
This is deliberately modest validation: 2 to 40 JavaScript string code units after trimming, with ASCII control characters rejected. It accepts names from multiple writing systems. A real account profile may need additional business rules, rate limits, and normalization policies.
2. Define a serializable action result
useActionState needs a consistent state shape. Keep the shared type in a normal module rather than exporting it alongside callable functions from a 'use server' file.
// app/name-state.ts
export type NameState = {
status: 'idle' | 'error' | 'saved'
message: string
}
3. Perform the mutation on the server
A file-level 'use server' directive makes its exported async functions callable from Client Components. In Next.js 15, cookies() is asynchronous; cookie writes belong in a Server Function or Route Handler, not in Server Component rendering. See Next.js 15 cookie documentation.
// app/actions.ts
'use server'
import { cookies } from 'next/headers'
import { validateName } from './name-validation'
import type { NameState } from './name-state'
export async function saveName(
_previousState: NameState,
formData: FormData
): Promise<NameState> {
const result = validateName(formData.get('name'))
if (!result.ok) {
return { status: 'error', message: result.message }
}
const store = await cookies()
store.set('demoDisplayName', result.value, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 30,
})
return { status: 'saved', message: 'Preference saved.' }
}
The first argument is the previous state, because the form will use useActionState. Without that hook, a basic form action ordinarily receives formData as its first argument. This change in argument order is easy to miss when refactoring a working form. React documents the distinction in useActionState troubleshooting.
4. Show submission progress and a useful result
// app/name-form.tsx
'use client'
import { useActionState } from 'react'
import { saveName } from './actions'
import type { NameState } from './name-state'
const initialState: NameState = { status: 'idle', message: '' }
export function NameForm({ initialName }: { initialName: string }) {
const [state, formAction, pending] = useActionState(
saveName,
initialState
)
return (
<form action={formAction}>
<label htmlFor="display-name">Display name</label>
<input
id="display-name"
name="name"
type="text"
defaultValue={initialName}
required
minLength={2}
maxLength={40}
aria-describedby="name-hint name-feedback"
aria-invalid={state.status === 'error'}
/>
<p id="name-hint">Use 2 to 40 characters.</p>
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save preference'}
</button>
<p id="name-feedback" role={state.status === 'error' ? 'alert' : 'status'}>
{state.message}
</p>
</form>
)
}
The pending value comes from the form's useActionState submission, and the returned state drives the status message. The native required and length attributes help in the browser, but callers can bypass them, which is why the server repeats validation. On a server validation error, aria-invalid identifies the field and the error message is associated with it using aria-describedby. Expected validation failures return a user-safe message rather than throwing an exception.
5. Read the value in a Server Component
// app/page.tsx
import { cookies } from 'next/headers'
import { NameForm } from './name-form'
export default async function Page() {
const savedName =
(await cookies()).get('demoDisplayName')?.value ?? ''
return (
<main>
<h1>Display-name preference</h1>
<p>Saved value: {savedName || 'Not set'}</p>
<NameForm initialName={savedName} />
</main>
)
}
Reading cookies() makes this page dynamic. Setting a cookie inside a Server Action allows Next.js to return refreshed Server Component UI along with the action response; it does not make the cookie trustworthy or prove that the action ran exactly once. This demo stores only a display preference. For real account settings, derive the user from a trusted server session and persist authorized changes in a database instead.
6. Add the required root layout
Every App Router application needs a root layout. This final file completes the example's source structure.
// app/layout.tsx
import type { ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
From the scratch app's root, run the production build, then start the server:
npm run build
npm run start
Visit http://localhost:3000. If Next.js creates tsconfig.json and next-env.d.ts during the build, keep them in the scratch project. The commands above have not been run in this editorial environment because the npm package registry is unreachable here.
The failure checklist that matters
| Symptom | Likely mistake | Specific check |
|---|---|---|
formData.get is not a function | The action expects FormData as its first parameter after adding useActionState | Check (previousState, formData) argument order |
| Submit button never shows pending | useFormStatus is called in the component that renders its own <form> | Use useActionState as above, or move useFormStatus into a child rendered inside the form |
| A field is always missing | The input has no matching name attribute, or the key differs | Confirm name="name" matches formData.get('name') |
| The mutation succeeds but another page shows old data | A separately cached data source was not invalidated | Choose a targeted revalidatePath or cache-tag strategy for the cached data, not automatically for every form |
| Signed-in users can edit another record | The Server Function checks login but trusts a submitted record ID | Scope the database mutation to objects the actor is authorized to change |
| A double submission creates two rows | The pending button was treated as a server-side uniqueness guarantee | Enforce idempotency or uniqueness where the operation requires it |
React's useFormStatus documentation states that the hook observes a parent form, not a form created inside the same component. If you only need a nested Submit button to know the status of its parent form, it remains a good choice. For a Client Component that also needs an action's returned validation message, useActionState usually gives you both pieces together.
The cookie example does not call revalidatePath because it is not writing a separately cached database result. If you replace the cookie with a cached database query, inspect where the affected result is read and apply Next.js cache revalidation at the appropriate path. Revalidation is a cache decision, not an automatic part of every mutation.
What Server Actions do not guarantee
Progressive enhancement depends on where the form is rendered. A Server Component form with a direct Server Function can submit without client JavaScript. Client Component forms have different pre-hydration behavior; when using useActionState, React offers a permalink option for progressive-enhancement scenarios. Do not infer JavaScript-disabled support merely because every form uses an action prop. Test your intended browser and hydration conditions. See React's form documentation and useActionState.
Server-only does not mean permission-checked. A hidden field, bound ID, or the fact that a button is absent from the UI cannot enforce access. Every protected action needs server-side authentication, input validation, and object-level authorization. The separate article on securing Next.js Server Actions works through a record-ownership failure and its repair.
A successful response is not a transaction guarantee. Disabling the button improves feedback but cannot eliminate concurrent or replayed requests. For payments, invitations, inventory changes, and other non-repeatable operations, design database constraints or an idempotency mechanism explicitly.
Actions are not replacements for every HTTP interface. Webhooks, public integrations, and API clients still need an addressable HTTP contract. The comparison in Server Actions vs. API Routes covers that architecture choice rather than this form's execution lifecycle.
How to verify this example before using it
The pure name-validation logic passed six Node.js tests. A second test reads the six app/ code blocks from this Markdown article and uses TypeScript to parse them; it also calls the compiled saveName function with a mock cookies() dependency. That test passed four checks, including a valid save, several invalid values, and a file upload value that must not write the cookie. These checks do not prove that React, Next.js, real HTTP headers, or browser behavior work together. The repository's QA record documents the commands and limitations.
For a complete acceptance check, start the scratch Next.js app and verify:
- Submit a valid name. The result should say
Preference saved., and the server-renderedSaved valueshould show it after the Action completes. - To exercise server validation, temporarily remove the input's
minLengthconstraint in browser DevTools, submit a one-character name, and confirm the returned error. Ordinary HTML constraint validation otherwise prevents this request from reaching the server. - Temporarily introduce a delay inside the Action in the scratch copy only. Confirm the button shows
Saving...and is disabled while the request is in flight. - Test submission before hydration and with JavaScript disabled. Record the actual navigation and result for this Client Component form rather than assuming identical progressive-enhancement behavior.
For a real authenticated mutation, add a second-user authorization scenario and repeat-submission scenario. These browser checks are proposed, not executed results.
The practical model is simple: an Action is a networked server mutation whose arguments must be checked, whose result must be represented in the UI, and whose cache and permission boundaries still belong to the application.
