Server Actions în Next.js 16: Formulare, Validare Zod și Securitate

Învață cum să folosești Server Actions în Next.js 16 cu React 19: validare cu Zod, useActionState, useOptimistic și cele 7 reguli de securitate. Cu exemple de cod complete și practice.

Ce sunt Server Actions și de ce ar trebui să-ți pese

Dacă ai lucrat cu Next.js înainte de App Router, probabil știi exact cât de frustrant era procesul: creai un API route, scriai logica pe server, apoi făceai un fetch din client. Mult boilerplate, multe fișiere, mult loc de greșeli. Ei bine, Server Actions elimină tot acest overhead — și sincer, e una dintre cele mai bune schimbări din ecosistemul React din ultimii ani.

Pe scurt, Server Actions sunt funcții asincrone care rulează pe server, dar pe care le apelezi direct din componentele React. Ca pe niște funcții obișnuite. Next.js se ocupă automat de request-ul HTTP în spate, creând un endpoint POST pe care îl gestionează transparent. Tu scrii o funcție, o marchezi cu 'use server', și cam atât.

De la Next.js 14, Server Actions sunt stabile și gata de producție. În Next.js 16.1 cu React 19 au primit și mai multă putere prin hook-urile useActionState și useOptimistic — două unelte care chiar schimbă modul în care construiești formulare. Dacă ai citit deja ghidurile noastre despre Cache Components și Turbopack în Next.js 16, Server Actions completează perfect tabloul.

Cum funcționează Server Actions sub capotă

Hai să înțelegem mecanismul, ca să nu lucrăm orbește. Când marchezi o funcție cu 'use server', Next.js face câteva lucruri interesante:

  • Creează un endpoint HTTP POST unic, identificat printr-un ID criptat și nedeterminist
  • Serializează argumentele funcției și le trimite prin rețea
  • Execută funcția pe server și returnează rezultatul
  • Actualizează automat UI-ul clientului cu răspunsul

Și acum partea importantă, pe care mulți o trec cu vederea: deși tu nu vezi acest endpoint, el există. Orice Server Action exportat devine un endpoint public. Asta înseamnă că trebuie tratat cu aceeași grijă ca orice API — validare, autentificare, autorizare. Vom ajunge la securitate mai încolo, dar ține minte asta.

Crearea primului Server Action

Directiva use server — la nivel de funcție

Cel mai simplu mod de a defini un Server Action e inline, direct în componenta server. Iată cum arată:

// app/page.tsx
export default function ContactPage() {
  async function submitMessage(formData: FormData) {
    'use server'
    const name = formData.get('name') as string
    const email = formData.get('email') as string
    const message = formData.get('message') as string

    await db.messages.create({
      data: { name, email, message }
    })
  }

  return (
    <form action={submitMessage}>
      <input name="name" placeholder="Numele tău" required />
      <input name="email" type="email" placeholder="Email" required />
      <textarea name="message" placeholder="Mesajul tău" />
      <button type="submit">Trimite</button>
    </form>
  )
}

Observă ceva drăguț aici: formularul folosește atributul action nativ HTML. Asta înseamnă că funcționează chiar și fără JavaScript activat pe client. Progressive enhancement la modul serios, nu doar un buzzword.

Directiva use server — la nivel de fișier

Acum, pentru proiecte mai mari (și sincer, cam orice proiect dincolo de un demo), cea mai bună practică e să grupezi acțiunile în fișiere separate, organizate pe domenii:

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

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

export async function createMessage(formData: FormData) {
  const name = formData.get('name') as string
  const email = formData.get('email') as string
  const message = formData.get('message') as string

  await db.messages.create({
    data: { name, email, message }
  })

  revalidatePath('/messages')
}

export async function deleteMessage(id: string) {
  await db.messages.delete({ where: { id } })
  revalidatePath('/messages')
}

Apoi le importezi oriunde ai nevoie — fie în Server Components, fie în Client Components. Fiecare funcție exportată din fișier devine un Server Action independent.

Formulare cu useActionState — pattern-ul modern

React 19 a introdus useActionState, și personal cred că e hook-ul care transformă complet modul în care gestionezi starea formularelor. Gândește-te la el ca la un useReducer optimizat pentru side-effects generate de acțiunile utilizatorului.

Hook-ul îți oferă trei lucruri esențiale:

  • state — ultima valoare returnată de Server Action (erori, mesaje de succes, date)
  • formAction — funcția pe care o pasezi ca action al formularului
  • isPending — boolean care indică dacă acțiunea e în curs de execuție

Exemplu complet: formular de înregistrare

Hai să construim un formular de înregistrare cu gestionarea erorilor pe server. Mai întâi, Server Action-ul:

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

import { z } from 'zod'

const RegisterSchema = z.object({
  name: z.string().min(2, 'Numele trebuie să aibă cel puțin 2 caractere'),
  email: z.string().email('Adresă de email invalidă'),
  password: z.string().min(8, 'Parola trebuie să aibă cel puțin 8 caractere'),
})

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

export async function registerUser(
  prevState: RegisterState,
  formData: FormData
): Promise<RegisterState> {
  const validatedFields = RegisterSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Câmpurile conțin erori. Verifică și încearcă din nou.',
    }
  }

  const { name, email, password } = validatedFields.data

  // Verifică dacă email-ul este deja folosit
  const existingUser = await db.users.findUnique({
    where: { email }
  })

  if (existingUser) {
    return {
      errors: { email: ['Acest email este deja înregistrat'] },
      message: 'Acest email este deja înregistrat.',
    }
  }

  // Creează utilizatorul
  const hashedPassword = await bcrypt.hash(password, 12)
  await db.users.create({
    data: { name, email, password: hashedPassword }
  })

  return { success: true, message: 'Cont creat cu succes!' }
}

Iar acum componenta client care consumă acest action:

// app/register/RegisterForm.tsx
'use client'

import { useActionState } from 'react'
import { registerUser, type RegisterState } from '@/app/actions/auth'

const initialState: RegisterState = {}

export function RegisterForm() {
  const [state, formAction, isPending] = useActionState(
    registerUser,
    initialState
  )

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name">Nume</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          aria-describedby="name-error"
        />
        {state.errors?.name && (
          <p id="name-error" className="text-red-500 text-sm">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          aria-describedby="email-error"
        />
        {state.errors?.email && (
          <p id="email-error" className="text-red-500 text-sm">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="password">Parolă</label>
        <input
          id="password"
          name="password"
          type="password"
          required
          aria-describedby="password-error"
        />
        {state.errors?.password && (
          <p id="password-error" className="text-red-500 text-sm">
            {state.errors.password[0]}
          </p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Se creează contul...' : 'Înregistrează-te'}
      </button>

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

Câteva detalii de observat aici: semnătura funcției Server Action primește prevState ca prim argument când e folosită cu useActionState. Răspunsul de la server apare chiar înainte ca hidratarea React să se finalizeze, ceea ce face experiența utilizatorului vizibil mai bună.

Validare cu Zod — o schemă, două medii

Unul dintre lucrurile pe care le apreciez cel mai mult la Zod e că poți defini schema de validare într-un singur fișier și o reutilizezi atât pe server (în Server Action), cât și pe client (pentru validare instantanee). Zero cod duplicat — și asta contează enorm pe termen lung.

Schema partajată

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

export const ContactSchema = z.object({
  name: z.string()
    .min(2, 'Numele trebuie să aibă cel puțin 2 caractere')
    .max(100, 'Numele poate avea maxim 100 de caractere'),
  email: z.string()
    .email('Adresă de email invalidă'),
  subject: z.string()
    .min(5, 'Subiectul trebuie să aibă cel puțin 5 caractere')
    .max(200, 'Subiectul poate avea maxim 200 de caractere'),
  message: z.string()
    .min(10, 'Mesajul trebuie să aibă cel puțin 10 caractere')
    .max(5000, 'Mesajul poate avea maxim 5000 de caractere'),
})

export type ContactFormData = z.infer<typeof ContactSchema>

Server Action cu schema importată

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

import { ContactSchema } from '@/lib/schemas/contact'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export type ContactState = {
  errors?: Record<string, string[]>
  message?: string
  success?: boolean
}

export async function sendContactMessage(
  prevState: ContactState,
  formData: FormData
): Promise<ContactState> {
  const result = ContactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    subject: formData.get('subject'),
    message: formData.get('message'),
  })

  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
      message: 'Verifică câmpurile marcate cu roșu.',
    }
  }

  await db.contactMessages.create({ data: result.data })
  revalidatePath('/contact')

  return {
    success: true,
    message: 'Mesajul a fost trimis cu succes!',
  }
}

Client cu validare instantanee folosind aceeași schemă

// app/contact/ContactForm.tsx
'use client'

import { useActionState, useState } from 'react'
import { ContactSchema } from '@/lib/schemas/contact'
import { sendContactMessage, type ContactState } from '@/app/actions/contact'

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    sendContactMessage,
    {} as ContactState
  )
  const [clientErrors, setClientErrors] = useState<Record<string, string>>({})

  function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
    const { name, value } = e.target
    const fieldResult = ContactSchema.shape[
      name as keyof typeof ContactSchema.shape
    ]?.safeParse(value)

    if (fieldResult && !fieldResult.success) {
      setClientErrors(prev => ({
        ...prev,
        [name]: fieldResult.error.issues[0].message
      }))
    } else {
      setClientErrors(prev => {
        const next = { ...prev }
        delete next[name]
        return next
      })
    }
  }

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input
          name="name"
          placeholder="Numele tău"
          onBlur={handleBlur}
        />
        {(clientErrors.name || state.errors?.name?.[0]) && (
          <p className="text-red-500 text-sm">
            {clientErrors.name || state.errors?.name?.[0]}
          </p>
        )}
      </div>
      {/* Câmpuri similare pentru email, subject, message */}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Se trimite...' : 'Trimite mesajul'}
      </button>
    </form>
  )
}

Metoda .flatten().fieldErrors de la Zod transformă erorile într-un obiect simplu, ușor de parcurs în JSX. Fiecare câmp primește un array de mesaje de eroare — poți afișa prima eroare sau toate deodată, cum preferi.

useOptimistic — feedback instant, fără așteptare

Sincer, utilizatorii nu au răbdare să aștepte ca serverul să răspundă pentru a vedea efectul acțiunii lor. Și de ce ar face-o? Hook-ul useOptimistic din React 19 rezolvă exact această problemă: actualizează UI-ul imediat, iar dacă serverul confirmă, starea rămâne. Dacă apare o eroare, se revine automat la starea anterioară.

E elegant. Hai să vedem cum arată în practică.

Exemplu practic: listă de todo-uri cu adăugare instantanee

// app/todos/TodoList.tsx
'use client'

import { useOptimistic } from 'react'
import { useActionState } from 'react'
import { addTodo } from '@/app/actions/todos'

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

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newTodoText: string) => [
      ...currentTodos,
      {
        id: 'temp-' + Date.now(),
        text: newTodoText,
        completed: false,
      },
    ]
  )

  async function handleAddTodo(formData: FormData) {
    const text = formData.get('text') as string
    addOptimisticTodo(text)
    await addTodo(formData)
  }

  return (
    <div>
      <form action={handleAddTodo}>
        <input name="text" placeholder="Ce ai de făcut?" required />
        <button type="submit">Adaugă</button>
      </form>

      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} style={{
            opacity: todo.id.startsWith('temp-') ? 0.6 : 1
          }}>
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  )
}

Elementul adăugat optimistic apare instant cu opacitate redusă (un indicator vizual subtil), apoi devine solid când serverul confirmă. Dacă acțiunea eșuează, elementul dispare automat. Fără cod suplimentar de gestionare — React se ocupă de tot.

Securitate Server Actions — cele 7 reguli de aur

Ok, aceasta e secțiunea pe care mulți o sar, dar care face diferența între o aplicație solidă și una vulnerabilă. Server Actions sunt endpoint-uri HTTP publice — nu uita asta niciodată.

1. Validează mereu pe server cu Zod

TypeScript oferă type-checking doar la compile-time. La runtime? Tipurile dispar complet. Un atacator poate trimite orice payload — un string unde aștepți un număr, un obiect JSON unde aștepți un string, sau chiar un payload de injection. Zod e prima ta linie de apărare:

'use server'

import { z } from 'zod'

const UpdateProfileSchema = z.object({
  name: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
  website: z.string().url().optional().or(z.literal('')),
})

export async function updateProfile(formData: FormData) {
  const result = UpdateProfileSchema.safeParse({
    name: formData.get('name'),
    bio: formData.get('bio'),
    website: formData.get('website'),
  })

  if (!result.success) {
    return { error: 'Date invalide' }
  }

  // Acum result.data este sigur și tipizat
  await db.users.update({
    where: { id: session.userId },
    data: result.data,
  })
}

2. Re-autentifică și re-autorizează în fiecare action

Nu te baza pe faptul că pagina a verificat sesiunea. Acțiunea e un endpoint separat — verifică identitatea și permisiunile de fiecare dată:

'use server'

import { auth } from '@/lib/auth'

export async function deletePost(postId: string) {
  const session = await auth()
  if (!session?.user) {
    return { error: 'Trebuie să fii autentificat' }
  }

  const post = await db.posts.findUnique({ where: { id: postId } })
  if (post?.authorId !== session.user.id) {
    return { error: 'Nu ai permisiunea să ștergi această postare' }
  }

  await db.posts.delete({ where: { id: postId } })
  revalidatePath('/posts')
}

3. Nu captura date sensibile în closure-uri

Când definești un Server Action inline într-o componentă, variabilele din scope-ul înconjurător sunt serializate și criptate de Next.js. Dar criptarea poate fi compromisă. Cea mai bună abordare? Mută acțiunile în fișiere separate și pasează doar date nesensibile:

// ❌ GREȘIT — secretul e capturat în closure
export default async function AdminPage() {
  const apiSecret = process.env.ADMIN_API_KEY

  async function performAction() {
    'use server'
    // apiSecret e serializat și trimis la client (criptat)
    await fetch(url, { headers: { Authorization: apiSecret } })
  }
}

// ✅ CORECT — acțiunea în fișier separat
// app/actions/admin.ts
'use server'
export async function performAction() {
  // secretul rămâne pe server, niciodată serializat
  const apiSecret = process.env.ADMIN_API_KEY
  await fetch(url, { headers: { Authorization: apiSecret } })
}

4. Folosește un Data Access Layer (DAL)

Centralizează toate accesele la baza de date într-un layer dedicat care verifică automat permisiunile. Asta previne scăpări de autorizare și face auditul mult mai ușor. E o investiție care se plătește singură pe măsură ce proiectul crește.

5. Protejează-te împotriva CSRF

Next.js compară automat header-ul Origin cu Host și blochează request-urile cross-origin. Pentru aplicații cu domenii multiple, configurează explicit originile permise.

6. Limitează dimensiunea request-urilor

Implicit, body-ul unui Server Action e limitat la 1MB. Poți ajusta asta în configurația Next.js dacă ai nevoie:

// next.config.ts
const nextConfig = {
  serverActions: {
    bodySizeLimit: '2mb',
  },
}

7. Nu te baza doar pe Middleware

Middleware-ul (acum proxy.ts în Next.js 16) a avut vulnerabilități în trecut, inclusiv CVE-2025-29927 care a permis bypass-ul verificărilor de securitate. Nu-l folosi niciodată ca singură linie de apărare — implementează verificări și la nivelul Server Actions și al Data Access Layer-ului. Mai bine redundant decât vulnerabil.

Server Actions vs Route Handlers — care și când

O întrebare pe care o aud des: când folosesc Server Actions și când Route Handlers? Răspunsul e mai simplu decât pare.

Folosește Server Actions când:

  • Faci mutații (create, update, delete) din componente React
  • Lucrezi cu formulare
  • Vrei type-safety automat între server și client
  • Aplicația ta e consumată doar de frontend-ul Next.js

Folosește Route Handlers când:

  • Clienți externi trebuie să acceseze API-ul (aplicații mobile, webhook-uri, servicii terțe)
  • Ai nevoie de control complet asupra HTTP — status codes, headers, streaming
  • Construiești endpoint-uri GET cache-abile
  • Primești webhook-uri de la Stripe, GitHub sau alte servicii

Abordarea pe care o recomand? Hibridă. Server Actions pentru toate mutațiile interne, Route Handlers pentru integrări externe. Next.js le suportă pe amândouă simultan, așa că poți începe cu Server Actions și adăuga Route Handlers pe măsură ce apar nevoi noi.

Biblioteci recomandate: next-safe-action și zsa

Dacă vrei un nivel suplimentar de type-safety și un API declarativ pentru autentificare și autorizare, două biblioteci merită atenția ta.

next-safe-action

Definește Server Actions end-to-end typesafe cu suport pentru orice bibliotecă de validare compatibilă Standard Schema (Zod, Valibot, ArkType). Vine cu un sistem de middleware compozabil pentru autorizare, logging și gestionarea erorilor:

import { createSafeActionClient } from 'next-safe-action'
import { z } from 'zod'

const actionClient = createSafeActionClient()

export const createPost = actionClient
  .schema(z.object({
    title: z.string().min(1),
    content: z.string().min(10),
  }))
  .action(async ({ parsedInput }) => {
    const post = await db.posts.create({
      data: parsedInput
    })
    return { post }
  })

zsa (Zod Server Actions)

Focusat exclusiv pe Zod, zsa oferă integrare built-in cu React Query și un pattern de return pe care îl apreciez mult: [data, null] la succes sau [null, error] la eșec. Dacă folosești deja React Query în proiect, merită încercat.

Ambele biblioteci sunt producții-ready și elimină o mare parte din boilerplate-ul repetitiv.

Reîmprospătarea datelor după mutații

După ce un Server Action modifică date, trebuie să te asiguri că UI-ul reflectă starea actuală. Next.js îți oferă mai multe variante:

'use server'

import { revalidatePath } from 'next/cache'
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createArticle(formData: FormData) {
  const article = await db.articles.create({
    data: { /* ... */ }
  })

  // Opțiunea 1: Revalidează o cale specifică
  revalidatePath('/articles')

  // Opțiunea 2: Revalidează un tag de cache
  revalidateTag('articles')

  // Opțiunea 3: Redirect după succes
  redirect(`/articles/${article.slug}`)
}

Dacă ai implementat deja Cache Components cu cacheTag (cum am descris în ghidul anterior), poți folosi revalidateTag pentru invalidare granulară. Combinația asta e extrem de eficientă în practică.

Pattern-uri avansate

Server Actions non-form — apel programatic

Server Actions nu sunt limitate la formulare. Le poți apela programatic din orice Client Component folosind useTransition:

'use client'

import { useTransition } from 'react'
import { toggleFavorite } from '@/app/actions/favorites'

export function FavoriteButton({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition()

  return (
    <button
      onClick={() => startTransition(() => toggleFavorite(postId))}
      disabled={isPending}
    >
      {isPending ? 'Se procesează...' : '❤ Adaugă la favorite'}
    </button>
  )
}

Gestionarea erorilor cu Error Boundaries

Când un Server Action aruncă o eroare neprinsă, ea e capturată de cel mai apropiat error.tsx sau <Suspense> boundary. Pentru erori controlate, mai bine returnează-le ca parte din state și afișează-le în UI. Nu le arunca — gestionează-le.

Întrebări frecvente

Server Actions înlocuiesc complet API routes?

Nu. Server Actions sunt perfecte pentru mutații interne din componente React. Dar dacă ai nevoie de endpoint-uri accesibile de clienți externi (aplicații mobile, webhook-uri, servicii terțe) sau de control complet asupra răspunsului HTTP, Route Handlers rămân necesare. Cel mai bun sfat: folosește-le pe amândouă, fiecare pentru ce face mai bine.

Pot folosi Server Actions pentru a citi date (GET)?

Tehnic da, dar nu e recomandat. Server Actions folosesc metoda HTTP POST, care nu e cache-abilă. Pentru citirea datelor, folosește Server Components (care rulează pe server oricum) sau Route Handlers cu GET, care pot beneficia de caching.

Cum testez Server Actions?

Le poți testa ca orice funcție asincronă — importă acțiunea, creează un obiect FormData mock cu datele de test, și apelează funcția. Asigură-te că mock-uiești dependințele externe (baza de date, servicii de autentificare). Pentru teste end-to-end, Playwright și Cypress pot testa formularul complet, inclusiv interacțiunea cu Server Actions.

Este sigur să stochez secrete în Server Actions?

Da, atâta timp cât secretele sunt accesate din variabile de mediu (process.env) direct în fișierul Server Action — nu prin closure-uri din componente. Codul Server Actions nu ajunge niciodată în bundle-ul clientului. Dar dacă un secret e capturat într-un closure dintr-o componentă, el e serializat (criptat) și trimis la client, ceea ce nu e ideal.

Ce se întâmplă dacă JavaScript e dezactivat pe client?

Formularele care folosesc atributul action nativ funcționează și fără JavaScript — Next.js procesează submit-ul ca un request POST tradițional. Totuși, useActionState, useOptimistic și alte hook-uri React necesită JavaScript. Asta e progressive enhancement în acțiune: funcționalitatea de bază merge mereu, experiența e mai bună cu JS activat.

Despre Autor Editorial Team

Our team of expert writers and editors.