Server Actions in Next.js: Validazione, Sicurezza e Pattern Avanzati

Le Server Actions di Next.js eliminano la complessità degli endpoint API manuali. Validazione con Zod, sicurezza, useActionState, aggiornamenti ottimistici e pattern avanzati per app production-ready.

Introduzione: Perché le Server Actions Cambiano Tutto

Se avete letto la nostra guida sullo streaming e il Partial Prerendering, sapete già come Next.js ha rivoluzionato il modo in cui i dati vengono letti e presentati all'utente. Ma leggere i dati è solo metà dell'equazione. L'altra metà — e onestamente, quella più insidiosa — riguarda come i dati vengono scritti, modificati e cancellati. Ed è qui che entrano in gioco le Server Actions.

Prima dell'App Router, ogni mutazione era un piccolo calvario. Creare un endpoint API nella cartella /api, gestire manualmente le richieste HTTP, parsare il body, validare, gestire gli errori... e poi lato client orchestrare il fetch, lo stato di caricamento e l'aggiornamento dell'interfaccia. Un'operazione concettualmente semplice come "salva un commento" poteva facilmente richiedere 80-100 righe di codice distribuite su tre file diversi. Roba da far venire il mal di testa.

Le Server Actions spazzano via tutta questa complessità. Sono funzioni asincrone che girano sul server e possono essere invocate direttamente dai componenti React — sia Server che Client. Niente endpoint manuali, niente fetch espliciti, niente parsing del body. Si scrive una funzione con la direttiva "use server", la si collega a un form o a un evento, e Next.js si occupa di tutto il resto: serializzazione, trasporto, deserializzazione, gestione degli errori e aggiornamento della cache.

Ma attenzione, perché c'è un "ma" importante: le Server Actions sono endpoint HTTP pubblici. Chiunque può trovare i loro identificativi nel bundle JavaScript e invocarle con una semplice richiesta POST. Questo significa che ogni Server Action va trattata esattamente come un'API pubblica, con validazione, autenticazione, autorizzazione e rate limiting. Ne parleremo in dettaglio più avanti.

Allora, partiamo dall'inizio. In questa guida vedremo ogni aspetto delle Server Actions: dalla sintassi di base ai pattern avanzati, dalla validazione con Zod alla sicurezza, dagli aggiornamenti ottimistici alla revalidazione della cache. Il tutto con esempi pratici e pronti per la produzione.

Anatomia di una Server Action: Cosa Succede Sotto il Cofano

Quando definite una funzione con la direttiva "use server", Next.js la compila in un endpoint POST con un identificativo univoco. Sul client, la referenza alla funzione viene sostituita con un proxy che, quando invocato, effettua una richiesta POST al server con gli argomenti serializzati. Il server deserializza gli argomenti, esegue la funzione e restituisce il risultato. Semplice ed elegante.

Ci sono due modi per definire le Server Actions.

Definizione in un File Dedicato

Il modo più pulito (e quello che vi consiglio) è creare un file separato con la direttiva "use server" in cima. Tutte le funzioni esportate da quel file diventano automaticamente Server Actions:

// app/actions/comments.ts
'use server'

import { revalidatePath } from 'next/cache'
import { db } from '@/lib/database'

export async function createComment(formData: FormData) {
  const content = formData.get('content') as string
  const postId = formData.get('postId') as string

  await db.comment.create({
    data: { content, postId }
  })

  revalidatePath(`/blog/${postId}`)
}

export async function deleteComment(commentId: string) {
  await db.comment.delete({ where: { id: commentId } })
  revalidatePath('/blog')
}

Definizione Inline in un Server Component

Potete anche definire una Server Action direttamente dentro un Server Component, aggiungendo "use server" nel corpo della funzione. Questo crea una closure che cattura le variabili dell'ambito esterno — e qui le cose si fanno interessanti:

// app/blog/[id]/page.tsx
export default async function BlogPost({ params }: { params: { id: string } }) {
  const post = await fetchPost(params.id)

  async function likePost() {
    'use server'
    await db.post.update({
      where: { id: params.id },
      data: { likes: { increment: 1 } }
    })
    revalidatePath(`/blog/${params.id}`)
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <form action={likePost}>
        <button type="submit">Mi piace ({post.likes})</button>
      </form>
    </article>
  )
}

Un dettaglio che molti trascurano sulle closure: le variabili catturate vengono serializzate, inviate al client e poi rispedite al server quando l'azione viene invocata. Next.js le cripta con una chiave privata generata ad ogni build, ma è comunque buona pratica non catturare mai dati sensibili. Usate solo valori che l'utente già conosce, come gli ID presenti nell'URL.

Gestione dei Form: useActionState e useFormStatus

React 19 ha introdotto due hook fondamentali per lavorare con i form e le Server Actions: useActionState e useFormStatus. Insieme permettono di costruire form che funzionano anche senza JavaScript (progressive enhancement), gestiscono gli stati di caricamento e mostrano errori di validazione in modo pulito.

useActionState: Gestire Stato e Risposte del Server

useActionState (che sostituisce il deprecato useFormState) restituisce tre valori: lo stato corrente, una funzione dispatch da passare al form, e un flag isPending. Lo stato iniziale viene specificato come secondo argomento e si aggiorna con il valore di ritorno della Server Action dopo ogni invocazione.

Vediamo un esempio concreto con un form di registrazione:

// app/actions/auth.ts
'use server'

import { z } from 'zod'

const SignupSchema = z.object({
  name: z.string().min(2, 'Il nome deve avere almeno 2 caratteri'),
  email: z.string().email('Inserisci un indirizzo email valido'),
  password: z.string().min(8, 'La password deve avere almeno 8 caratteri')
    .regex(/[A-Z]/, 'La password deve contenere almeno una lettera maiuscola')
    .regex(/[0-9]/, 'La password deve contenere almeno un numero'),
})

export type SignupState = {
  errors?: {
    name?: string[]
    email?: string[]
    password?: string[]
  }
  message?: string
  success?: boolean
}

export async function signup(
  prevState: SignupState,
  formData: FormData
): Promise<SignupState> {
  const validated = SignupSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
      message: 'Controlla i campi evidenziati.',
    }
  }

  try {
    await createUser(validated.data)
    return { success: true, message: 'Account creato con successo!' }
  } catch (error) {
    return { message: 'Errore durante la registrazione. Riprova più tardi.' }
  }
}
// components/SignupForm.tsx
'use client'

import { useActionState } from 'react'
import { signup, type SignupState } from '@/app/actions/auth'
import { SubmitButton } from './SubmitButton'

const initialState: SignupState = {}

export function SignupForm() {
  const [state, formAction, isPending] = useActionState(signup, initialState)

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name">Nome</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          className="w-full border rounded p-2"
        />
        {state.errors?.name && (
          <p className="text-red-500 text-sm mt-1" aria-live="polite">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="w-full border rounded p-2"
        />
        {state.errors?.email && (
          <p className="text-red-500 text-sm mt-1" aria-live="polite">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          required
          className="w-full border rounded p-2"
        />
        {state.errors?.password && (
          <p className="text-red-500 text-sm mt-1" aria-live="polite">
            {state.errors.password[0]}
          </p>
        )}
      </div>

      <SubmitButton />

      {state.message && (
        <p className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      )}
    </form>
  )
}

useFormStatus: Indicatori di Caricamento

useFormStatus fornisce lo stato di pending del form genitore più vicino. C'è un caveat importante: deve essere usato in un componente figlio del form, non nello stesso componente che contiene il tag <form>.

// components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full bg-blue-600 text-white py-2 rounded
                 disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {pending ? 'Invio in corso...' : 'Registrati'}
    </button>
  )
}

Perché serve un componente separato? Perché useFormStatus legge lo stato del form dal contesto React. Se lo usaste nello stesso componente del form, non avrebbe un form genitore da cui leggere. Può sembrare scomodo all'inizio, ma in realtà questo pattern promuove la composizione e il riutilizzo — quel pulsante lo potete usare in qualsiasi form della vostra app.

Validazione Robusta con Zod: Lo Schema come Unica Fonte di Verità

TypeScript vi dà sicurezza a tempo di compilazione, ma i tipi scompaiono a runtime. E questo è un problema serio. Quando un utente (o peggio, un attaccante) invia dati al server, TypeScript non può proteggervi. Un campo annotato come string potrebbe ricevere un oggetto, un array, o qualsiasi altro dato malevolo.

Per questo serve la validazione a runtime, e Zod è lo strumento perfetto per il job.

Il pattern più efficace prevede un singolo file di schema condiviso tra client e server:

// lib/schemas/product.ts
import { z } from 'zod'

export const ProductSchema = z.object({
  name: z.string()
    .min(3, 'Il nome del prodotto deve avere almeno 3 caratteri')
    .max(100, 'Il nome del prodotto non può superare i 100 caratteri'),
  description: z.string()
    .min(10, 'La descrizione deve avere almeno 10 caratteri')
    .max(2000, 'La descrizione non può superare i 2000 caratteri'),
  price: z.coerce.number()
    .positive('Il prezzo deve essere positivo')
    .max(99999.99, 'Il prezzo massimo è 99.999,99€'),
  category: z.enum(['electronics', 'clothing', 'books', 'home'], {
    errorMap: () => ({ message: 'Seleziona una categoria valida' })
  }),
  inStock: z.coerce.boolean().default(true),
})

// Il tipo TypeScript viene inferito dallo schema
export type ProductInput = z.infer<typeof ProductSchema>

Questo schema viene usato sia nel componente client per la validazione in tempo reale, sia nella Server Action per la validazione definitiva lato server. Un'unica definizione, zero duplicazione, tipo TypeScript inferito automaticamente. Elegante, no?

// app/actions/products.ts
'use server'

import { ProductSchema } from '@/lib/schemas/product'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createProduct(prevState: any, formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    description: formData.get('description'),
    price: formData.get('price'),
    category: formData.get('category'),
    inStock: formData.get('inStock'),
  }

  const validated = ProductSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
      message: 'Errore di validazione',
    }
  }

  try {
    const product = await db.product.create({ data: validated.data })
    revalidatePath('/products')
    redirect(`/products/${product.id}`)
  } catch (error) {
    return { message: 'Impossibile creare il prodotto. Riprova.' }
  }
}

Notate un dettaglio cruciale in questo codice: redirect() viene chiamato fuori dal blocco try/catch. Perché? Perché redirect() internamente lancia un'eccezione speciale che Next.js intercetta per effettuare la navigazione. Se fosse dentro il try/catch, verrebbe catturata dal catch e la navigazione non avverrebbe mai. È un errore che ho visto fare anche a sviluppatori esperti.

next-safe-action: Type Safety End-to-End con Middleware Componibile

Per applicazioni di produzione, la libreria next-safe-action porta la gestione delle Server Actions a un livello superiore. L'idea è semplice: invece di ripetere la logica di validazione e autenticazione in ogni azione, si definisce un "action client" con middleware componibile — un po' come Express usa i middleware per le route.

// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { getSession } from '@/lib/auth'

// Client base: validazione automatica, senza autenticazione
export const actionClient = createSafeActionClient({
  handleServerError(error) {
    console.error('Action error:', error.message)
    return 'Si è verificato un errore. Riprova più tardi.'
  },
})

// Client autenticato: richiede una sessione valida
export const authActionClient = actionClient.use(async ({ next }) => {
  const session = await getSession()

  if (!session?.user) {
    throw new Error('Non autorizzato')
  }

  return next({ ctx: { user: session.user } })
})

// Client admin: richiede ruolo admin
export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
  if (ctx.user.role !== 'admin') {
    throw new Error('Accesso negato')
  }

  return next({ ctx })
})

Vedete come i middleware si compongono? Il client admin eredita automaticamente il check di autenticazione dal client auth. Questo tipo di composizione è incredibilmente potente per applicazioni complesse.

// app/actions/products.ts
'use server'

import { authActionClient } from '@/lib/safe-action'
import { ProductSchema } from '@/lib/schemas/product'
import { revalidatePath } from 'next/cache'

export const createProduct = authActionClient
  .schema(ProductSchema)
  .action(async ({ parsedInput, ctx }) => {
    // parsedInput è già validato e tipizzato
    // ctx.user è garantito dal middleware di autenticazione
    const product = await db.product.create({
      data: {
        ...parsedInput,
        createdBy: ctx.user.id,
      },
    })

    revalidatePath('/products')
    return { product }
  })
// components/CreateProductForm.tsx
'use client'

import { useAction } from 'next-safe-action/hooks'
import { createProduct } from '@/app/actions/products'

export function CreateProductForm() {
  const { execute, result, isExecuting } = useAction(createProduct)

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const formData = new FormData(e.currentTarget)
      execute({
        name: formData.get('name') as string,
        description: formData.get('description') as string,
        price: Number(formData.get('price')),
        category: formData.get('category') as any,
        inStock: formData.get('inStock') === 'on',
      })
    }}>
      {/* campi del form */}

      {result.serverError && (
        <p className="text-red-600">{result.serverError}</p>
      )}

      {result.validationErrors && (
        <ul className="text-red-600">
          {Object.entries(result.validationErrors).map(([field, errors]) => (
            <li key={field}>{field}: {errors?.join(', ')}</li>
          ))}
        </ul>
      )}

      <button disabled={isExecuting}>
        {isExecuting ? 'Creazione in corso...' : 'Crea Prodotto'}
      </button>
    </form>
  )
}

I vantaggi sono evidenti: validazione e autenticazione definite una volta sola, tipi che fluiscono automaticamente dal server al client, errori di validazione strutturati e tipizzati. Se state costruendo qualcosa di serio, vale la pena adottare questo pattern fin da subito.

Aggiornamenti Ottimistici con useOptimistic

C'è una cosa che gli utenti odiano: aspettare. Clicco "Mi piace" e devo stare lì a guardare uno spinner? No grazie. Gli aggiornamenti ottimistici risolvono proprio questo: l'interfaccia si aggiorna immediatamente come se l'operazione fosse già riuscita, e nel caso (improbabile) di errore lo stato viene ripristinato.

React 19 fornisce l'hook useOptimistic per questo scopo:

// components/TodoList.tsx
'use client'

import { useOptimistic } from 'react'
import { addTodo, toggleTodo, deleteTodo } from '@/app/actions/todos'

type Todo = {
  id: string
  text: string
  completed: boolean
}

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, updateOptimisticTodos] = useOptimistic(
    todos,
    (state: Todo[], action: { type: string; payload: any }) => {
      switch (action.type) {
        case 'add':
          return [...state, {
            id: `temp-${Date.now()}`,
            text: action.payload,
            completed: false
          }]
        case 'toggle':
          return state.map(todo =>
            todo.id === action.payload
              ? { ...todo, completed: !todo.completed }
              : todo
          )
        case 'delete':
          return state.filter(todo => todo.id !== action.payload)
        default:
          return state
      }
    }
  )

  return (
    <div>
      <form action={async (formData) => {
        const text = formData.get('text') as string
        updateOptimisticTodos({ type: 'add', payload: text })
        await addTodo(text)
      }}>
        <input name="text" placeholder="Nuovo task..." required />
        <button type="submit">Aggiungi</button>
      </form>

      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} className="flex items-center gap-2">
            <form action={async () => {
              updateOptimisticTodos({ type: 'toggle', payload: todo.id })
              await toggleTodo(todo.id)
            }}>
              <button type="submit">
                {todo.completed ? '✓' : '○'}
              </button>
            </form>
            <span className={todo.completed ? 'line-through' : ''}>
              {todo.text}
            </span>
            <form action={async () => {
              updateOptimisticTodos({ type: 'delete', payload: todo.id })
              await deleteTodo(todo.id)
            }}>
              <button type="submit" className="text-red-500">×</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  )
}

Il bello di useOptimistic è che aggiorna lo stato locale istantaneamente, poi la Server Action gira in background. Quando la pagina viene revalidata con i dati reali dal server, lo stato ottimistico viene sostituito automaticamente. E se l'azione fallisce? React ripristina lo stato precedente senza che dobbiate scrivere una sola riga di codice per il rollback. Davvero ben fatto.

Revalidazione della Cache: revalidatePath, revalidateTag e updateTag

Dopo una mutazione, i dati nella cache non corrispondono più alla realtà. Next.js offre tre primitive per invalidarla, ciascuna con un ambito diverso.

revalidatePath: Invalidazione per Percorso

Invalida la cache per uno specifico percorso URL. La scelta giusta quando sapete esattamente quale pagina mostra i dati modificati:

'use server'

import { revalidatePath } from 'next/cache'

export async function updatePost(id: string, data: PostData) {
  await db.post.update({ where: { id }, data })

  // Invalida solo questa pagina specifica
  revalidatePath(`/blog/${id}`)

  // Invalida tutte le pagine che matchano questo pattern
  revalidatePath('/blog/[slug]', 'page')

  // Invalida tutto sotto il layout root (nucleare)
  revalidatePath('/', 'layout')
}

revalidateTag: Invalidazione per Tag

Invece di ragionare per percorsi, potete assegnare tag ai dati e invalidare per tag. Questo è particolarmente utile quando gli stessi dati compaiono su più pagine diverse:

// Quando fetchate i dati, assegnate un tag
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] }
  })
  return res.json()
}

async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { tags: ['products', `product-${id}`] }
  })
  return res.json()
}

// Nella Server Action, invalidate per tag
'use server'

import { revalidateTag } from 'next/cache'

export async function deleteProduct(id: string) {
  await db.product.delete({ where: { id } })

  // Invalida tutti i fetch con tag 'products' (lista prodotti, homepage, ecc.)
  revalidateTag('products')
}

updateTag: Invalidazione Immediata

A differenza di revalidateTag che usa la semantica stale-while-revalidate, updateTag (disponibile nelle versioni più recenti) scade immediatamente la cache. Essenziale per scenari di "read-your-own-writes", dove l'utente deve vedere subito le proprie modifiche:

'use server'

import { revalidatePath } from 'next/cache'
import { updateTag } from 'next/cache'

export async function updateProfile(formData: FormData) {
  const name = formData.get('name') as string
  await db.user.update({ where: { id: userId }, data: { name } })

  // Scadenza immediata: l'utente vedrà subito il nome aggiornato
  updateTag('user-profile')
  revalidatePath('/settings')
}

In pratica, revalidatePath e revalidateTag vengono spesso usati insieme. Una buona regola empirica: revalidateTag per i dati condivisi tra più pagine, revalidatePath per la pagina specifica dove si trova l'utente.

Sicurezza delle Server Actions: Difesa in Profondità

Questo è probabilmente l'aspetto più sottovalutato delle Server Actions. Lo ripeto perché è fondamentale: ogni Server Action è un endpoint HTTP pubblico. Non importa se viene invocata da un componente protetto da autenticazione — l'azione stessa deve verificare indipendentemente identità e permessi.

La Vulnerabilità React2Shell (2025)

A dicembre 2025 è stata divulgata una vulnerabilità critica con punteggio CVSS 10.0 (CVE-2025-55182 e CVE-2025-66478), denominata React2Shell. In sostanza, una falla nel protocollo RSC Flight di React permetteva l'esecuzione di codice remoto arbitrario con una singola richiesta crafted. Le versioni vulnerabili includono React 19 e qualsiasi app Next.js con Server Actions. I fix sono disponibili in React 19.0.3+ e Next.js 15.0.5+, 15.2.6+, 15.4.8+, 16.0.7+. Se non avete ancora aggiornato, fatelo adesso. Sul serio.

Le 5 Regole d'Oro della Sicurezza

1. Validare sempre gli input a runtime. I tipi TypeScript scompaiono a runtime. Un campo userId: string potrebbe ricevere {"$ne": null} — un payload di NoSQL injection. Usate Zod per ogni singolo input, senza eccezioni.

// ❌ MAI fare così
export async function getUser(userId: string) {
  return db.user.findUnique({ where: { id: userId } })
}

// ✅ Sempre validare
import { z } from 'zod'

const UserIdSchema = z.string().uuid()

export async function getUser(rawUserId: string) {
  const userId = UserIdSchema.parse(rawUserId)
  return db.user.findUnique({ where: { id: userId } })
}

2. Autenticare in ogni azione, non solo nella pagina. L'autenticazione nella pagina non protegge l'azione. Sono endpoint separati e l'azione può essere chiamata direttamente da chiunque conosca l'identificativo.

// ❌ L'autenticazione nella pagina NON protegge l'azione
export default async function AdminPage() {
  const session = await getSession()
  if (!session?.user?.role === 'admin') redirect('/login')

  return <DeleteUserForm /> // l'azione dentro può essere chiamata da chiunque
}

// ✅ Verificare in OGNI azione
export async function deleteUser(userId: string) {
  const session = await getSession()
  if (!session?.user || session.user.role !== 'admin') {
    throw new Error('Non autorizzato')
  }

  const validatedId = z.string().uuid().parse(userId)
  await db.user.delete({ where: { id: validatedId } })
}

3. Verificare l'autorizzazione, non solo l'autenticazione. Un utente autenticato non è necessariamente autorizzato a fare tutto. Verificate sempre che possieda la risorsa o abbia il ruolo necessario:

export async function updateComment(commentId: string, content: string) {
  const session = await getSession()
  if (!session?.user) throw new Error('Non autenticato')

  const comment = await db.comment.findUnique({ where: { id: commentId } })

  // Verifica di ownership: l'utente può modificare solo i propri commenti
  if (comment?.authorId !== session.user.id) {
    throw new Error('Non autorizzato a modificare questo commento')
  }

  await db.comment.update({
    where: { id: commentId },
    data: { content }
  })
}

4. Non esporre mai messaggi di errore interni. Le eccezioni del database, gli stack trace e i messaggi di sistema possono rivelare dettagli sulla vostra architettura. Restituite messaggi generici all'utente e loggate i dettagli solo lato server:

export async function createOrder(data: OrderInput) {
  try {
    const validated = OrderSchema.parse(data)
    await db.order.create({ data: validated })
    revalidatePath('/orders')
    return { success: true }
  } catch (error) {
    // Loggate l'errore reale lato server
    console.error('Order creation failed:', error)
    // Restituite un messaggio generico al client
    return { success: false, message: 'Impossibile creare l'ordine.' }
  }
}

5. Implementare il rate limiting per azioni sensibili. Senza rate limiting, un attaccante può invocare le vostre azioni migliaia di volte al secondo. Librerie come Upstash Ratelimit o Arcjet rendono l'implementazione piuttosto semplice:

import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { headers } from 'next/headers'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '60 s'), // 5 richieste per minuto
})

export async function submitContactForm(formData: FormData) {
  const headersList = await headers()
  const ip = headersList.get('x-forwarded-for') ?? 'unknown'

  const { success } = await ratelimit.limit(ip)
  if (!success) {
    return { error: 'Troppi tentativi. Riprova tra qualche minuto.' }
  }

  // ... logica del form
}

Protezione CSRF Integrata

Una buona notizia: le Server Actions sono implementate come endpoint POST, e i cookie SameSite (default nei browser moderni) forniscono una protezione CSRF integrata. Non dovete aggiungere token CSRF manualmente. Detto ciò, per un livello extra di protezione, configurate allowedOrigins nel vostro next.config.js.

Pattern Avanzati: Composizione, Error Boundary e Azioni Parallele

Composizione di Azioni

Quando un'operazione richiede più mutazioni che devono riuscire tutte o fallire tutte, componete le azioni in una singola transazione. Ecco un esempio realistico di checkout:

'use server'

import { db } from '@/lib/database'

export async function checkoutCart(cartId: string) {
  const session = await getSession()
  if (!session?.user) throw new Error('Non autenticato')

  // Transazione: o tutto riesce, o nulla cambia
  const order = await db.$transaction(async (tx) => {
    const cart = await tx.cart.findUnique({
      where: { id: cartId, userId: session.user.id },
      include: { items: { include: { product: true } } },
    })

    if (!cart || cart.items.length === 0) {
      throw new Error('Carrello vuoto o non trovato')
    }

    // Verifica disponibilità
    for (const item of cart.items) {
      if (item.product.stock < item.quantity) {
        throw new Error(`${item.product.name} non è più disponibile`)
      }
    }

    // Crea ordine
    const order = await tx.order.create({
      data: {
        userId: session.user.id,
        total: cart.items.reduce(
          (sum, item) => sum + item.product.price * item.quantity, 0
        ),
        items: {
          create: cart.items.map(item => ({
            productId: item.productId,
            quantity: item.quantity,
            price: item.product.price,
          })),
        },
      },
    })

    // Aggiorna stock
    for (const item of cart.items) {
      await tx.product.update({
        where: { id: item.productId },
        data: { stock: { decrement: item.quantity } },
      })
    }

    // Svuota carrello
    await tx.cartItem.deleteMany({ where: { cartId } })

    return order
  })

  revalidatePath('/cart')
  revalidatePath('/orders')
  redirect(`/orders/${order.id}`)
}

Gestione Errori con Error Boundary

Per errori imprevisti nelle Server Actions, React cancella tutte le azioni in coda e mostra l'Error Boundary più vicino. Definite un file error.tsx nelle vostre route per gestire questi casi con grazia:

// app/products/error.tsx
'use client'

export default function ProductError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="text-center py-10">
      <h2>Qualcosa è andato storto</h2>
      <p className="text-gray-600 mt-2">
        Si è verificato un errore durante l'operazione.
      </p>
      <button
        onClick={reset}
        className="mt-4 bg-blue-600 text-white px-4 py-2 rounded"
      >
        Riprova
      </button>
    </div>
  )
}

La regola pratica è questa: per errori noti (validazione, permessi, business logic) restituite l'errore come parte dello stato e mostratelo nell'UI. Per errori imprevisti (bug, timeout, crash) lanciate un'eccezione e lasciate che l'Error Boundary faccia il suo lavoro.

Progressive Enhancement: Form che Funzionano Senza JavaScript

Un vantaggio che passa spesso in secondo piano: il progressive enhancement. Quando un form usa l'attributo action con una Server Action, funziona anche se JavaScript non è stato caricato, è disabilitato o ha fallito il download. Il browser invia il form come una normale richiesta POST, il server esegue l'azione e restituisce la risposta.

Nei Client Component, i form che invocano Server Actions accodano le submission se JavaScript non è ancora pronto, dando priorità all'idratazione. Dopo l'idratazione, il browser non si ricarica alla submission — React gestisce tutto lato client per un'esperienza più fluida.

In pratica? Le vostre app Next.js sono funzionali dal primo byte di HTML. JavaScript aggiunge poi le feature avanzate (spinner, aggiornamenti ottimistici, validazione real-time), ma la funzionalità core — inviare dati al server — è garantita anche senza JS. È una di quelle cose che fanno la differenza in contesti con connessioni lente o dispositivi datati.

Checklist per la Produzione

Prima di portare le vostre Server Actions in produzione, passate in rassegna questi punti:

  • Versione Next.js: State usando almeno Next.js 15.0.5 o superiore? I fix per React2Shell sono critici.
  • Validazione: Ogni input di ogni Server Action è validato con Zod a runtime. Nessuna eccezione.
  • Autenticazione: Ogni Server Action che tocca dati sensibili verifica la sessione dell'utente, indipendentemente dalla pagina che la ospita.
  • Autorizzazione: Oltre all'identità, verificate che l'utente abbia il permesso specifico per quella risorsa specifica.
  • Rate limiting: Le azioni sensibili (login, registrazione, form di contatto, checkout) hanno un rate limit configurato.
  • Messaggi di errore: Nessun messaggio interno esposto al client. Solo messaggi generici e user-friendly.
  • Closure: Nessun dato sensibile catturato nelle closure. Solo ID e dati che l'utente già conosce.
  • Revalidazione: Dopo ogni mutazione, la cache viene invalidata correttamente con revalidatePath e/o revalidateTag.
  • redirect() fuori dal try/catch: Tutte le chiamate a redirect() sono fuori dai blocchi try/catch.
  • Progressive enhancement: I form funzionano anche senza JavaScript.

Conclusioni

Le Server Actions sono un vero cambio di paradigma per chi costruisce applicazioni full-stack con Next.js. Via gli endpoint API manuali, via la complessità nella gestione dei form, e con il progressive enhancement avete la garanzia che le vostre app funzionino per tutti.

Ma tutta questa potenza va gestita con responsabilità. Trattate ogni Server Action come un endpoint pubblico, validate ogni input, verificate ogni permesso, limitate le invocazioni. Gli strumenti ci sono tutti: Zod per la validazione, next-safe-action per il middleware, useActionState e useOptimistic per un'UX di primo livello.

Se avete seguito anche la nostra guida sullo streaming e il Partial Prerendering, a questo punto avete il quadro completo: sapete ottimizzare la lettura dei dati con lo streaming SSR e gestire la scrittura con le Server Actions. I due aspetti si completano a vicenda e formano la base di un'architettura Next.js moderna, performante e — cosa non da poco — sicura.

Sull'Autore Editorial Team

Our team of expert writers and editors.