To mutate data with a Server Action, mark an async function with the use server directive, read the submitted FormData, validate it, write through your data access layer, then revalidate or redirect. The browser calls the action over POST while the function itself runs only on the server. The write is your job: the action gives you a server context where databases and secrets are available, and nothing runs in the browser.
Here is the smallest working example. The action stores a name in memory so the example runs anywhere; in a real app the same spot calls your database. The save step is where the mutation actually happens.
// app/actions.ts
'use server'
let savedName = 'Ada'
export async function updateName(formData: FormData) {
const name = formData.get('name')?.toString().trim()
if (!name) return { error: 'Name is required.' }
savedName = name
return { name }
}The action reads the name field, rejects an empty value, then writes savedName and returns the result. If the name is empty, the action returns an error object instead and no write happens.
That early return is the server-side guard. The return value travels back to the client, which is how you get confirmation of the mutation.
Now attach it to a form. This page is a Server Component, so the form works with progressive enhancement before JavaScript loads. The form sends the fields as FormData, and the action runs the same way whether the visitor has JavaScript or not.
// app/profile/page.tsx
import { updateName } from '../actions'
export default function ProfilePage() {
return (
<form action={updateName}>
<label>Name <input name="name" required /></label>
<button type="submit">Save</button>
</form>
)
}When the form is submitted, the browser sends the fields as FormData and calls updateName on the server. The action runs, saves the name, and returns it, but the page does not automatically re-render with new data until you revalidate.
Validate on the server, not just in the form
The required attribute on the input is a convenience for the visitor. It does not protect the action, because the action is reachable over HTTP and can be called without the form. The form may be skipped entirely by a scripted POST, so the required attribute only saves the visitor a roundtrip.
Always validate inside the action itself. Check length, allowed characters, and ownership there, and return a message the form can display.
The example returns an error object when the name is empty, but a real form should also check length, format, and permissions. See server-side form validation with Zod for a schema-based approach that returns field errors.
Check the caller before you write
Validation proves the input is well formed. It does not prove the person sending it is allowed to change this record, and a scripted POST reaches the action with no form and no page behind it.
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
let savedName = 'Ada'
export async function updateName(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const name = formData.get('name')?.toString().trim()
if (name) savedName = name
}This is the same action as before with a session check in front of it. The lookup happens before the form data is read or written, so an unauthenticated request fails immediately. Rendering the form only for signed-in visitors does not do this job, because that check lives in the UI and the action is reachable without it.
Authorization is the second half: confirm this user owns the record being changed, not just that someone is logged in. See securing Server Actions for the full pattern.
Write through a data access layer
The line that assigns savedName is where your real write goes. In a production app, replace it with a call to your database client, an ORM, or a dedicated data access layer that holds the connection logic. Keeping the write in the action is fine for small apps; moving it into a shared layer is better as the app grows.
The important property is that this code runs only on the server. Secrets, connection strings, and database calls stay out of the browser bundle automatically.
Because the function is async, you can await the write and return only the fields the client needs, not the whole record. A small, deliberate return type also keeps sensitive fields from leaking to the client.
Refresh the page after the write
After a successful mutation, call revalidatePath for the affected page, or updateTag when several pages read the same tagged data. Both invalidate the cache and re-render the current route in the same response.
For a redirect after saving, call redirect after the revalidation, since redirect stops execution and any code after it does not run. Keep the visitor on the form for inline edits, or send them to a list page for a create flow.
Revalidation is what makes the new name show up on the next load instead of a stale copy. See revalidating data after a Server Action for the exact calls. To show a pending state and the returned result while the action runs, reach for the useActionState hook.
Rune AI
Key Insights
- Mark the action with use server so it runs only on the server.
- Read submitted fields from the FormData argument.
- Validate the input inside the action, not just in the form.
- Write to your data store after validation passes.
- Revalidate or redirect after the write so the UI shows fresh data.
Frequently Asked Questions
Where does the actual database write go?
Do I still need client-side validation?
How do I show the updated data after the mutation?
Conclusion
Mutating data with a Server Action means reading FormData, validating on the server, writing through your data access layer, and revalidating or redirecting afterward. The browser calls the action over POST while the function itself runs only on the server.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.