Type something to search...
Next.js Mutating Data

Next.js Mutating Data

If you learned React before Server Components existed, "mutating data" probably meant one thing: write an API route, call it with fetch from a useEffect or an onSubmit handler, manage your own loading and error state, and hope you remembered to revalidate whatever cache sat in front of the data you just changed. It worked, but it was a lot of ceremony for something as simple as "save this form."

The App Router replaces most of that ceremony with Server Functions — plain async functions that live on the server but can be called directly from your components, forms, and event handlers as if they were local. No route file, no manual fetch, no hand-written JSON parsing on either end. This article walks through how to define them, how to invoke them from every context Next.js supports, and the practical details — security, caching, redirects, cookies — that turn a toy example into something you'd actually ship.

What a Server Function Actually Is

A Server Function is an async function marked with the 'use server' directive. That's the entire definition. React (not Next.js) owns this primitive — it's part of React Server Functions — and Next.js builds its data-mutation story on top of it.

When you call a Server Function from client-side code, React doesn't run the function locally. It serializes your arguments, sends a POST request to the server, executes the real function there, and streams the result (and, where relevant, an updated UI) back down. From the calling component's point of view, it just looks like await someFunction().

There's a second term you'll see constantly: Server Action. A Server Action isn't a different API — it's a Server Function used in a specific way, wrapped in startTransition, typically to handle a form submission or a mutation. That wrapping happens automatically the moment you:

  • Pass the function to a <form>'s action prop, or
  • Pass it to a <button>'s formAction prop.

So every Server Action is a Server Function, but not every Server Function is necessarily used as an "action" in this sense — you can also call one directly from an onClick handler or a useEffect, which we'll get to. The distinction matters less for how you write the code and more for understanding why documentation and error messages sometimes use one term and sometimes the other.

One detail worth internalizing early: when a Server Action is invoked through a form or button, Next.js can return both the updated UI and new data in a single round trip. That's the actual performance win over the old pattern — you're not doing "mutate, then separately re-fetch, then separately re-render." It's one request that does all three.

Defining a Server Function

You mark a function as a Server Function by adding 'use server' as the first line inside it, or by putting 'use server' at the top of an entire file — which marks every export in that file as a Server Function.

import { auth } from "@/lib/auth";

export async function createPost(formData: FormData) {
  "use server";
  const session = await auth();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  const title = formData.get("title");
  const content = formData.get("content");

  // Mutate data
  // Revalidate cache
}

export async function deletePost(formData: FormData) {
  "use server";
  const session = await auth();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  const id = formData.get("id");

  // Verify the user owns this resource before deleting
  // Mutate data
  // Revalidate cache
}

Both of these functions run exclusively on the server. Nothing inside them — your database client, your auth secrets, your query logic — ever ships to the browser bundle. That's a genuinely different security model from a client-side fetch to an API route, where the request URL, method, and payload shape are all visible in DevTools by design. Here, the client only ever sees "call this function," not the implementation behind it.

Notice both functions re-check auth() independently and throw if there's no session. Hold onto that pattern — it's not boilerplate you can trim later, and I'll come back to exactly why in the security section below.

Defining Server Functions Inside Server Components

You don't have to put every Server Function in a separate file. You can inline one directly inside a Server Component by adding the directive to the top of a nested function body:

export default function Page() {
  // Server Action
  async function createPost(formData: FormData) {
    "use server";
    // ...
  }

  return <></>;
}

This is convenient for a one-off action that's only ever used by the component defining it — a single form on a single page, say. The moment you need the same action from two different components, pull it out into its own file instead. Inlined actions can't be imported anywhere else.

There's also a nice side effect of defining Server Actions this way: Server Components support progressive enhancement by default. A form wired to an inlined (or imported) Server Action will still submit correctly even if the page's JavaScript hasn't finished loading yet, or if JavaScript is disabled entirely. The browser falls back to a genuine HTML form POST. That's not something you get automatically from a fetch-based onSubmit handler — you'd have to build it yourself, and almost nobody does.

Defining Server Functions for Client Components

Here's a constraint that trips people up the first time: you cannot define a Server Function inside a Client Component. A file with 'use client' at the top can't also contain a 'use server' function — the two directives describe two different execution environments, and a single function body can't live in both.

Instead, define the Server Function in its own server-side file, and import it into the Client Component:

"use server";

export async function createPost() {}
"use client";

import { createPost } from "@/app/actions";

export function Button() {
  return <button formAction={createPost}>Create</button>;
}

The Client Component never sees the function body — at build time, Next.js replaces the import with a reference to a server endpoint. All the button knows is "when someone clicks this, call this remote thing." This is also why you can't dynamically construct or transform a Server Function reference in the client the way you might a normal callback — what gets shipped to the browser is essentially an opaque ID, not executable code.

One more behavioral difference worth knowing: in a Client Component, if the JavaScript for the page hasn't loaded yet, form submissions to a Server Action are queued rather than submitted immediately, and they get prioritized once hydration happens. After hydration completes, submitting the form no longer causes a full browser navigation/refresh — it's handled entirely by the client router. Contrast that with a Server Component's form, which falls back to a real, unenhanced HTML POST when JS isn't available yet. Both eventually work; they just degrade differently.

Passing Actions as Props

Sometimes the component that renders the form isn't the component that owns the action — a generic <ClientComponent> might accept the action to run as a prop from its parent:

<ClientComponent updateItemAction={updateItem} />
"use client";

export default function ClientComponent({
  updateItemAction,
}: {
  updateItemAction: (formData: FormData) => void;
}) {
  return <form action={updateItemAction}>{/* ... */}</form>;
}

This is a genuinely useful pattern for building reusable form components — a <EditableField> or <DeleteButton> that doesn't know or care what specific mutation it triggers. I'd flag one naming convention here that the docs quietly follow but don't call out explicitly: suffixing action props with Action (updateItemAction, not onUpdate). It's not enforced by the framework, but it's a useful signal to future readers of the code — and to yourself in six months — that this prop is a Server Function reference, not a plain client-side callback, which behave very differently (one triggers a network round trip and can throw serialization errors if you pass it something non-serializable; the other doesn't).

Invoking Server Functions

There are really only two invocation surfaces that matter:

  1. Forms — in either Server or Client Components.
  2. Event handlers and useEffect — exclusively in Client Components, since these are inherently client-side APIs.

From a Form

This is the primary, intended use case, and it's the one that gets you progressive enhancement and the single-round-trip UI update for free. React extends the native HTML <form> element so its action prop can accept a Server Function directly:

import { createPost } from "@/app/actions";

export function Form() {
  return (
    <form action={createPost}>
      <input type="text" name="title" />
      <input type="text" name="content" />
      <button type="submit">Create</button>
    </form>
  );
}
"use server";

import { auth } from "@/lib/auth";

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  const title = formData.get("title");
  const content = formData.get("content");

  // Mutate data
  // Revalidate cache
}

When the form submits, the function is automatically called with the browser's native FormData object as its first argument — every named <input>, <select>, and <textarea> in the form is available through formData.get("fieldName"). This is genuinely simpler than the old pattern of manually serializing form state into JSON before a fetch call, and it means your form still works exactly as browsers have handled forms since the 1990s, just with a richer result once JavaScript is running.

A subtlety worth knowing: if you need to pass additional arguments beyond the form fields themselves — an ID from the surrounding page context, for instance — you'll usually reach for .bind():

const updatePostWithId = updatePost.bind(null, post.id);

<form action={updatePostWithId}>

The bound argument arrives as the first parameter to your Server Function, with formData as the second. This is a React pattern, not something Next.js invents, but it's how you'll end up passing IDs and other context into actions used across a list of items.

From an Event Handler

Not every mutation is tied to form submission. "Like" buttons, inline edits, drag-and-drop reordering — these need to fire a Server Function from an arbitrary event, most commonly onClick:

"use client";

import { incrementLike } from "./actions";
import { useState } from "react";

export default function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);

  return (
    <>
      <p>Total Likes: {likes}</p>
      <button
        onClick={async () => {
          const updatedLikes = await incrementLike();
          setLikes(updatedLikes);
        }}
      >
        Like
      </button>
    </>
  );
}

This looks almost identical to calling any other async function, and that's the point — a Server Function called this way behaves like a promise-returning API you await and handle normally, with the server round trip abstracted away. The tradeoff is that you lose the automatic progressive enhancement and pending-state wiring that <form> gives you; you're back to managing your own loading and error state, same as you would with a hand-written fetch call. Use this pattern deliberately for interactions that genuinely aren't form submissions, not as a default habit just because it feels more familiar than <form action={...}>.

From useEffect

Less common, but real: sometimes a mutation should fire automatically, tied to a lifecycle event rather than a user action. Incrementing a page's view count on mount is the textbook example. Auto-saving a draft after a debounce, or reacting to an intersection observer for infinite scroll, are others.

"use client";

import { incrementViews } from "./actions";
import { useState, useEffect, useTransition } from "react";

export default function ViewCount({ initialViews }: { initialViews: number }) {
  const [views, setViews] = useState(initialViews);
  const [isPending, startTransition] = useTransition();

  useEffect(() => {
    startTransition(async () => {
      const updatedViews = await incrementViews();
      setViews(updatedViews);
    });
  }, []);

  // You can use `isPending` to give users feedback
  return <p>Total Views: {views}</p>;
}

Wrapping the call in startTransition here isn't decoration — it's what marks the update as non-blocking and gives you the isPending flag to work with, the same mechanism the <form> case gets for free. If you skip the transition and just call the Server Function directly inside useEffect, it'll still work, but you lose the pending-state hook and React treats the resulting state update as a normal synchronous one rather than a low-priority transition.

Be deliberate about this pattern. A view counter that fires once on mount is harmless. An action wired to a dependency array that changes frequently — say, on every keystroke — will hammer your server with requests and is usually a sign you want debouncing, or a completely different approach (client-side state that syncs periodically, rather than a Server Function firing on every render).

An Important Constraint: Server Functions Dispatch Sequentially

This is easy to miss because it's buried in a "good to know" callout in the docs, but it matters for anyone building something non-trivial: the client currently dispatches Server Function calls one at a time and awaits each before starting the next. If your component fires off three Server Functions in quick succession, they don't race in parallel — they queue.

Next.js is explicit that this is an implementation detail that could change, not a guaranteed contract, but as of today, if you need genuine parallel work, you have two real options:

  1. Do the parallel work as data fetching inside a Server Component instead (see the Fetching Data guide — Server Components fetching in parallel is a well-supported, different code path).
  2. Do the parallel work inside a single Server Function, using Promise.all internally, or push it into a dedicated Route Handler if the operation doesn't fit the "form submission" mental model at all.

I'd treat this as a genuine design constraint rather than a footnote: if you're building something like a bulk-action toolbar that fires ten independent deletes, don't wire it up as ten separate Server Function calls from the client and expect them to run concurrently. Batch them into one Server Function that loops internally, or use Promise.all there, where you control the execution model directly.

Giving Users a Pending State

Users need to know a mutation is in flight, especially for anything that takes more than a few hundred milliseconds — file uploads, anything hitting a slow third-party API, expensive database writes. React's useActionState hook, paired with startTransition, gives you this for free:

"use client";

import { useActionState, startTransition } from "react";
import { createPost } from "@/app/actions";
import { LoadingSpinner } from "@/app/ui/loading-spinner";

export function Button() {
  const [state, action, pending] = useActionState(createPost, false);

  return (
    <button onClick={() => startTransition(action)}>
      {pending ? <LoadingSpinner /> : "Create Post"}
    </button>
  );
}

useActionState returns three things: the current state (whatever your Server Function's return value most recently resolved to, or the initial value you pass — false here), a wrapped version of the action to actually invoke, and a pending boolean you can use directly in your render. This is a much smaller amount of code than the equivalent hand-rolled useState + try/catch/finally dance you'd write around a plain fetch call, and it composes cleanly with error state too, since whatever your Server Function returns (or throws) flows through state.

If you're building anything more involved than a single button — optimistic updates, retry logic, multi-step forms — it's worth reading the Building interactive apps guide directly, since it covers useOptimistic and richer error-handling patterns that go beyond what fits in this article.

One more forward-looking note: if your project has the experimental useOffline config enabled, a Server Action that gets interrupted by a dropped connection doesn't just fail — it stays pending and completes automatically once the network comes back. That's a meaningfully different failure mode than a rejected fetch promise, and worth knowing about if you're building for users on flaky connections, even though it's still experimental as of this writing.

Refresh, Revalidate, or Redirect — Picking the Right One

After a mutation succeeds, you almost always need to do one of three things, and it's worth being precise about which, because they're not interchangeable.

refresh() — Re-run the Current Route, Nothing More

"use server";

import { auth } from "@/lib/auth";
import { refresh } from "next/cache";

export async function updatePost(formData: FormData) {
  const session = await auth();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }
  // Mutate data
  // ...

  refresh();
}

refresh() tells the client router to re-render the current route so the UI reflects the latest state. It's the closest equivalent to "just refresh the page, but without a full browser reload." Critically, refresh() does not revalidate any tagged cached data — if the values you changed are sitting behind a cacheTag, calling refresh() alone won't invalidate that cache entry. You'll get a fresh render, but of potentially stale cached data. For that, you need the next two tools.

revalidatePath() / revalidateTag() — Invalidate the Cache

import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  "use server";
  const session = await auth();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }
  // Mutate data
  // ...

  revalidatePath("/posts");
}

revalidatePath clears the cached render for a specific path (and, depending on how you call it, its layouts), so the next request for that route generates fresh output instead of serving a stale cached copy. revalidateTag does the equivalent for any cached data tagged with a specific string, regardless of which route it was originally fetched under — genuinely more precise if the same piece of data is rendered in multiple places (a product shown on both a listing page and a detail page, say).

The practical rule I'd give: reach for revalidateTag by default once you've adopted tag-based caching (cacheTag on your fetches or cached functions), since it decouples "what data changed" from "which URLs happen to render it." Fall back to revalidatePath for simpler apps, or for pages that aren't built around tagged caching at all.

redirect() — Send the User Somewhere Else

"use server";

import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }
  // Mutate data
  // ...

  revalidatePath("/posts");
  redirect("/posts");
}

There's one detail here that catches people off guard the first time: redirect() works by throwing a special, framework-handled exception internally. That means any code you write after the redirect() call inside the same function simply never executes — it's not skipped conditionally, the function just stops. So the ordering above is deliberate: revalidate first, redirect second. If you flip them, your cache invalidation never happens, because redirect() has already unwound the function by that point. If you have cleanup logic that absolutely must run regardless, put it before the redirect, not after — there is no "after" once redirect() fires.

Reading and Writing Cookies

Server Functions can read, set, and delete cookies through the cookies() API, same as you would in other server-side contexts:

"use server";

import { cookies } from "next/headers";

export async function exampleAction() {
  const cookieStore = await cookies();

  // Get cookie
  cookieStore.get("name")?.value;

  // Set cookie
  cookieStore.set("name", "Delba");

  // Delete cookie
  cookieStore.delete("name");
}

What's genuinely useful here — and easy to overlook — is what happens when you set or delete a cookie inside a Server Action: Next.js automatically re-renders the current page and its layouts on the server, specifically so the UI reflects the new cookie value without you having to manually trigger a refresh. This re-render is scoped sensibly too — it re-renders, mounts, or unmounts only the parts of the tree that actually need it, client-side state on components that survive the re-render is preserved, and any effects on those surviving components re-run only if their dependencies actually changed. In practice, this makes cookie-driven UI (a theme preference, a dismissed banner, a locale choice) noticeably simpler to build correctly than in the Pages Router, where you'd typically need a manual router.refresh() or a full reload to get server-rendered content to reflect a just-set cookie.

Security Is Never Automatic — Say It Twice

I want to come back to something the official docs flag with an actual warning callout, because it's the single most consequential thing in this entire article and it's easy to read past: Server Functions are reachable via direct POST requests, not just through your application's UI.

Concretely: nothing stops someone from opening DevTools, finding the endpoint your Server Action maps to, and issuing a raw POST request to it directly — bypassing your form, your button, your client-side validation, all of it. The function ID is effectively public. This is fundamentally the same threat model as an API route, even though the syntax of calling a Server Function makes it feel like calling a trusted local function.

The fix is exactly what every example in this article has been doing, and it's not optional boilerplate: every Server Function must independently verify authentication and authorization, every single time, at the top of the function body — never assume that because a button was disabled in the UI, or a form was hidden behind a login check on the page, the underlying action is protected too. UI-level gating is a UX nicety. Only checks inside the Server Function itself are actual security.

export async function deletePost(formData: FormData) {
  "use server";
  const session = await auth();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  const id = formData.get("id");
  // Verify the user owns this resource before deleting — not just that they're logged in
  // ...
}

Notice the comment in the original deletePost example: checking that a user is authenticated is necessary but not sufficient. You also need to verify that this particular user is authorized to delete this particular resource — otherwise any logged-in user could pass an arbitrary ID and delete someone else's post. This is the exact class of bug that "it worked in my testing because I was always deleting my own stuff" quietly hides until it doesn't.

If you want the fuller treatment of this — rate limiting, input validation, structuring auth checks so you don't repeat yourself across dozens of actions — it's worth reading the Data Security guide directly rather than trying to reconstruct it from first principles per-project.

Common Mistakes Worth Naming Explicitly

Trying to define a Server Function inside a Client Component file. You'll get a build error. Move it to its own server-side module and import it.

Assuming refresh() invalidates tagged cache data. It doesn't. If your mutation needs to bust a cache tag, call revalidateTag (or revalidatePath) explicitly — refresh() alone just re-renders with whatever's currently cached.

Putting logic after redirect() and expecting it to run. It won't. redirect() throws internally; anything after it in the same function is dead code.

Skipping auth checks because "the form is only shown to logged-in users." The Server Function is reachable independently of the form. Gate the function, not just the UI.

Expecting concurrent Server Function calls to run in parallel from the client. They're dispatched sequentially today. Batch parallel work inside a single Server Function, or move it to Server Component data fetching.

Forgetting that inlined Server Actions in a Server Component can't be imported elsewhere. If you find yourself wanting to reuse one, that's the signal to extract it into its own file.

Where This Fits in the Bigger Picture

Mutating data doesn't live in isolation — it's the other half of the story that starts with Fetching Data and continues into Caching and Revalidating. The mental model that ties all three together is worth stating plainly: you fetch data (often cached, often tagged), you mutate it through a Server Function, and then you tell Next.js precisely what to invalidate so the next read reflects the write. Skipping the third step is the most common way a Next.js app ends up with a bug report that says "I saved the change, but the page still shows the old value" — the mutation worked fine; nothing told the cache it was now wrong.

If you're coming from the Pages Router, this entire chapter replaces the combination of API routes plus client-side fetch plus manual router.replace()/router.refresh() calls you'd have used with getServerSideProps. It's not a small quality-of-life improvement — it removes an entire category of "did I remember to invalidate everything" bugs by making cache invalidation an explicit, visible line of code inside the mutation itself, rather than a side effect you have to reason about from a separate file.

Key Takeaways

ScenarioWhat to use
Standard form submission<form action={serverFunction}>
Click-triggered mutation (like buttons, inline edits)onClick + await serverFunction()
Mutation tied to mount/lifecycleuseEffect + startTransition
Show a loading state during a mutationuseActionState
Re-render the current route after a mutationrefresh() from next/cache
Invalidate cached data after a mutationrevalidatePath() or revalidateTag()
Send the user elsewhere after a mutationredirect() — called before any code you need to skip
Read/write cookies from a mutationcookies() — triggers an automatic server re-render
Protecting a Server FunctionRe-check auth and ownership inside every function, always
Parallel mutationsNot supported client-side yet — batch inside one Server Function instead

Server Functions collapse a lot of what used to be distinct concerns — routing, request parsing, client state management, and cache invalidation — into a single async function with a directive on top. That simplicity is genuinely valuable, but it doesn't remove the need to think carefully about security and caching; it just moves those decisions closer to the code that actually needs them, which in my experience is exactly where they belong.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper