Type-Safe Next.js Server Actions
next-safe-action is a library designed for Next.js projects to create type-safe and validated Server Actions. It leverages modern Next.js, React, and TypeScript features to ensure end-to-end type safety from client-side component calls to server-side action execution. The current stable version is 8.5.2, with minor and patch releases occurring frequently to refine types, add features, and improve developer experience. Key differentiators include robust input/output validation, a flexible middleware system for authorization or logging, advanced server error handling, and support for optimistic updates, making it a powerful tool for building reliable and predictable data mutations in Next.js applications.
Common errors
-
Error: Cannot find module 'next-safe-action' or its corresponding type declarations.
cause Incorrect import path or CommonJS project attempting to import an ESM package.fixVerify that `next-safe-action` is installed correctly (`npm i next-safe-action`) and ensure you are using ES module imports (`import ... from 'next-safe-action';`). If using CommonJS in Node.js, you might need to configure your project for ESM or use a bundler that handles ESM correctly. -
Error: `useAction` is not a function or `useAction` is not defined.
cause Incorrect import path for the `useAction` hook.fixThe `useAction` hook must be imported from the `/hook` subpath: `import { useAction } from 'next-safe-action/hook';`. -
TypeError: Cannot read properties of undefined (reading 'validationErrors')
cause Accessing properties like `validationErrors` directly on `result` without checking if `result` or `result.validationErrors` is defined, or if the action even produced validation errors.fixAlways conditionally access properties of `result` and its nested objects, e.g., `result?.validationErrors?.fieldName` or `if (result.validationErrors) { /* handle errors */ }`. With v8.5.0+, leverage the discriminated union by checking `result.validationErrors` first.
Warnings
- breaking next-safe-action v8 introduced breaking changes, including how action clients are created and used. Refer to the migration guide for a smooth transition.
- gotcha Version 8.5.0 narrowed `SafeActionResult` into a discriminated union, meaning `data`, `serverError`, and `validationErrors` are now mutually exclusive in the type system.
- gotcha Using `require()` for imports will lead to errors as `next-safe-action` is an ESM-first package. Node.js environments configured for CommonJS will not resolve imports correctly.
- gotcha When using `useAction`, ensure the component where it's called is a React Client Component. Server Actions are defined on the server, but the `useAction` hook must be run in a client environment.
Install
-
npm install next-safe-action -
yarn add next-safe-action -
pnpm add next-safe-action
Imports
- createSafeActionClient
const { createSafeActionClient } = require('next-safe-action');import { createSafeActionClient } from 'next-safe-action'; - useAction
import { useAction } from 'next-safe-action';import { useAction } from 'next-safe-action/hook'; - safeAction
const action = client.action(schema, async (input) => { /* ... */ });
Quickstart
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
import { useAction } from 'next-safe-action/hook';
// server/actions.ts
export const actionClient = createSafeActionClient();
export const addTodo = actionClient
.schema(z.object({ text: z.string().min(1) }))
.action(async ({ text }) => {
// Simulate database operation
await new Promise(resolve => setTimeout(resolve, 500));
console.log(`Adding todo: ${text}`);
return { success: true, newTodo: { id: Date.now(), text } };
});
// app/page.tsx (or any client component)
'use client';
import { useState } from 'react';
import { addTodo } from '@/server/actions'; // Adjust path as needed
export default function TodoForm() {
const [todoText, setTodoText] = useState('');
const { execute, result, status } = useAction(addTodo, {
onSuccess: (data) => {
console.log('Todo added successfully:', data?.newTodo);
setTodoText('');
},
onError: (error) => {
console.error('Failed to add todo:', error);
}
});
const isLoading = status === 'executing';
return (
<form onSubmit={(e) => {
e.preventDefault();
execute({ text: todoText });
}}>
<input
type="text"
value={todoText}
onChange={(e) => setTodoText(e.target.value)}
placeholder="New todo item"
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Adding...' : 'Add Todo'}
</button>
{result.validationErrors?.text && (
<p style={{ color: 'red' }}>{result.validationErrors.text[0]}</p>
)}
{result.serverError && (
<p style={{ color: 'red' }}>{result.serverError}</p>
)}
</form>
);
}