Skip to main content
Next.js learning series · Blog 7

Next.js 16 Server Actions Explained: Updating Data Step by Step

Create, update, and delete data with forms, secure Server Functions, validation, pending feedback, cache updates, and optimistic UI.

A form sending data through a protected server function to database records and a refreshed browser interface

In the previous tutorial, you learned how to read data in Next.js 16. Real applications also create posts, edit profiles, change settings, and delete records. Those operations are mutations: they change application state.

This guide follows the current Next.js 16 App Router model. You will build form actions, read FormData, validate input, check authorization, report pending and error states, update cached data, redirect safely, and understand when optimistic UI is worth using.

Updating Data at a Glance

The browser submits named form controls. A Server Function receives their FormData, validates untrusted values, verifies the current user, performs a parameterized database operation, and then refreshes or redirects the interface.

Diagram 1: From reading data to an updated interfaceAfter data is read, a form submission reaches a Server Action, which validates input, updates the database, revalidates affected data, and returns updated UI.
  1. Read dataBlog 6
  2. Submit form
  3. Server Action
  4. Validate
  5. Update database
  6. Revalidate
  7. Updated UI

What Are Server Actions?

The current documentation uses Server Function for an async function that runs on the server and can be called from the client through a network request. In an action or mutation context, it is also called a Server Action. “Next.js Server Actions” remains the common search term, but Server Function is the broader React concept.

When a Server Action is passed to a form's action prop or a button's formAction prop, React automatically runs it in a Transition. Next.js invokes actions with POST and can return updated UI and data in one server roundtrip. This is ideal for application-owned mutations, but it is not a universal replacement for HTTP APIs.

Diagram 2: Read data compared with writing dataA read renders API or database results through a Server Component. A write validates and authorizes form data in a Server Action before mutation, revalidation, or redirect.

Understanding "use server"

The directive has two current patterns. An inline directive is convenient when an action belongs to one Server Component:

app/posts/page.tsx
export default function PostsPage() {
  async function createPost(formData: FormData) {
    'use server'
    // Validate, authorize, mutate, revalidate
  }

  return <form action={createPost}>...</form>
}

A module-level directive marks every exported async function in that file as a Server Function. Use this when Client Components must import actions or several routes share them:

app/actions.ts
'use server'

export async function createPost(formData: FormData) {
  // Server-only mutation
}

export async function deletePost(formData: FormData) {
  // Server-only mutation
}
A boundary, not automatic security

"use server" keeps execution on the server. It does not prove the caller's identity, validate values, or grant permission to change a record. Treat every exported action as a reachable mutation endpoint.

Your First Server Action

app/posts/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'

export async function createPost(formData: FormData) {
  const rawTitle = formData.get('title')

  if (typeof rawTitle !== 'string' || rawTitle.trim().length < 3) {
    return { ok: false, message: 'Title must be at least 3 characters.' }
  }

  await db.posts.create({ title: rawTitle.trim() })
  revalidatePath('/posts')
  return { ok: true, message: 'Post created.' }
}

The repository is a PHP content site and contains no Next.js database package, ORM, validation library, or auth implementation. For that reason, examples use a deliberately generic db interface rather than pretending Prisma, Drizzle, or another package is installed. Adapt the method to your own parameterized data layer.

Using Server Actions with Forms

app/posts/new/page.tsx
import { createPost } from '@/app/posts/actions'

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <label htmlFor="title">Title</label>
      <input id="title" name="title" required minLength={3} />

      <label htmlFor="content">Content</label>
      <textarea id="content" name="content" required />

      <button type="submit">Create post</button>
    </form>
  )
}

The name attributes determine the keys in FormData. Server Component forms also support progressive enhancement: they can submit before JavaScript loads. Keep native labels, required fields, and browser validation, but repeat validation on the server because client constraints can be bypassed.

Reading and Validating FormData

FormData.get() can return a string, a File, or null. Narrow each value before string operations. Avoid blindly using Object.fromEntries(formData); React may add internal fields, multiple values can be collapsed, and uploaded files need separate rules.

Explicit validation without an added dependency
type PostInput = { title: string; content: string }

function parsePost(formData: FormData):
  | { ok: true; data: PostInput }
  | { ok: false; errors: Record<string, string> } {
  const title = formData.get('title')
  const content = formData.get('content')
  const errors: Record<string, string> = {}

  if (typeof title !== 'string' || title.trim().length < 3) {
    errors.title = 'Use at least 3 characters.'
  }
  if (typeof content !== 'string' || content.trim().length < 20) {
    errors.content = 'Use at least 20 characters.'
  }

  if (Object.keys(errors).length) return { ok: false, errors }
  return { ok: true, data: { title: title.trim(), content: content.trim() } }
}
Diagram 3: Input validation flowUntrusted FormData is narrowed and validated. Invalid fields return safe field errors, while valid normalized input continues to authorization and persistence.

Creating, Updating, and Deleting Data

Create and update actions share the same sequence: validate values, authenticate, authorize the operation, mutate through a safe data API, and refresh affected views. Updates and deletes also need a record identifier, but never trust that identifier as proof of ownership.

Protected update and delete patterns
'use server'

import { revalidatePath } from 'next/cache'
import { verifySession } from '@/lib/auth'
import { db } from '@/lib/db'

export async function updatePost(id: string, formData: FormData) {
  const session = await verifySession()
  if (!session) return { ok: false, message: 'Sign in required.' }

  const parsed = parsePost(formData)
  if (!parsed.ok) return { ok: false, errors: parsed.errors }

  const post = await db.posts.findById(id)
  if (!post || post.authorId !== session.user.id) {
    return { ok: false, message: 'You cannot edit this post.' }
  }

  await db.posts.update(id, parsed.data)
  revalidatePath(`/posts/${id}`)
  return { ok: true, message: 'Post updated.' }
}

export async function deletePost(id: string) {
  const session = await verifySession()
  if (!session) return { ok: false, message: 'Sign in required.' }

  const post = await db.posts.findById(id)
  if (!post || post.authorId !== session.user.id) {
    return { ok: false, message: 'You cannot delete this post.' }
  }

  await db.posts.delete(id)
  revalidatePath('/posts')
  return { ok: true }
}

Use parameterized ORM or driver methods. Do not concatenate a client-supplied ID or title into SQL. For destructive controls, clearly label the action, require deliberate confirmation where the impact is meaningful, disable duplicate submissions, and consider audit logging or a recoverable soft delete.

Five-stage mutation pipeline showing a form, validation shield, authorization lock, database update, and successful interface
A protected write has multiple gates. Server execution is only the environment; validation, authentication, authorization, and safe persistence are still application responsibilities.

Authentication and Authorization

Authentication answers “who is making this request?” Authorization answers “may that user perform this specific operation?” A hidden Edit button improves the interface but is not enforcement. Repeat both checks inside the action, close to the database access.

Diagram 4: Authentication and authorization before mutationThe server verifies the current session, loads the target record from trusted storage, checks role or ownership, and permits or rejects the mutation.
  1. Verify session
  2. Load target record
  3. Check role or ownership
  4. Allow mutation
  5. Reject safely

Do not infer ownership from a hidden userId field. Read the user ID from the verified server session, then compare it with the stored record. Return a safe message for expected denial; avoid exposing queries, stack traces, tokens, or internal table names.

Pending State with useFormStatus

A small Client Component can expose the parent form's submission status without turning the whole route into client code. This keeps data access and mutation logic server-side, matching the boundaries explained in Blog #5.

app/ui/submit-button.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending} aria-disabled={pending}>
      {pending ? 'Saving...' : 'Save post'}
    </button>
  )
}

useFormStatus must run in a component rendered inside the relevant form. Disable repeat submission, keep the pending label understandable, and preserve a touch target of at least roughly 44 pixels.

Expected Errors with useActionState

Return validation and business errors as serializable state. Throw unexpected failures so the nearest error boundary can handle them. With useActionState, the action receives previous state first and the form payload second.

For route boundaries, 404 states, safe retries, digests, and production logging, continue with the dedicated Next.js 16 error handling guide.

Action and client form
// actions.ts
'use server'

type State = { message: string; errors?: Record<string, string> }

export async function createPost(
  previousState: State,
  formData: FormData
): Promise<State> {
  const parsed = parsePost(formData)
  if (!parsed.ok) return { message: 'Check the form.', errors: parsed.errors }

  // Authenticate, authorize, and write through the data layer.
  return { message: 'Post created.' }
}

// post-form.tsx
'use client'

import { useActionState } from 'react'
import { createPost } from './actions'

const initialState = { message: '' }

export function PostForm() {
  const [state, formAction, pending] = useActionState(createPost, initialState)

  return (
    <form action={formAction}>
      <input name="title" aria-describedby="title-error" />
      <p id="title-error" role="alert">{state.errors?.title}</p>
      <button disabled={pending}>{pending ? 'Creating...' : 'Create'}</button>
      <p aria-live="polite">{state.message}</p>
    </form>
  )
}

The state returned from a Server Function must be serializable. Return plain strings, numbers, booleans, arrays, and plain objects rather than database clients, class instances, functions, secrets, or raw error objects.

Revalidating Updated Data

A successful database write does not always update every cached view automatically. Pick the smallest invalidation that matches how the data was cached:

  • revalidatePath('/posts') invalidates a specific route path.
  • updateTag('posts') is a Next.js 16 Server Action API for immediate read-your-writes behavior.
  • revalidateTag('posts', 'max') marks tagged data stale and uses stale-while-revalidate semantics, useful when a slight delay is acceptable.
  • refresh() refreshes the client router from a Server Action but does not invalidate tagged data.
Current Next.js 16 cache APIs
'use server'

import { revalidatePath, revalidateTag, updateTag } from 'next/cache'

export async function publishPost(formData: FormData) {
  // Validate, authorize, and persist first.

  revalidatePath('/posts')
  updateTag('my-posts')              // Immediate read-your-writes
  revalidateTag('public-posts', 'max') // Stale-while-revalidate
}
Diagram 5: Mutation and cache revalidationA successful database mutation can invalidate a route path, immediately expire an action-owned tag, or mark public tagged content stale before the next render.
Database write succeeds
PathrevalidatePath
Immediate tagupdateTag
Stale tagrevalidateTag(tag, 'max')
Fresh UI strategy

For a deeper explanation of cache lifetimes, tags, path invalidation, and stale data, read our Next.js 16 caching and revalidation guide.

Redirecting After a Mutation

Call redirect() after the write and required revalidation. It throws a framework-handled control-flow exception, so keep it outside a try block that would catch it. In a Server Action it produces a 303 response.

Revalidate before redirect
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  let postId: string

  try {
    postId = await saveValidatedPost(formData)
  } catch {
    return { message: 'The post could not be created.' }
  }

  revalidatePath('/posts')
  redirect(`/posts/${encodeURIComponent(postId)}`)
}

Prefer a known internal route built from a validated identifier. Do not pass an arbitrary user-supplied URL straight into redirect. For more navigation context, see Next.js linking, navigation, and redirects.

Passing Additional Arguments

Use bind when the action needs a trusted contextual value plus FormData. The bound value becomes an argument before the form payload:

Bind a record ID
const updatePostWithId = updatePost.bind(null, post.id)

return <form action={updatePostWithId}>...</form>

A hidden input is visible in HTML and is equally untrusted. Bound arguments are not an authorization mechanism either: always look up the target and verify access server-side.

Calling Server Actions from Client Components

You cannot define a Server Function inside a Client Component. Import one from a dedicated module with a top-level "use server" directive, or receive it through a prop whose name ends in Action. Forms are usually the simplest path. Event handlers are supported too; when invoking an action outside an Action prop, use a Transition so React can coordinate pending and optimistic state.

Optimistic Updates with useOptimistic

Normal UI waits for server confirmation before showing the final state. Optimistic UI shows the likely result immediately while the action runs, then reconciles with the authoritative response. It works best for frequent, reversible interactions such as likes or adding a lightweight list item. Avoid it for destructive or high-stakes writes unless rollback behavior is exceptionally clear.

Two lanes comparing a normal update that waits for the server and an optimistic update that changes the interface before confirmation
Optimistic UI moves feedback earlier. The server remains authoritative, so the interface must reconcile or recover when the request completes.
Diagram 6: Normal and optimistic update timingA normal update waits for the server before changing UI. An optimistic update changes UI immediately, sends the action, then confirms or rolls back.
Optimistic form action
'use client'

import { useOptimistic } from 'react'
import { addComment } from './actions'

export function Comments({ comments }: { comments: Comment[] }) {
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (current, pending: Comment) => [...current, pending]
  )

  async function formAction(formData: FormData) {
    const rawBody = formData.get('body')
    if (typeof rawBody !== 'string') return
    const body = rawBody.trim()
    addOptimistic({ id: 'pending', body, pending: true })
    await addComment(formData)
  }

  return <form action={formAction}>{/* render list and controls */}</form>
}

Complete CRUD Architecture

A maintainable feature separates route rendering, small interactive controls, action orchestration, validation, authorization, and persistence. That structure reduces accidental client exposure and lets each protected action reuse the same policy.

Diagram 7: Server-first CRUD feature architectureA route page renders data and forms. Small client controls call Server Actions, which pass through validation and an authorization-aware data layer before the database. Revalidation returns fresh route data.
Route page + forms
Pending buttonError statusOptimistic list
Server Actions
ValidationSession + policyData access layer
DatabaseRevalidate path or tag ↑
Suggested App Router feature files
app/
|-- posts/
|   |-- actions.ts          # 'use server', mutation orchestration
|   |-- page.tsx            # read and render posts
|   |-- new/page.tsx        # route-level create form
|   `-- [id]/edit/page.tsx  # route-level edit form
|-- ui/posts/
|   |-- post-form.tsx       # focused client state only if needed
|   `-- submit-button.tsx   # useFormStatus
lib/
|-- auth.ts                 # session verification
|-- posts.ts                # authorized data operations
`-- validation.ts           # shared input rules

Server Actions vs Route Handlers

NeedOften suitable
Form mutation inside a Next.js appServer Action
Public HTTP endpointRoute Handler
Webhook endpointRoute Handler
Third-party API consumerRoute Handler or API
Server-side form mutationServer Action
External clients or mobile appsHTTP API or Route Handler

These are tendencies, not absolute rules. Blog #10 will cover Route Handlers in detail.

Common Mistakes

  • Trusting the client. Repeat validation, authentication, and authorization in every protected action.
  • Using a client ID as ownership. Load the record and compare it with the verified server session.
  • Making the whole page a Client Component. Isolate pending, status, and optimistic interaction.
  • Using the old one-argument revalidateTag. In Next.js 16 use a profile such as 'max', or use updateTag for immediate action-owned updates.
  • Catching redirect(). Redirect after try/catch because it throws to control framework flow.
  • Returning raw errors. Return safe expected messages and let an error boundary handle unexpected failures.
  • Assuming "use server" makes code secure. It only defines the execution boundary.
  • Building SQL strings. Use parameterized methods from the chosen driver or ORM.

Best Practices

  • Keep mutation functions async and server-only.
  • Prefer semantic forms when the interaction is a submission.
  • Use native constraints for usability and server validation for trust.
  • Verify session, permission, role, and ownership inside the action.
  • Return minimal serializable state to Client Components.
  • Choose path and tag invalidation deliberately after a successful write.
  • Disable duplicate submission and announce progress or safe errors accessibly.
  • Use optimistic state only when failure recovery is understandable.
  • Keep redirects internal or validate destinations with an allowlist.
  • Protect destructive actions with confirmation and server-side policy.

Frequently Asked Questions

What are Server Actions in Next.js 16?

A Server Action is an asynchronous React Server Function used for an action or mutation, such as a form submission. Server Function is the broader official term.

What does "use server" do?

It marks an async function, or every exported async function in a module, as callable server-side. It does not replace input validation, authentication, or authorization.

How do I submit a form with a Server Action?

Import or define a Server Function and pass it to the React form action prop. React supplies the submitted FormData as the action argument.

How do I get form values in a Server Action?

Use FormData.get or FormData.getAll, then narrow the unknown values and validate them before using them.

Can Server Actions update a database?

Yes. Keep database access in server-only code, use parameterized ORM or driver methods, and perform authorization before every protected write.

Do Server Actions need authentication?

Protected actions do. Treat each action like a public-facing endpoint and verify the session and the user permission inside the action.

What is the difference between validation and authorization?

Validation checks whether input has an acceptable shape and value. Authorization checks whether the current user may perform that operation on the selected record.

How do I refresh data after a Server Action?

Use revalidatePath for a path, updateTag for immediate read-your-writes tag invalidation in a Server Action, or revalidateTag with a cache-life profile for stale-while-revalidate behavior.

Can I call a Server Action from a Client Component?

Yes. Import it from a module with a top-level use server directive, receive it as a prop, or invoke it from a supported action or event flow.

Should I use Server Actions or Route Handlers?

Server Actions often suit mutations inside a Next.js UI. Route Handlers often suit public HTTP APIs, webhooks, mobile clients, and third-party consumers.

Official Resources

Next Steps

You can now move from reading data to secure mutations: forms call Server Actions, actions validate and authorize, the data layer writes safely, and revalidation or redirects bring the interface up to date. Continue with Next.js 16 caching and revalidation to control freshness precisely.

WhatsApp