Back to blog

Next.js 15: Smooth Server Actions and mutations

February 15, 20252 min read
Next.jsReactServer Actions

Introduction

Next.js 15 brings increased maturity to **Server Actions**. Mutations can now be handled directly from server components or forms, with polished developer experience.

Why Server Actions?

  • **Less boilerplate**: no need to expose an API route for each mutation.
  • **End-to-end typing**: arguments and return values are typed.
  • **Progressive enhancement**: forms work even without JavaScript.

Minimal example

// app/actions.ts
"use server"
import { revalidatePath } from "next/cache"

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string
  if (!title) return

  await db.posts.create({ title })
  revalidatePath("/blog")
}

And on the interface side (App Router):

// app/blog/new-post/page.tsx
import { createPost } from "../actions"

export default function NewPostPage() {
  return (
    <form action={createPost} className="space-y-4">
      <label className="block text-sm font-medium">
        Title
        <input
          name="title"
          className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
        />
      </label>
      <button
        type="submit"
        className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground"
      >
        Create post
      </button>
    </form>
  )
}

What really changes with version 15

Two things concretely improve daily workflow compared to previous versions, beyond API stabilization:

  • **`fetch` cache is no longer enabled by default.** Previously, a `fetch` in a Server Component was cached unless specified otherwise; since version 15, it’s the opposite. This changes how you think about a Server Action: you no longer need to worry about silent caching returning stale data after a mutation, but you now must explicitly opt-in (`{ cache: "force-cache" }`) where you truly want caching.
  • **`useActionState`** (the evolution of `useFormState`, now provided by React itself) simplifies tracking the state of a Server Action from a Client Component:
"use client"
import { useActionState } from "react"
import { createPost } from "../actions"

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null)

  return (
    <form action={formAction} className="space-y-3">
      <input name="title" className="rounded-md border border-border px-3 py-2 text-sm" />
      <button disabled={isPending} className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground">
        {isPending ? "Submitting…" : "Create"}
      </button>
      {state?.error && <p className="text-sm text-destructive">{state.error}</p>}
    </form>
  )
}

No need to juggle separate `useState` for pending state and errors: the Server Action and form state are natively linked.

Best practices

1. **Always validate data** (Zod, valibot, etc.) before writing to the database — a Server Action remains a public entry point, just like an API route. 2. **Isolate business logic** in reusable functions to prevent Server Actions from growing too large. 3. Use `revalidatePath` or `revalidateTag` right after a mutation to keep the UI in sync, keeping in mind that the default cache behavior has changed in version 15.

Conclusion

Server Actions are a central tool for modern Next.js applications. Version 15 doesn’t change their core behavior, but the new cache behavior and `useActionState` change the way you write them day-to-day.

Next.js 15: Smooth Server Actions and mutations | Izayid Ali