next-safe-action
is a library that takes full advantage of the latest and greatest Next.js, React and TypeScript features to let you define typesafe actions on the server and call them from Client Components.
Features
- ✅ Pretty simple
- ✅ End to end type safety
- ✅ Input validation
- ✅ Direct or hook usage from client
- ✅ Optimistic updates
- ✅ Authenticated actions
Requirements
Next.js >= 13.4.2 and >= TypeScript 5.0.
Installation
npm i next-safe-action zod
Code example ⬇️
Check out this example repository to see a basic implementation of this library and to experiment a bit with it.
Project configuration
In next.config.js
(since Next.js 13.4.0):
const nextConfig = {
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;
Then, you need to create the safe action client:
import { createSafeActionClient } from "next-safe-action";
export const action = createSafeActionClient();
Now create a file for an action:
"use server";
import { z } from "zod";
import { action } from "~/lib/safe-action";
const input = z.object({
username: z.string().min(3).max(10),
password: z.string().min(8).max(100),
});
export const loginUser = action({ input }, async ({ username, password }) => {
if (username === "johndoe") {
return {
error: {
reason: "user_suspended",
},
};
}
if (username === "user" && password === "password") {
return {
success: true,
};
}
return {
error: {
reason: "incorrect_credentials",
},
};
}
);
action
returns a new function (in this case loginUser
) that can be directly imported in Client Components.
There are two ways to call safe actions from the client:
1. The direct way
"use client";
import { loginUser } from "./login-action";
export default function Login() {
return (
<button
onClick={async () => {
// Typesafe action called from client.
const res = await loginUser({ username: "user", password: "password" });
// Res keys.
const { data, validationError, serverError } = res;
}}>
Log in
</button>
);
}
On the client you get back a typesafe response object, with three optional keys:
-
data
: if action runs without issues, you get what you returned in the server action body.
-
validationError
: if an invalid input object (parsed by Zod via input validator) is passed from the client when calling the action, invalid fields will populate this key, in the form of:
{
"validationError": {
"fieldName": ["issue"],
}
}
serverError
: if an unexpected error occurs in the server action body, it will be caught, and the client will only get back a serverError
response. By default, the server error will be logged via console.error
, but this is configurable.
2. The hook way
Another way to mutate data from client is by using the useAction
hook. This is useful when you need global access to the action state in the Client Component.
useAction
uses React's useTransition
hook behind the scenes to manage the mutation.
Here's how it works:
"use client";
import { useAction } from "next-safe-action/hook";
import { loginUser } from "./login-action";
export default function Login() {
const { execute, isExecuting, res } = useAction(loginUser);
return (
<>
<button
onClick={() => {
// Typesafe action called from client.
execute({ username: "user", password: "password" });
}}>
Log in
</button>
<p>Is executing: {JSON.stringify(isExecuting)}</p>
<p>Res: {JSON.stringify(res)}</p>
</>
);
}
The useAction
hook returns an object with three keys:
execute
: a caller for the safe action you provided as argument to the hook. Here you pass your typesafe input
, the same way you do when using safe action the non-hooky way.isExecuting
: a boolean
that is true while the execute
function is mutating data.res
: when execute
finished mutating data, the response object. Otherwise it is null
. It has the same three optional keys as the one above (data
, validationError
, serverError
), plus one: fetchError
. This additional optional key is populated when communication with the server fails for some reason.
Image example:
Optimistic update ✨ (experimental)
If you need optimistic UI in your Client Component, the lib also exports a hook called useOptimisticAction
, that under the hood uses React's experimental_useOptimistic
hook.
Note: this React hook is not stable, use it at your own risk!
Here's how it works:
First, define your server action as usual:
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { action } from "~/lib/safe-action";
import { incrementLikes } from "./db";
const inputValidator = z.object({
incrementBy: z.number(),
});
export const addLikes = action(
{ input: inputValidator },
async ({ incrementBy }) => {
await new Promise((res) => setTimeout(res, 2000));
const newLikes = incrementLikes(incrementBy);
revalidatePath("/optimistic-hook");
return {
newLikes,
};
}
);
Then, you can pass state as prop from a Server Component to a Client Component (in this case likesCount
), and use the hook in it.
"use client";
import { useOptimisticAction } from "next-safe-action/hook";
import { addLikes } from "./addlikes-action";
export default function AddLikes({ likesCount }: { likesCount: number }) {
const { execute, isExecuting, res, optimisticState } = useOptimisticAction(
addLikes,
{ likesCount }
);
return (
<>
<button
onClick={() => {
const randomIncrement = Math.round(Math.random() * 100);
// Action call. Here we pass action input and expected (optimistic)
// server state.
execute(
{ incrementBy: 42 },
{ likesCount: likesCount + randomIncrement }
);
}}>
Add likes
</button>
<p>Optimistic state: {JSON.stringify(optimisticState)}</p> {/* [1] */}
<p>Is executing: {JSON.stringify(isExecuting)}</p>
<p>Res: {JSON.stringify(res)}</p>
</>
);
}
As you can see, useOptimisticAction
has the same three keys as the normal useAction
hook (execute
, isExecuting
, res
).
[1]: It returns one additional key though: optimisticState
is an object with a type of the second argument passed to useOptimisticAction
hook. This object will update immediately when you execute
the action. Real data will come back once action has finished executing.
Authenticated action
The library also supports creating protected actions, that will return a serverError
back if user is not authenticated. You need to make some changes to the above code in order to use them.
First, when creating the safe action client, you must provide an async function
called getAuthData
as an option. You can return anything you want from here. If you find out that the user is not authenticated, you can safely throw an error in this function. It will be caught, and the client will receive a serverError
response.
import { createSafeActionClient } from "next-safe-action";
export const action = createSafeActionClient({
getAuthData: async () => {
const session = true;
if (!session) {
throw new Error("user is not authenticated!");
}
return {
userId: "coolest_user_id",
};
},
});
Then, you can provide a withAuth: true
option to the safe action you're creating:
"use server";
import { z } from "zod";
import { action } from "~/lib/safe-action";
...
export const editUser = action({ input, withAuth: true },
async (parsedInput, { userId }) => {
console.log(userId);
...
}
);
If you set withAuth
to true
in the safe action you're creating, but you forgot to define a getAuthData
function when creating the client (above step), an error will be thrown when calling the action from client, that results in a serverError
response for the client.
Custom server error logging
As you just saw, you can provide a getAuthData
function to createSafeActionClient
function.
You can also provide a custom logger function for server errors. By default, they'll be logged via console.error
(on the server, obviously), but this is configurable:
import { createSafeActionClient } from "next-safe-action";
export const action = createSafeActionClient({
serverErrorLogFunction: (e) => {
console.error("CUSTOM ERROR LOG FUNCTION:", e);
},
});
Alternatives
License
This project is licensed under the MIT License.