Retour • 13/10/2024
Bien utiliser Next-Safe-Action pour tes Server-Action
Écris par Melvyn Malherbe le 13/10/2024
Les Server Actions en NextJS sont puissantes mais elles manquent cruellement de fonctionnalités essentielles :
- middleware pour vérifier les droits d'utilisateur, ajouter des logs et autres
- gestion des erreurs
- vérification des paramètres
Pour résoudre ce problème, une library open-source next-safe-action
est devenue très populaire et je vais te montrer exactement comment elle peut résoudre tous tes problèmes.
Installation de Next-Safe-Action
Pour installer next-safe-action
, il suffit de lancer la commande suivante :
pnpm add next-safe-action zod
Puis créer un fichier lib/action.ts
:
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient();
Ce actionClient
va te permettre ensuite de créer des server action
plus facilement. Imaginons qu'on veuille créer une Server Action
pour modifier un utilisateur, on peut maintenant faire :
// 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;
});
Tu peux voir qu'ici parsedInput
contient les données définies par le Schéma. Tout est automatiquement TypeSafe
et il n'a pas besoin de faire plus de vérification.
On peut ensuite utiliser ton action de cette manière :
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,
});
// Si result?.data est défini, l'action est un succès
if (result?.data) {
alert('The user has been updated');
router.push('/users');
return;
}
// Si result?.serverError est défini, l'action a échoué
if (result?.serverError) {
alert(result.serverError ?? 'Error updating user');
return;
}
};
Ajout de middleware
Il va être très fréquent de faire des vérifications avant d'exécuter une action. Par exemple, on peut vérifier si l'utilisateur est connecté et qu'il a les droits pour faire cette action.
Pour cela, on va utiliser next-safe-action
et créer un middleware qui va vérifier si l'utilisateur est connecté et qu'il a les droits pour faire cette 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 } });
});
Maintenant on va pouvoir créer une action et récupérer l'utilisateur actuel. On est sûr que cet utilisateur est l'utilisateur actuel grâce au middleware qu'on a créé avec "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');
}
// ...
});
Gestion des erreurs
Mais le problème qu'on a c'est que les Error
par défaut ne sont pas retournées au client pour des raisons de sécurité. Il va falloir qu'on définisse nous-mêmes les Erreurs
qu'on va vouloir retourner au client.
Pour ça on va utiliser la méthode handleServerError
:
// 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');
}
// ...
});
De cette manière, cette erreur sera retournée dans result?.serverError
quand l'action a échoué à cause de ces erreurs.
Récupérer le résultat de l'action
Le code pour récupérer le résultat de l'action peut-être compliqué et me pose beaucoup de problèmes.
J'ai créé un utilitaire qui permet de définir rapidement si l'action est réussie ou échouée :
/**
* 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;
};
Grâce à is
on vient dire :
Si cette méthode retourne
true
, dans ce cas on sait pertinemment quedata
est défini
On peut ensuite utiliser cette méthode de cette manière :
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');
};
On peut voir que c'est déjà plus propre et permet de connaître le résultat de l'action plus facilement.
Mais parfois on veut aller encore plus loin... par exemple si tu utilises Tanstack Query
pour venir gérer les requêtes au backend.
Ce qu'aime bien Tanstack Query
c'est d'avoir un promise, le truc c'est que les server-action retournées par next-safe-action
sont toujours en resolved
.
Pourtant, parfois on a envie qu'elles soient en rejected
dans le cas où l'action a échoué.
Pour ça j'ai créé cet utilitaire :
/**
* 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);
});
});
};
Cette méthode resolveActionResult
va venir transformer notre action
en un Promise
qui resolve
quand l'action fonctionne et qui au contraire reject
quand l'action a échoué.
On peut donc utiliser cette méthode de cette manière :
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');
},
});
C'est pas beau ?
Créer des middleware paramétrables
Imaginons que tes utilisateurs aient des permissions, qui soient sous forme de string dans un tableau permissions
, comment faire pour créer des actions qui vérifient automatiquement ces permissions ? Pour ça on va utiliser les metadata.
Voici un exemple de 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 } });
});
Ici on vient définir un schéma de metadata qui va contenir les permissions de l'utilisateur. On peut ensuite utiliser cette metadata dans le middleware pour vérifier si l'utilisateur a les droits pour faire cette action.
On peut ensuite créer des server-action sécurisées de cette manière :
// 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;
});
Ici on vient automatiquement vérifier que l'utilisateur est bien administrateur avant de faire l'action.
Je te conseille d'utiliser ces méthodes avec des permissions
qui contiendraient des string
avec les différents droits disponibles.
Conclusions
Je t'ai présenté tout ce que je savais sur cette librairie next-safe-action
qui peut vraiment changer ta manière de faire du NextJS
.
Je t'invite vraiment à en abuser et si tu es intéressé par vraiment maîtriser NextJS
je t'invite à t'inscrire à ma formation gratuite ici :
Le meilleur moyen d'apprendre NextJS !
Rejoins par développeurs, cette formation reçoit une note de 4.7 / 5 🚀
Reçois la formation gratuitement dans ta boîte mail :
On se retrouve là-bas.