How to Mutate Data with a Server Action

A step-by-step pattern for mutating data with a Server Action: read FormData, validate on the server, save, and refresh the page.

7 min read

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.

index.tsindex.ts
// 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.tsxApp.tsx
// 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.

index.tsindex.ts
// 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Where does the actual database write go?

Inside the Server Action, after validation passes. That is where you call your data access layer or ORM, and it runs only on the server.

Do I still need client-side validation?

Client-side validation is a convenience, not a guard. Always validate again inside the Server Action, because the action is reachable over HTTP and bypasses the form.

How do I show the updated data after the mutation?

Call revalidatePath or updateTag at the end of the action, or redirect to the updated page. Next.js then re-renders the current route in the same response.

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.