codelynx.dev
🇫🇷🇬🇧

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 :

BASH
pnpm add next-safe-action zod

Puis créer un fichier lib/action.ts :

TSX
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 :

TSX
// 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 :

TSX
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.

TSX
// 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" :

TSX
// 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 :

TSX
// 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 :

TSX
/**
 * 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 que data est défini

On peut ensuite utiliser cette méthode de cette manière :

TSX
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 :

TS
/**
 * 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 :

TSX
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 :

TSX
// 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 :

TSX
// 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.

NextReact

Cours NextJS gratuit

Accède à des exercices, des vidéos et bien plus sur NextJS dans la formation "NextReact" 👇