Retour • 11/10/2024
Le Guide des Server Actions en NextJS
Écris par Melvyn Malherbe le 11/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 :
- Créer une API Route
// /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);
};
- Faire un fetch côté client
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
// /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 :
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.
// 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 :
- Il faut qu'elle soit
async
- 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.
// 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);
}}
>
// [!code highlight] 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 unform
- que le
formAction
soit appelé avecasync
- 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
.
// 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 :
// 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 :
// 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 :
// 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 :
// 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.
// 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
:
// 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 :
- récupérer et vérifier que l'utilisateur est connecté et qu'il a le droit de faire cette action
- vérifier si les props sont corrects
Tu peux commencer par installer next-safe-action
:
pnpm add next-safe-action zod
Puis créer un fichier lib/action.ts
:
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 :
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
:
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 :
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 :
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éussieserverError
: Si la requête a échoué, il nous donne l'erreurvalidationErrors
: 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
:
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 :