Back • 13/10/2024
How to Use Next-Safe-Action for Your Server Actions
Written by Melvyn Malherbe on 13/10/2024
Server Actions in NextJS are powerful, but they lack essential features:
- Middleware for user rights verification, logging, and more
- Error management
- Parameter validation
To address this issue, an open-source library next-safe-action
has become very popular, and I will show you exactly how it can solve all your problems.
Installation of Next-Safe-Action
To install next-safe-action
, simply run the following command:
pnpm add next-safe-action zod
Then create a file lib/action.ts
:
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient();
This actionClient
will then allow you to create server actions
more easily. Imagine we want to create a Server Action
to update a user; we can now do:
// app/users/update/user-update.action.ts
'use server';
import { getCurrentUser } from '~/lib/auth';
import { actionClient } from '~/lib/action';
import { z } from 'zod';
const UpdateUserSchema = z.object({
id: z.string(),
name: z.string().optional(),
email: z.string().optional(),
});
export const updateUserAction = actionClient
.schema(UpdateUserSchema)
.action(async ({ parsedInput: { id, name, email } }) => {
const currentUser = await getCurrentUser();
if (currentUser.id !== id) {
throw new Error('You are not allowed to update this user');
}
const updatedUser = await prisma.user.update({
where: {
id,
},
data: {
name: name ?? currentUser.name,
email: email ?? currentUser.email,
},
});
return updatedUser;
});
You can see here that parsedInput
contains the data defined by the Schema. Everything is automatically TypeSafe
, and no further verification is needed.
You can then use your action in this way:
const handleSubmit = async (formData: FormData) => {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const result = await updateUserAction({
id: params.id,
name,
email,
});
// If result?.data is defined, the action is successful
if (result?.data) {
alert('The user has been updated');
router.push('/users');
return;
}
// If result?.serverError is defined, the action failed
if (result?.serverError) {
alert(result.serverError ?? 'Error updating user');
return;
}
};
Adding Middleware
It will be very common to perform checks before executing an action. For example, we can check if the user is logged in and has the rights to perform this action.
For this, we will use next-safe-action
and create middleware that will check if the user is logged in and has the rights to perform this action.
// lib/action.ts
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient();
export const authActionClient = actionClient.use(async ({ next }) => {
const currentUser = await getCurrentUser();
if (!currentUser) {
throw new Error('You are not allowed to create a user');
}
return next({ ctx: { user: currentUser } });
});
Now we can create an action and retrieve the current user. We are sure that this user is the current user thanks to the middleware we created with "use"
:
// app/users/update/user-update.action.ts
'use server';
/// ...
export const updateUserAction = authActionClient
.schema(UpdateUserSchema)
.action(async ({ parsedInput: { id, name, email }, ctx: { user } }) => {
if (user.id !== id) {
throw new Error('You are not allowed to update this user');
}
// ...
});
Error Management
But the problem we have is that the default Error
is not returned to the client for security reasons. We will need to define ourselves the Errors
that we want to return to the client.
For this, we will use the handleServerError
method:
// lib/action.ts
import { createSafeActionClient } from 'next-safe-action';
export class ActionError extends Error {}
export const actionClient = createSafeActionClient({
handleServerError: (error) => {
if (error instanceof ActionError) {
return error.message;
}
return 'Oh no, generic error';
},
});
export const authActionClient = actionClient.use(async ({ next }) => {
const currentUser = await getCurrentUser();
if (!currentUser) {
throw new Error('You are not allowed to create a user');
throw new ActionError('You are not allowed to create a user');
}
return next({ ctx: { user: currentUser } });
});
// app/users/update/user-update.action.ts
('use server');
// ...
export const updateUserAction = authActionClient
.schema(UpdateUserSchema)
.action(async ({ parsedInput: { id, name, email }, ctx: { user } }) => {
if (user.id !== id) {
throw new Error('You are not allowed to update this user');
throw new ActionError('You are not allowed to update this user');
}
// ...
});
This way, this error will be returned in result?.serverError
when the action failed due to these errors.
Retrieving the Action Result
The code to retrieve the action result can be complicated and causes me a lot of problems.
I created a utility that allows you to quickly determine if the action is successful or failed:
/**
* Determines if a server action is successful or not
* A server action is successful if it has a data property and no serverError property
*
* @param action Return value of a server action
* @returns A boolean indicating if the action is successful
*/
export const isActionSuccessful = <T extends z.ZodType>(
action?: SafeActionResult<string, T, readonly [], any, any>
): action is { data: T; serverError: undefined; validationError: undefined } => {
if (!action) {
return false;
}
if (action.serverError) {
return false;
}
if (action.validationErrors) {
return false;
}
return true;
};
Thanks to is
, we are saying:
If this method returns
true
, then we know for sure thatdata
is defined
You can then use this method in this way:
const handleSubmit = async (formData: FormData) => {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const result = await updateUserAction({
id: params.id,
name,
email,
});
if (!isActionSuccessful(result)) {
alert(result.serverError ?? 'Error updating user');
return;
}
alert('The user has been updated');
router.push('/users');
};
You can see that this is already cleaner and makes it easier to know the result of the action.
But sometimes we want to go even further... for example, if you use Tanstack Query
to manage backend requests.
What Tanstack Query
likes is to have a promise, the thing is that server-action returned by next-safe-action
is always resolved
.
However, sometimes we want them to be rejected
when the action fails.
For this, I created this utility:
/**
* Converts an action result to a promise that resolves to false
*
* @param action Return value of a server action
* @returns A promise that resolves to false
*/
export const resolveActionResult = async <T extends z.ZodType>(
action: Promise<SafeActionResult<string, T, readonly [], any, any> | undefined>
): Promise<T> => {
return new Promise((resolve, reject) => {
action
.then((result) => {
if (isActionSuccessful(result)) {
resolve(result.data);
} else {
reject(result?.serverError ?? 'Something went wrong');
}
})
.catch((error) => {
reject(error);
});
});
};
This resolveActionResult
method will transform our action
into a Promise
that resolves
when the action works and rejects
when the action fails.
You can use this method in this way:
export const mutation = useMutation({
mutationFn: async (props: { email: string; name: string }) => {
return resolveActionResult(
updateUserAction({
...props,
id: params.id,
})
);
},
onError: (error) => {
alert(error);
},
onSuccess: (data) => {
alert('The user has been updated');
router.push('/users');
},
});
Isn't it beautiful?
Creating Configurable Middleware
Let's say your users have permissions, which are in the form of strings in a permissions
array. How do you create actions that automatically check these permissions? For this, we will use metadata.
Here is an example of code:
// lib/action.ts
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient({
handleServerError: (error) => {
// ...
},
defineMetadataSchema() {
return z.object({
role: z.string(),
});
},
});
export const authActionClient = actionClient.use(async ({ next, metadata }) => {
const currentUser = await getCurrentUser();
if (!currentUser) {
throw new Error('You are not allowed to create a user');
}
if (!currentUser.roles.includes(metadata.role)) {
throw new Error('You are not allowed to do this action.');
}
return next({ ctx: { user: currentUser } });
});
Here we define a metadata schema that will contain the user's permissions. We can then use this metadata in the middleware to check if the user has the rights to perform this action.
We can then create secure server-actions in this way:
// app/users/update/user-update.action.ts
'use server';
import { authActionClient } from '~/lib/action';
import { z } from 'zod';
const UpdateUserSchema = z.object({
id: z.string(),
name: z.string().optional(),
email: z.string().optional(),
});
export const updateUserAction = authActionClient
.schema(UpdateUserSchema)
.metadata({
role: 'admin',
})
.action(async ({ parsedInput: { id, name, email }, ctx: { user } }) => {
const updatedUser = await prisma.user.update({
where: {
id,
},
data: {
name: name ?? user.name,
email: email ?? user.email,
},
});
return updatedUser;
});
Here we automatically check that the user is an administrator before performing the action.
I recommend using these methods with permissions
containing strings
with the different available rights.
Conclusions
I have presented everything I know about this next-safe-action
library, which can truly change the way you do NextJS
.