codelynx.dev
🇫🇷🇬🇧

Retour 09/10/2024

Le Guide des Server Actions en NextJS

Écris par Melvyn Malherbe le 09/10/2024


Les Server Actions en NextJS sont devenues un des outils indispensables et controversés de NextJS. Elles te permettent de créer des applications rapidement et sans avoir besoin de t'occuper de faire des API Routes.

Je vais t'expliquer précisément :

  • C'est quoi un Server Action ?
  • Les moyens de l'utiliser dans ton application
  • Quelle bonne pratique utiliser pour tes server-actions

C'est quoi un Server Action ?

Les Server Actions sont des méthodes qui peuvent être appelées par ton front-end pour faire des mutations. Elles remplacent les API Routes qui étaient précédemment utilisées pour faire ça.

Avant il fallait :

  1. Créer une API Route
JS
// /api/users/create
export const POST = async (req: NextRequest) => {
  const body = await req.json();

  const createUser = await prisma.user.create({
    data: {
      name: body.name,
      email: body.email,
    },
  });

  return NextResponse.json(data);
};
  1. Faire un fetch côté client
JS
const createUser = async () => {
  const res = await fetch('/api/users/create', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: 'Melvyn',
      email: 'melvynx@gmail.com',
    }),
  });

  const data = await res.json();

  return data;
};

Avec les Server Actions, toute cette logique est cachée avec NextJS. Si tu es intéressé par son fonctionnement, tu peux voir mon autre article sur le sujet.

Les Server Actions vont nous permettre de faire des mutations ou des requêtes à notre backend par exemple :

  • modifier des données avec Prisma
  • lancer des services externes comme envoyer des messages via des webhooks Discord
  • faire des requêtes à des API externes avec des secrets

Tu peux le voir comme ceci :

L'avantage des Server-Actions, c'est qu'on n'a plus besoin de toute cette logique de "fetch" de données et il est possible de simplement créer un fichier qui commence par "use server" qui exporte des méthodes, puis appeler ces méthodes

JS
// /app/users/create-user.action.ts
"use server"

export const createUserAction = async (name: string, email: string) => {
  const createUser = await prisma.user.create({
    data: {
      name: name,
      email: email,
    },
  });

  return createUser;
};

Puis dans notre client on peut simplement appeler cette méthode :

JS
const createUser = async () => {
  const data = await createUserAction('Melvyn', 'melvynx@gmail.com');

  return data;
};

Toute la logique est gérée par NextJS qui va s'occuper de venir appeler cette méthode et de nous retourner le résultat.

Attention, il faut faire attention à beaucoup de choses car les Server Actions ne sont pas sécurisées. Je vais t'expliquer dans la suite les bonnes pratiques des Server Actions.

Comment créer des Server Actions ?

Il y a 3 méthodes pour ce faire, car les Server Actions peuvent aussi être créées directement dans des Server Components.

Méthode 1 : Dans un server-component avec action

Il est possible directement dans un server-component de créer des Server Actions avec action.

action est une prop que prend l'élément form et qui permet de faire des actions sur le formulaire en global.

TSX
// app/users/create/page.tsx
import { redirect } from "next/navigation";

// Ceci est un Server Component !
export default async function Page() {

  async function handleSubmit(formData: FormData) { 
    "use server"
    const name = formData.get('name') as string; 
    const email = formData.get('email') as string; 

    // Effectuer une mutation, par exemple, créer un utilisateur
    const createUser = await prisma.user.create({ 
      data: { 
        name, 
        email, 
      }, 
    }); 

    redirect("/users") 
  } 

  return (
    <form action={handleSubmit}>
      <label htmlFor="name">Nom</label>
      <input type="text" id="name" name="name" required />

      <label htmlFor="email">Email</label>
      <input type="email" id="email" name="email" required />

      <button type="submit">Créer un utilisateur</button>
    </form>
  );
}

Quand on fait ça, on vient sans code JavaScript côté client, gérer la création d'un utilisateur. Tu peux voir que pour définir la méthode handleSubmit comme une Server Action il y a 2 règles importantes :

  1. Il faut qu'elle soit async
  2. Il faut qu'elle commence par "use server"

Quand on fait ces deux choses, on vient automatiquement "exposer" cette méthode pour qu'elle puisse être appelée.

Cette méthode prend un formData qui va contenir les informations du formulaire.

Méthode 2 : Dans un server-component avec formAction

formAction est une autre méthode qui est disponible uniquement sur les boutons et qui permet de faire des server-action simplement lors d'un clic.

TSX
// app/users/page.tsx

import { revalidatePath } from 'next/cache';

export default async function Page() {
  const users = await prisma.user.findMany();

  async function handleButtonClick(id) { 
    "use server"
    
    await prisma.user.delete({ 
      where: { 
        id,
      }
    })

    revalidatePath("/users");
  }

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Email</th>
          <th>Action</th>
        </tr>
      </thead>
      <tbody>
        {users.map((user) => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
            <td>
              <form>
                <button formAction={async () => {
                  "use server"
                  await handleButtonClick(user.id);
                }}>
                  Create User
                </button>
              </form>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Ici tu peux voir dans cet exemple un peu plus compliqué que je viens gérer la suppression d'un utilisateur avec simplement un bouton. Pour que ça fonctionne il faut :

  • que le bouton soit dans un form
  • que le formAction soit appelé avec async
  • que toutes les méthodes appelées soient marquées en "use server"

Cet exemple est un bon exemple de comment utiliser les Server Actions directement dans nos Server Components pour faire des mutations.

Encore une fois, ce code n'est pas sécurisé. Les Server Actions sont "exposées" à notre front-end et dans l'exemple ci-dessus, un utilisateur mal intentionné pourrait supprimer tous les utilisateurs.

Finalement il faut noter qu'avec ces deux méthodes, il n'est pas possible de faire du code client. C'est-à-dire qu'on est obligé d'utiliser les redirections ou l'URL pour afficher des erreurs, mais il n'est pas possible de venir appeler une méthode comme alert ou autre dans nos Server Action.

Méthode 3 : Dans un client-component

Il est possible de marquer n'importe quelle méthode de ton application qui est dans un fichier comme une Server Action avec use server.

TSX
// app/users/create/user-create.action.ts
'use server'

export const createUserAction = async (name: string, email: string) => {
  const createUser = await prisma.user.create({
    data: {
      name: name,
      email: email,
    },
  });

  return createUser;
};

Cette méthode est maintenant disponible comme une Server Action et peut être appelée par n'importe quel client.

On pourrait ensuite côté client créer un formulaire et appeler cette méthode :

TSX
// app/users/create/page.tsx
'use client'

import { createUserAction } from './user-create.action';
import { useRouter } from 'next/navigation';

export default function Page() {
  const router = useRouter();
  
  const handleSubmit = async (formData: FormData) => {
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;

    const createUser = await createUserAction(name, email);

    alert("The user has been created");
    router.push("/users")
  };

  return (
    <form action={handleSubmit}>
      <label htmlFor="name">Nom</label>
      <input type="text" id="name" name="name" required />

      <label htmlFor="email">Email</label>
      <input type="email" id="email" name="email" required />

      <button type="submit">Créer un utilisateur</button>
    </form>
  );
}

Tu peux voir que maintenant on a une méthode handleSubmit qui est aussi appelée via la prop action mais attention, cette fois on est client side. C'est-à-dire que tout se passe côté client et pas côté serveur.

C'est pour ça que la méthode handleSubmit ne possède pas de "use server" et va tout simplement appeler createUserAction. Cette méthode sera appelée côté backend et le résultat sera retourné.

Ensuite, nous on vient afficher une alert (qui est disponible car on est côté client) et on redirige l'utilisateur sur la page /users.

Gestion des erreurs

Il faut savoir que tu ne peux pas throw d'erreur dans les Server Action puis récupérer l'information, de cette manière :

TSX
// action.tsx
"use server";

const createUserAction = async (name: string, email: string) => {
  // ...

  if (!createUser) {
    throw new Error("Error creating user");
  }

  return createUser;
};

// App.jsx
const handleSubmit = async (formData: FormData) => {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  try {
    const createUser = await createUserAction(name, email);
  } catch (error) {
    alert(error.message);
    return
  }

  alert("The user has been created");
  router.push("/users")
};

Ce code ne va pas fonctionner ! Parce que NextJS cache les erreurs en production et il part du principe que le throw new Error qu'on a fait ci-dessus est une erreur qui n'est pas définie.

Tu ne pourras pas récupérer cette erreur, ce qu'il faut faire à la place c'est de retourner un objet d'erreur :

TSX
// action.tsx
"use server";

const createUserAction = async (name: string, email: string) => {
  // ...

  if (!createUser) { 
    return { 
      error: "Error creating user", 
      status: "error", 
    } 
  } 

  return { 
    data: createUser, 
    status: "success", 
  } 
};

// App.jsx
const handleSubmit = async (formData: FormData) => {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  const result = await createUserAction(name, email);

  if (result.status === "error") { 
    alert(result.error); 
    return
  } 

  alert("The user has been created");
  router.push("/users")
};

On verra qu'avec ma librairie pour gérer les server-action il y a un moyen plus simple de faire.

Comment bien utiliser les Server Actions ?

Il faut savoir qu'à partir du moment où tu utilises une Server Action dans une page, elle vient être incluse dans le routing de NextJS et n'importe qui est capable de l'appeler.

En plus de ça, n'importe quel paramètre peut être donné à ta Server Action, TypeScript ne te protège de rien.

On va donc voir comment créer du "clean code" de Server Actions.

Finalement on va voir comment on peut utiliser une librairie pour tout simplifier.

1. Vérifier les droits de l'utilisateur.

Il n'y a pas de magie ou de secret, ton code est vulnérable. Pour résoudre ce problème il faut toujours vérifier si l'utilisateur a le droit de faire cette action. Par exemple :

TSX
// app/users/create/user-create.action.ts
'use server'

import { getCurrentUser } from '~/lib/auth';

export const createUserAction = async (name: string, email: string) => {
  const currentUser = await getCurrentUser(); 

  if (!currentUser.isAdmin) { 
    return { 
      error: "You are not allowed to create a user", 
    } 
  } 
  
  const createUser = await prisma.user.create({
    data: {
      name: name,
      email: email,
    },
  });

  return createUser;
};

Un autre exemple serait dans le cas d'une modification, il faut toujours venir vérifier si l'utilisateur a les droits pour faire cette action.

TSX
// app/users/update/user-update.action.ts
'use server'

import { getCurrentUser } from '~/lib/auth';

export const updateUserAction = async (id: string, name: string, email: string) => {
  const currentUser = await getCurrentUser(); 

  if (currentUser.id !== id) {   
    return { 
      error: "You are not allowed to update this user", 
    } 
  } 

  await prisma.user.update({
    where: {
      id,
    },
    data: {
      name: name,
      email: email,
    },
  });

  return {
    success: "User updated",
  };
};

Tu peux voir qu'ici on vérifie bien les identifiants.

2. Vérifier les props

La deuxième chose c'est de vérifier si les props sont corrects. Tu ne peux jamais être sûr que les props ont correctement été passées à ta méthode.

Pour le vérifier, on va utiliser zod :

TSX
// app/users/create/user-create.action.ts
'use server'

import { z } from 'zod';

export const createUserAction = async (name: string, email: string) => {
  const currentUser = await getCurrentUser();

  if (!currentUser.isAdmin) {
    return {
      error: "You are not allowed to create a user",
    }
  }

  // On vérifie que les props sont corrects
  const schema = z.object({ 
    name: z.string().min(3), 
    email: z.string().email(), 
  }); 

  const body = { name, email } 

  const safeData = schema.tryParse(body); 

  if (!safeData.success) { 
    return { 
      error: "Invalid data", 
    }
  }

  const data = safeData.data;

  const createUser = await prisma.user.create({
    data: {
      name: data.name,
      email: data.email,
    },
  });

  return createUser;
};

Tu peux voir qu'ici je viens vérifier tous les paramètres avec Zod.

3. La solution ultime : next-safe-action

next-safe-action est une librairie qui te permet de facilement :

  1. récupérer et vérifier que l'utilisateur est connecté et qu'il a le droit de faire cette action
  2. vérifier si les props sont corrects

Tu peux commencer par installer next-safe-action :

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();

Maintenant tu peux utiliser actionClient pour vérifier les paramètres de cette manière :

TSX
import { actionClient } from "~/lib/action";
import { z } from "zod";
import { getCurrentUser } from "~/lib/auth";

const Schema = z.object({
  name: z.string().min(3),
  email: z.string().email(),
});

const createUserAction = actionClient
  .schema(Schema)
  .action(async ({ parsedInput: { name, email } }) => {
    const currentUser = await getCurrentUser();

    if (!currentUser.isAdmin) {
      return {
        error: "You are not allowed to create a user",
      };
    }

    const createUser = await prisma.user.create({
      data: {
        name: name,
        email: email,
      },
    });

    return createUser;
  });

Mais on peut aller plus loin pour créer des sortes de "middleware" qui vont automatiquement vérifier que l'utilisateur est connecté et venir injecter le user dans l'action.

Pour ça on va créer une méthode authActionClient :

TSX
import { createSafeActionClient } from "next-safe-action";

export const authActionClient = createSafeActionClient();

export const authActionClient = authActionClient.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 } });
});

Puis on va pouvoir l'utiliser comme ça :

TSX
import { authActionClient } from "~/lib/action";

// ... code précédent ...

const createUserAction = authActionClient
  .schema(Schema)
  .action(async ({ parsedInput: { name, email }, ctx: { user } }) => {
    if (!user.isAdmin) { 
      throw new Error("You are not allowed to create a user");
    }

    const createUser = await prisma.user.create({
      data: {
        name: name,
        email: email,
      },
    });

    return createUser;
  });

Maintenant tu te demandes peut-être pourquoi j'utilise throw ici et que ça fonctionne ? C'est parce que next-safe-action vient automatiquement gérer les erreurs pour nous. Quand on utilise les méthodes, il faut faire comme ceci :

TSX
const handleSubmit = async (formData: FormData) => {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  const result = await createUserAction(name, email); 

  if (result?.data) { 
    alert("The user has been created");
    router.push("/users")
    return
  }

  alert(result?.serverError ?? "Error creating user");
};

Car maintenant nos server-actions retournent 5 valeurs :

  • data : Si la requête est réussie
  • serverError : Si la requête a échoué, il nous donne l'erreur
  • validationErrors : Si les données passées ne sont pas valides

Il faut savoir que les erreurs seront cachées par défaut, si tu veux pouvoir "retourner" des erreurs il va falloir rajouter handleServerError :

TS
import { createSafeActionClient } from "next-safe-action";

class ActionError extends Error { 
  constructor(message: string) { 
    super(message); 
  } 
} 

export const authActionClient = createSafeActionClient({
  handleServerError: (error) => { 
    if (error instanceof ActionError) { 
      return error.message; 
    } 

    return "Oh no, generic error";
  }
});

export const authActionClient = authActionClient.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 } });
});

De cette manière, on peut maintenant récupérer les erreurs mais uniquement celles qu'on a volontairement remontées au client !

Conclusion

Les Server Actions sont un outil puissant à utiliser en NextJS mais malheureusement par défaut, il leur manque beaucoup de fonctionnalités et de sécurité.

Dans cet article, je t'ai montré comment mieux sécuriser tes Server Actions afin d'avoir une application plus sécurisée et robuste en utilisant next-safe-action et zod.

Si tu souhaites vraiment maîtriser NextJS je t'invite à t'inscrire à mon cours gratuit 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 :

NextReact

Cours NextJS gratuit

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