Next.js 15: Smooth Server Actions and mutations
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.