next-safe-action
Integrations

TanStack Query

next-safe-action has a first-party adapter for TanStack Query that provides a mutationOptions() factory function for use with useMutation(). It bridges next-safe-action's result-based error model to TanStack Query's thrown-error model via a typed ActionMutationError class.

Installation

npm install next-safe-action @tanstack/react-query @next-safe-action/adapter-tanstack-query

Quick start

Define the action

Create a standard server action with an input schema:

src/app/actions.ts
"use server";

import { z } from "zod";
import { actionClient } from "@/lib/safe-action";

const createUserSchema = z.object({
	name: z.string().min(2, "Name must be at least 2 characters"),
	email: z.string().email("Invalid email address"),
});

export const createUser = actionClient
	.inputSchema(createUserSchema)
	.action(async ({ parsedInput }) => {
		const user = await db.user.create({ data: parsedInput });
		return { user };
	});

Create the mutation component

Use mutationOptions() from the adapter with TanStack Query's useMutation:

src/app/create-user-form.tsx
"use client";

import { useMutation } from "@tanstack/react-query";
import { mutationOptions, hasValidationErrors } from "@next-safe-action/adapter-tanstack-query";
import { createUser } from "./actions";

export function CreateUserForm() {
	const { mutate, isPending, isError, error, data } = useMutation(
		mutationOptions(createUser)
	);

	return (
		<form
			onSubmit={(e) => {
				e.preventDefault();
				const formData = new FormData(e.currentTarget);
				mutate({
					name: formData.get("name") as string,
					email: formData.get("email") as string,
				});
			}}
		>
			<input name="name" placeholder="Name" />
			<input name="email" placeholder="Email" />

			{isError && hasValidationErrors(error) && (
				<p>Validation failed</p>
			)}
			{isError && !hasValidationErrors(error) && (
				<p>Server error: {String(error.serverError)}</p>
			)}
			{data && <p>Created: {data.user.name}</p>}

			<button type="submit" disabled={isPending}>
				{isPending ? "Creating..." : "Create User"}
			</button>
		</form>
	);
}

Hooks vs. adapter

next-safe-action provides two ways to call server actions from client components: the built-in hooks (useAction, useOptimisticAction, useStateAction) from next-safe-action/hooks, and this TanStack Query adapter (mutationOptions) from @next-safe-action/adapter-tanstack-query. Both give you type-safe action execution, but they use fundamentally different mechanisms under the hood and shine in different scenarios.

How they differ

The built-in hooks are built on top of React 19 concurrent primitives: useTransition, useOptimistic, and useActionState. When you call execute(), the action runs inside a React Transition, which means React keeps the current UI responsive while the action is in flight, and features like Suspense boundaries and optimistic state rollbacks integrate automatically. The hooks also work directly with next-safe-action's result envelope, so you access result.data, result.serverError, and result.validationErrors as structured fields without any error transformation.

The TanStack Query adapter delegates all state management to TanStack Query's useMutation. It bridges the result envelope to TanStack Query's thrown-error model: when an action returns serverError or validationErrors, the adapter extracts them from the result and wraps them in a typed ActionMutationError instance on the client. In return, you gain TanStack Query's full mutation lifecycle: configurable retry strategies with exponential backoff, client-side query cache invalidation after mutations (in addition to Next.js's server-side revalidatePath/revalidateTag, which work with both approaches), TanStack Query DevTools integration, mutation persistence for offline support, and the ability to coordinate mutations with queries in a single client cache.

When to use which

ScenarioRecommendation
New Next.js project without TanStack QueryBuilt-in hooks
Simple form submissions and button actionsBuilt-in hooks
You want instant optimistic UI via React's useOptimisticBuilt-in hooks (useOptimisticAction)
You want zero additional dependenciesBuilt-in hooks
Already using TanStack Query for data fetchingAdapter
Already using tRPC + TanStack QueryAdapter
You need automatic retries with backoffAdapter
You need to invalidate TanStack Query's client cache after a mutationAdapter
You want TanStack Query DevTools visibility for mutationsAdapter
You need mutations to survive page reloads or network loss (offline persistence)Adapter

Feature comparison

FeatureBuilt-in hooksAdapter
React TransitionsYes, actions run inside startTransitionNo
Optimistic updatesuseOptimisticAction via React's useOptimisticManual via onMutate + query cache
Automatic retriesNoYes, retry option with backoff
Server cache invalidationYes, revalidatePath() / revalidateTag() inside server actionsYes, same Next.js APIs inside server actions
Client query cache invalidationNo (not applicable)Yes, queryClient.invalidateQueries() in onSuccess
DevToolsNoYes, TanStack Query DevTools
Error modelResult envelope (result.serverError, result.validationErrors)Thrown ActionMutationError with type guards
Offline mutation persistenceNo, state is lost on unmount or reloadYes, paused mutations can be serialized to storage and resumed via dehydrate/hydrate
Async executionexecuteAsync() returns Promise<Result>mutateAsync() returns Promise<Data>
Status trackingstatus string + shorthand booleans (isIdle, isPending, hasSucceeded, hasErrored)Boolean flags (isPending, isError, isSuccess)
Extra dependenciesNone (React only)@tanstack/react-query

General guidance

Prefer built-in hooks for most Next.js applications. They require no extra dependencies, integrate deeply with React's concurrent rendering model, and give you direct access to the result envelope without error transformation. If your app primarily uses Server Components for data fetching and only needs server actions for mutations (forms, button clicks, state changes), the built-in hooks are the simplest and most natural choice.

Prefer the adapter when TanStack Query is already part of your stack, especially alongside tRPC for a type-safe API layer. If you are fetching data with useQuery and want mutations to participate in the same client-side cache lifecycle (query invalidation, optimistic cache updates, retry strategies, DevTools inspection), the adapter keeps everything in one ecosystem instead of splitting state management between two systems. Note that Next.js's server-side cache invalidation (revalidatePath/revalidateTag) works regardless of which approach you choose, since it runs inside the server action itself.

Why mutations only?

This adapter intentionally provides only mutationOptions() for useMutation(). There is no queryOptions() or useQuery() support, by design.

Server Actions in React and Next.js are built exclusively for mutations, not data fetching:

  • POST-only transport. Server Actions always use POST. Next.js docs: "Behind the scenes, actions use the POST method, and only this HTTP method can invoke them." Queries should use GET, the correct method for safe, cacheable reads.

  • Sequential queuing. Server Actions are queued per client to preserve ordering. Next.js docs: "Server Actions are queued, which means using them for data fetching introduces sequential execution." This creates request waterfalls where concurrent reads should run in parallel.

  • No HTTP caching. POST requests bypass browser cache, Cache-Control, ETag, and conditional requests. useQuery relies on stable cache keys from URLs and parameters, which Server Actions provide neither.

  • No request deduplication. Without a stable resource identity, TanStack Query cannot deduplicate simultaneous reads across components.

What to use instead for data fetching

  • Server-side reads: React Server Components: data is fetched during rendering on the server with full Next.js caching support.
  • Client-side reads: Create a Route Handler (GET endpoint) and use useQuery / queryOptions. This gives you HTTP caching, deduplication, staleTime, background refetching, and all TanStack Query cache features.
  • Full-stack type-safe API layer: If you want end-to-end type safety for both queries (GET) and mutations (POST), first-class TanStack Query integration, and the ability to share your API procedures across multiple clients or applications, tRPC is the best fit. tRPC provides queryOptions() and mutationOptions() factories that plug directly into useQuery and useMutation, giving you the complete TanStack Query experience (caching, deduplication, background refetching, and optimistic updates) for reads and writes alike.

How it works

The mutationOptions() function creates a complete UseMutationOptions object that bridges next-safe-action's result envelope to TanStack Query's error model:

  1. Calls the safe action with the input provided to mutate() / mutateAsync()
  2. Inspects the result envelope for serverError or validationErrors
  3. Throws ActionMutationError if either is present, a custom error class created on the client, so instanceof checks work reliably
  4. Returns data directly as TanStack Query's TData on success
  5. Handles navigation errors (redirect(), notFound(), forbidden(), unauthorized()) by composing TanStack Query's throwOnError option to always re-throw them during React's render phase, allowing Next.js to catch them and perform the navigation

Package entry points

Entry pointExportsEnvironment
@next-safe-action/adapter-tanstack-querymutationOptions, ActionMutationError, isActionMutationError, hasServerError, hasValidationErrorsClient

mutationOptions

Creates a complete UseMutationOptions object for use with useMutation.

import { mutationOptions } from "@next-safe-action/adapter-tanstack-query";

const options = mutationOptions(safeActionFn, opts?);

Parameters

Prop

Type

Return value

Returns a complete UseMutationOptions<Data, ActionMutationError<ServerError, ShapedErrors>, Input, TOnMutateResult> object, ready to be spread into useMutation().

Examples

Basic

import { useMutation } from "@tanstack/react-query";
import { mutationOptions } from "@next-safe-action/adapter-tanstack-query";
import { createUserAction } from "./actions";

function CreateUserForm() {
	const { mutate, isPending, isError, error, data } = useMutation(
		mutationOptions(createUserAction)
	);

	return (
		<form onSubmit={(e) => { e.preventDefault(); mutate({ name: "John" }); }}>
			<button disabled={isPending}>Create User</button>
			{isError && error.serverError && <p>{error.serverError}</p>}
			{data && <p>Created: {data.name}</p>}
		</form>
	);
}

With callbacks and query invalidation

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { mutationOptions, hasValidationErrors } from "@next-safe-action/adapter-tanstack-query";

function CreateUserForm() {
	const queryClient = useQueryClient();

	const mutation = useMutation(mutationOptions(createUserAction, {
		onSuccess: (data) => {
			toast.success(`Created ${data.name}`);
			queryClient.invalidateQueries({ queryKey: ["users"] });
		},
		onError: (error) => {
			if (hasValidationErrors(error)) {
				showFieldErrors(error.validationErrors);
			} else {
				toast.error(`Server error: ${error.serverError}`);
			}
		},
		retry: (count, error) => {
			if (hasValidationErrors(error)) return false;
			return count < 3;
		},
	}));
}

With optimistic updates

const mutation = useMutation(mutationOptions(toggleTodoAction, {
	onMutate: async (input) => {
		await queryClient.cancelQueries({ queryKey: ["todos"] });
		const previous = queryClient.getQueryData(["todos"]);
		queryClient.setQueryData(["todos"], (old) =>
			old.map((t) => t.id === input.id ? { ...t, done: !t.done } : t)
		);
		return { previous };
	},
	onError: (_error, _input, context) => {
		if (context?.previous) {
			queryClient.setQueryData(["todos"], context.previous);
		}
	},
	onSettled: () => {
		queryClient.invalidateQueries({ queryKey: ["todos"] });
	},
}));

With mutateAsync

const { mutateAsync } = useMutation(mutationOptions(createUserAction));

async function handleSubmit(formData: FormData) {
	try {
		const user = await mutateAsync({ name: formData.get("name") as string });
		router.push(`/users/${user.id}`);
	} catch (error) {
		if (isActionMutationError(error) && hasValidationErrors(error)) {
			// handle validation errors
		}
	}
}

Error handling

When a safe action returns serverError or validationErrors, the adapter throws an ActionMutationError. This means TanStack Query's isError, error, failureCount, and retry mechanism all work naturally:

  • isSuccess is only true when the action succeeds without errors
  • isError is true when the action has server or validation errors
  • error is a typed ActionMutationError with serverError and validationErrors properties

ActionMutationError

class ActionMutationError<ServerError, ShapedErrors> extends Error {
	readonly kind: "server" | "validation" | "both";
	readonly serverError?: ServerError;
	readonly validationErrors?: ShapedErrors;
}

The kind property tells you which errors are present:

  • "server": only serverError is set
  • "validation": only validationErrors is set
  • "both": both are set

Type guards

import {
	isActionMutationError,
	hasServerError,
	hasValidationErrors,
} from "@next-safe-action/adapter-tanstack-query";

// Check if an unknown error is an ActionMutationError
if (isActionMutationError(error)) {
	error.serverError;      // typed access
	error.validationErrors; // typed access
}

// Narrow to server errors
if (hasServerError(error)) {
	error.serverError; // guaranteed non-undefined
}

// Narrow to validation errors
if (hasValidationErrors(error)) {
	error.validationErrors; // guaranteed non-undefined
}

throwValidationErrors / throwServerError incompatibility

Do not use throwValidationErrors: true or throwServerError: true on actions passed to mutationOptions().

React's Flight protocol serializes errors thrown in Server Actions across the server–client boundary. Custom error classes are converted to plain Error objects, all custom properties (like validationErrors) are lost, and instanceof checks fail. In production, even the error message is replaced with a generic string.

The adapter relies on the result envelope (the default behavior) to extract structured error data. When throwValidationErrors or throwServerError is enabled, errors are thrown on the server and lose all structured data before reaching the client.

Server actions that call redirect(), notFound(), forbidden(), or unauthorized() throw framework-level navigation errors. The adapter automatically handles these by composing TanStack Query's throwOnError option to always re-throw navigation errors during React's render phase, allowing Next.js to catch them and perform the navigation.

If you provide your own throwOnError option, the adapter composes it: navigation errors are always re-thrown, and your function handles everything else.


Type utilities

InferMutationOptions

Infer the UseMutationOptions type from a safe action function:

import type { InferMutationOptions } from "@next-safe-action/adapter-tanstack-query";

type MyMutationOptions = InferMutationOptions<typeof createUser>;

InferActionMutationError

Infer the ActionMutationError type from a safe action function:

import type { InferActionMutationError } from "@next-safe-action/adapter-tanstack-query";

type MyError = InferActionMutationError<typeof createUser>;

See also

On this page