Les Server Actions de Next.js 15 sont une révolution. Mais il y a un problème majeur : la plupart des tutoriels vous montrent comment les utiliser, pas comment les sécuriser.
Après avoir déployé 3 applications production avec Server Actions (dont mon propre portfolio avec formulaire de contact), j'ai appris à mes dépens que chaque Server Action est un endpoint HTTP public. Oui, public. Accessible par n'importe qui.
Voici le guide complet que j'aurais aimé avoir il y a 6 mois.
Pourquoi la sécurité des Server Actions est critique
Le mythe de la "sécurité par obscurcissement"
Next.js 15 génère des IDs non-déterministes pour vos Server Actions. Beaucoup pensent que c'est suffisant pour la sécurité.
C'est faux.
// Votre Server Action
'use server'
export async function deleteUser(userId: string) {
await db.user.delete({ where: { id: userId } })
}
Ce que Next.js fait : Génère un ID aléatoire comme a1b2c3d4e5
Le problème : Cet ID est visible dans :
- Le code source client (bundle JS)
- Les DevTools Network
- Les requêtes HTTP interceptées
⚠️ Règle d'or : Traitez chaque Server Action comme un endpoint API public. Point final.
Pattern #1 : Validation avec Zod (obligatoire)
Pourquoi Zod et pas juste TypeScript ?
TypeScript disparaît à la compilation. Zod valide au runtime.
// ❌ DANGEREUX - TypeScript seul
'use server'
export async function createPost(title: string, content: string) {
// Aucune validation runtime !
await db.post.create({ data: { title, content } })
}
// ✅ SÉCURISÉ - Avec Zod
'use server'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10).max(5000),
category: z.enum(['tech', 'design', 'tutorial']),
})
export async function createPost(formData: FormData) {
// 1. Parser les données brutes
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
category: formData.get('category'),
}
// 2. Valider avec safeParse (ne throw pas)
const validation = CreatePostSchema.safeParse(rawData)
if (!validation.success) {
return {
success: false,
errors: validation.error.flatten().fieldErrors,
}
}
// 3. Données validées et typées !
const { title, content, category } = validation.data
await db.post.create({ data: { title, content, category } })
return { success: true }
}
Pattern avancé : next-safe-action
Pour les projets complexes, utilisez next-safe-action :
npm install next-safe-action zod
// lib/safe-actions.ts
import { createSafeActionClient } from "next-safe-action"
export const actionClient = createSafeActionClient()
// actions/posts.ts
'use server'
import { actionClient } from "@/lib/safe-actions"
import { z } from "zod"
const createPostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10).max(5000),
})
export const createPostAction = actionClient
.schema(createPostSchema)
.action(async ({ parsedInput: { title, content } }) => {
// parsedInput est déjà validé et typé !
await db.post.create({ data: { title, content } })
return { success: true }
})
Avantages :
- ✅ Validation automatique
- ✅ Types TypeScript inférés
- ✅ Gestion d'erreurs intégrée
- ✅ Middleware support (auth, logging...)
📚 Ressource : next-safe-action.dev
Pattern #2 : Gestion d'erreurs avec useActionState
L'approche Next.js 15 officielle
// actions/contact.ts
'use server'
import { z } from 'zod'
const ContactSchema = z.object({
email: z.string().email('Email invalide'),
message: z.string().min(10, 'Message trop court'),
})
type ActionState = {
success: boolean
message?: string
errors?: Record<string, string[]>
}
export async function sendContactAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// Validation
const validation = ContactSchema.safeParse({
email: formData.get('email'),
message: formData.get('message'),
})
if (!validation.success) {
return {
success: false,
errors: validation.error.flatten().fieldErrors,
}
}
try {
// Envoi email (nodemailer, resend, etc.)
await sendEmail(validation.data)
return {
success: true,
message: 'Message envoyé avec succès !',
}
} catch (error) {
console.error('Contact form error:', error)
return {
success: false,
message: 'Erreur lors de l'envoi. Réessayez plus tard.',
}
}
}
Côté client avec useActionState
'use client'
import { useActionState } from 'react'
import { sendContactAction } from '@/actions/contact'
export function ContactForm() {
const [state, formAction, isPending] = useActionState(
sendContactAction,
{ success: false }
)
return (
<form action={formAction}>
<input
type="email"
name="email"
required
/>
{state.errors?.email && (
<p className="text-red-500">{state.errors.email[0]}</p>
)}
<textarea name="message" required />
{state.errors?.message && (
<p className="text-red-500">{state.errors.message[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Envoi...' : 'Envoyer'}
</button>
{state.success && (
<p className="text-green-500">{state.message}</p>
)}
</form>
)
}
Avantages de useActionState :
- ✅ État pending automatique
- ✅ Pas de useState/useEffect manuel
- ✅ Progressive enhancement (fonctionne sans JS)
- ✅ Retours d'erreurs structurés
⚠️ Important : N'utilisez jamais
throwpour les erreurs attendues. Retournez toujours un objet.
Pattern #3 : Rate Limiting avec Redis
Pourquoi le rate limiting est essentiel
Sans rate limiting, un attaquant peut :
- Spammer votre formulaire de contact (comme le mien !)
- Surcharger votre base de données
- Générer des coûts d'API (emails, SMS, etc.)
Installation
npm install @upstash/ratelimit @upstash/redis
Configuration Upstash Redis
- Créez un compte sur Upstash
- Créez une base Redis
- Copiez les variables d'environnement
# .env.local
UPSTASH_REDIS_REST_URL="https://..."
UPSTASH_REDIS_REST_TOKEN="..."
Implémentation
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"
// Créer l'instance Redis
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
// Sliding window : 5 requêtes par 60 secondes
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "60 s"),
analytics: true, // Stats dans Upstash dashboard
})
Utilisation dans Server Action
// actions/contact.ts
'use server'
import { ratelimit } from "@/lib/rate-limit"
import { headers } from "next/headers"
export async function sendContactAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// 1. Récupérer l'IP
const headersList = await headers()
const ip = headersList.get("x-forwarded-for") || "unknown"
// 2. Vérifier le rate limit
const { success: rateLimitOk } = await ratelimit.limit(ip)
if (!rateLimitOk) {
return {
success: false,
message: "Trop de tentatives. Réessayez dans 1 minute.",
}
}
// 3. Continuer normalement
// ... validation, envoi email, etc.
}
Stratégies de rate limiting
| Stratégie | Usage | Exemple |
|---|---|---|
| Fixed Window | Simple, basique | 10 req/minute |
| Sliding Window | Précis, recommandé | 5 req/60s glissantes |
| Token Bucket | Burst autorisé | 20 tokens, +1/sec |
📚 Ressource : Upstash Rate Limiting Guide
Pattern #4 : Authentification (souvent oubliée)
Le piège classique
// ❌ VULNÉRABLE
'use server'
export async function deletePost(postId: string) {
// Aucune vérification d'authentification !
await db.post.delete({ where: { id: postId } })
}
N'importe qui peut appeler cette fonction depuis la console :
// Dans la console navigateur
fetch('/api/...')
.then(res => res.json())
.then(data => console.log('Post supprimé !'))
Solution : Toujours vérifier la session
// ✅ SÉCURISÉ
'use server'
import { auth } from "@/lib/auth" // NextAuth, Clerk, etc.
export async function deletePost(postId: string) {
// 1. Vérifier l'authentification
const session = await auth()
if (!session?.user) {
return { success: false, error: 'Non authentifié' }
}
// 2. Vérifier l'autorisation (ownership)
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true },
})
if (post?.authorId !== session.user.id) {
return { success: false, error: 'Non autorisé' }
}
// 3. Effectuer l'action
await db.post.delete({ where: { id: postId } })
return { success: true }
}
Authentification ≠ Autorisation :
- Authentification : "Qui êtes-vous ?"
- Autorisation : "Pouvez-vous faire ça ?"
⚠️ Toujours vérifier les deux dans les Server Actions sensibles.
Pattern #5 : Revalidation intelligente
Le problème du cache
Next.js met en cache agressivement. Après une mutation, vos données peuvent être obsolètes.
// actions/posts.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function createPost(data: PostData) {
const newPost = await db.post.create({ data })
// Méthode 1 : Revalider un path spécifique
revalidatePath('/blog')
// Méthode 2 : Revalider par tag (recommandé)
revalidateTag('posts')
return { success: true, post: newPost }
}
Stratégie de tags (avancée)
// lib/cache.ts
export const CacheTags = {
posts: 'posts',
postDetail: (id: string) => \`post-\${id}\`,
user: (id: string) => \`user-\${id}\`,
}
// Dans votre fetch
export async function getPost(id: string) {
return fetch(\`/api/posts/\${id}\`, {
next: {
tags: [CacheTags.posts, CacheTags.postDetail(id)],
},
})
}
// Dans votre Server Action
export async function updatePost(id: string, data: PostData) {
await db.post.update({ where: { id }, data })
revalidateTag(CacheTags.posts)
revalidateTag(CacheTags.postDetail(id))
}
Pattern #6 : Logging et monitoring
Pourquoi c'est crucial en production
Quand un Server Action échoue, vous devez savoir :
- Quelle action a échoué
- Pour quel utilisateur
- Avec quelles données
- À quelle heure
// lib/logger.ts
export function logServerAction(
action: string,
userId: string | null,
data: unknown,
success: boolean,
error?: unknown
) {
console.log({
timestamp: new Date().toISOString(),
action,
userId,
data: JSON.stringify(data),
success,
error: error ? String(error) : undefined,
})
// En production, envoyez à Sentry, LogRocket, etc.
}
// actions/posts.ts
export async function createPost(data: PostData) {
try {
const post = await db.post.create({ data })
logServerAction('createPost', session.user.id, data, true)
return { success: true, post }
} catch (error) {
logServerAction('createPost', session.user.id, data, false, error)
return {
success: false,
error: 'Erreur lors de la création',
}
}
}
Pattern #7 : Tests des Server Actions
Pourquoi tester ?
Les Server Actions sont du code serveur. Ils doivent être testés comme des API endpoints.
// __tests__/actions/posts.test.ts
import { createPost } from '@/actions/posts'
import { prismaMock } from '@/lib/prisma-mock'
describe('createPost Server Action', () => {
it('devrait créer un post avec des données valides', async () => {
const mockPost = { id: '1', title: 'Test', content: 'Content' }
prismaMock.post.create.mockResolvedValue(mockPost)
const result = await createPost({
title: 'Test',
content: 'Content',
})
expect(result.success).toBe(true)
expect(result.post).toEqual(mockPost)
})
it('devrait rejeter des données invalides', async () => {
const result = await createPost({
title: '', // Titre vide
content: 'Content',
})
expect(result.success).toBe(false)
expect(result.errors?.title).toBeDefined()
})
})
Mon retour d'expérience production
Ce que j'ai appris sur mon portfolio
Mon formulaire de contact utilise :
- ✅ Validation Zod côté serveur
- ✅ Rate limiting Redis (5 messages/heure/IP)
- ✅ Nodemailer pour l'envoi
- ✅ useActionState pour l'UX
Résultat : 0 spam en 3 mois, 100% de messages légitimes.
Erreurs que j'ai faites (pour que vous les évitiez)
- Oublier le rate limiting → Spam massif le premier jour
- Throw des erreurs au lieu de les retourner → Mauvaise UX
- Ne pas logger → Impossible de debug en production
- Validation uniquement côté client → Données corrompues en DB
Checklist sécurité Server Actions
Avant de déployer, vérifiez :
- Validation Zod sur toutes les entrées
- Rate limiting sur les actions sensibles
- Authentification vérifiée côté serveur
- Autorisation (ownership) vérifiée
- Erreurs retournées, pas throw
- useActionState pour l'UX
- Revalidation après mutations
- Logging des actions critiques
- Tests unitaires écrits
- Variables d'environnement sécurisées
Ressources essentielles
Documentation officielle
Outils recommandés
- 🛡️ next-safe-action - Type-safe Server Actions
- ⚡ Upstash Rate Limiting
- ✅ Zod - Validation TypeScript-first
- 📧 Resend - Emails transactionnels
Articles complémentaires
Conclusion : La sécurité n'est pas optionnelle
Les Server Actions sont puissants, mais dangereux si mal utilisés.
Les 3 règles d'or :
- 🔐 Validez tout côté serveur (Zod)
- ⏱️ Limitez les appels (Rate limiting)
- 🛡️ Vérifiez auth + autorisation
En suivant ces 7 patterns, vos Server Actions seront production-ready, sécurisées et performantes.
Mon conseil final : Commencez simple. Ajoutez la sécurité dès le début. C'est beaucoup plus facile que de la rajouter après coup.
Les Server Actions ne sont pas qu'une nouvelle syntaxe - c'est un nouveau paradigme. Apprenez à les sécuriser correctement, et vous aurez un avantage énorme sur 80% des développeurs Next.js.
Prêt à sécuriser vos Server Actions ? Commencez par Zod et rate limiting. Le reste suivra naturellement. 🚀
