Next.js Server Actions teljes útmutató: Űrlapkezelés, validáció és biztonsági minták

Tanuld meg a Next.js Server Actions használatát a gyakorlatban: űrlapkezelés, Zod validáció, useActionState, gyorsítótár-frissítés, next-safe-action middleware és biztonsági bevált gyakorlatok kódpéldákkal.

Bevezetés: Miért forradalmasítják a Server Actions a webes űrlapkezelést?

Ha valaha is fejlesztettél modern webes alkalmazást, akkor pontosan tudod, miről beszélek: az űrlapkezelés és az adatmutáció az egyik leggyakrabban visszatérő — és őszintén szólva legidegesítőbb — feladat. API végpontokat kell létrehoznod, kliens oldali állapotot kezelned, hibaüzeneteket megjelenítened, és mindezt úgy, hogy a felhasználói élmény is gördülékeny maradjon. Hagyományosan ez egy halom boilerplate kódot jelentett: fetch hívások, loading állapotok, try-catch blokkok, és persze az API route-ok karbantartása.

A Next.js Server Actions mindezen változtat.

Az App Router és a React 19 együttműködésének köszönhetően most már közvetlenül a React komponensekből hívhatunk szerver oldali függvényeket — anélkül, hogy külön API végpontokat kellene létrehoznunk. Szóval az űrlapot elküldöd, a szerver feldolgozza, a felhasználói felület meg automatikusan frissül. Egyszerűen hangzik, ugye? De a részletek — mint mindig — a lényegesek.

Ebben az átfogó útmutatóban végigmegyünk a Server Actions minden fontos aspektusán: az alapoktól a haladó biztonsági mintákig. Megmutatjuk, hogyan kezelj űrlapokat progresszív fejlesztéssel, hogyan validálj Zod-dal, hogyan frissítsd a gyorsítótárat mutáció után, és hogyan építs biztonságos, típusbiztos action-öket a next-safe-action könyvtárral. Ha korábban már olvastad az adatlekérdezésről szóló cikkünket a React Server Components kapcsán, ez az útmutató az érem másik oldalát mutatja be — nem az adatok olvasásáról, hanem az adatok módosításáról szól.

Mi az a Server Action és hogyan működik?

A 'use server' direktíva

A Server Action lényegében egy aszinkron függvény, amely a szerveren fut, de kliens oldalról is meghívható. Két módon definiálhatsz Server Action-t:

1. Inline Server Action — közvetlenül a Server Componentben:

// app/posts/page.tsx — Server Component
export default function PostsPage() {
  // Inline Server Action — a 'use server' direktíva a függvényen belül
  async function createPost(formData: FormData) {
    'use server';

    const title = formData.get('title') as string;
    const content = formData.get('content') as string;

    await db.insert(posts).values({ title, content });
    revalidatePath('/posts');
  }

  return (
    <form action={createPost}>
      <input name="title" placeholder="Cím" required />
      <textarea name="content" placeholder="Tartalom" required />
      <button type="submit">Létrehozás</button>
    </form>
  );
}

2. Külön fájlban definiált Server Action:

// app/actions/posts.ts
'use server';

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.insert(posts).values({ title, content });
  revalidatePath('/posts');
}

export async function deletePost(postId: string) {
  await db.delete(posts).where(eq(posts.id, postId));
  revalidatePath('/posts');
}

A második megközelítés — a külön fájlban való definíció — az ajánlott a legtöbb valós projektben. Tapasztalatból mondom, hogy így a Server Action-ök újrafelhasználhatóak, könnyebben tesztelhetőek, és ami talán a legfontosabb: Client Components-ekből is simán importálhatóak.

Hogyan működik a háttérben?

Amikor a 'use server' direktívát használod, a Next.js automatikusan létrehoz egy HTTP POST végpontot az adott függvényhez. A kliens oldalon a React elfogja a form submission-t, és egy fetch hívást indít ehhez a végponthoz. A szerver végrehajtja a függvényt, és a válasz alapján frissíti a UI-t. Mindez transzparensen történik — neked nem kell API route-okat írnod vagy fetch hívásokat kezelned.

Na de itt jön a fontos rész: minden Server Action egy nyilvános HTTP végpont. Ez azt jelenti, hogy bárki meghívhatja — nem csak az alkalmazásod felhasználói felülete. Éppen ezért a validáció, hitelesítés és jogosultságkezelés nem opcionális, hanem kötelező. De erről részletesen később beszélünk.

Űrlapkezelés Server Actions-szel: Az alapoktól a haladó mintákig

Egyszerű űrlap

A legegyszerűbb eset: egy HTML form, amelynek action attribútuma egy Server Action. Nincs szükség onSubmit eseménykezelőre, preventDefault() hívásra vagy manuális fetch kérésre. Tényleg ennyire egyszerű.

// app/contact/page.tsx
import { submitContactForm } from '@/app/actions/contact';

export default function ContactPage() {
  return (
    <form action={submitContactForm}>
      <label htmlFor="name">Név</label>
      <input id="name" name="name" type="text" required />

      <label htmlFor="email">E-mail</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="message">Üzenet</label>
      <textarea id="message" name="message" required />

      <button type="submit">Küldés</button>
    </form>
  );
}
// app/actions/contact.ts
'use server';

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

export async function submitContactForm(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.insert(contactMessages).values({
    name,
    email,
    message,
    createdAt: new Date(),
  });
}

Extra paraméterek átadása bind()-dal

Előfordul, hogy az űrlapadatokon kívül további paramétereket is át szeretnél adni — például egy elem azonosítóját szerkesztéskor. Erre a JavaScript bind() metódusa a megoldás:

// app/posts/[id]/edit/page.tsx
import { updatePost } from '@/app/actions/posts';

export default async function EditPostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await getPost(id);
  const updatePostWithId = updatePost.bind(null, id);

  return (
    <form action={updatePostWithId}>
      <input name="title" defaultValue={post.title} />
      <textarea name="content" defaultValue={post.content} />
      <button type="submit">Mentés</button>
    </form>
  );
}

// app/actions/posts.ts
'use server';

export async function updatePost(postId: string, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.update(posts)
    .set({ title, content, updatedAt: new Date() })
    .where(eq(posts.id, postId));

  revalidatePath('/posts');
  redirect(`/posts/${postId}`);
}

useActionState: Az űrlapállapot kezelésének új standardja

Miért van szükség rá?

Az egyszerű form action megközelítés remekül működik alap esetekre, de mi van, ha szeretnéd megjeleníteni a hibaüzeneteket? Vagy jelezni a felhasználónak, hogy a beküldés sikeres volt? Esetleg egy korábbi állapotot megőrizni hiba esetén?

Erre szolgál a React 19-ben bevezetett useActionState hook. Ez három dolgot ad vissza: az aktuális állapotot (state), a form action-t, és egy isPending jelzőt. Nézzük meg működés közben:

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

import { useActionState } from 'react';
import { registerUser } from '@/app/actions/auth';

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

const initialState: FormState = {
  success: false,
  message: '',
};

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

  return (
    <form action={formAction}>
      {state.message && (
        <div className={state.success ? 'alert-success' : 'alert-error'}>
          {state.message}
        </div>
      )}

      <div>
        <label htmlFor="username">Felhasználónév</label>
        <input id="username" name="username" type="text" required />
        {state.errors?.username && (
          <p className="error">{state.errors.username[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">E-mail cím</label>
        <input id="email" name="email" type="email" required />
        {state.errors?.email && (
          <p className="error">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">Jelszó</label>
        <input id="password" name="password" type="password" required />
        {state.errors?.password && (
          <p className="error">{state.errors.password[0]}</p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Regisztráció folyamatban...' : 'Regisztráció'}
      </button>
    </form>
  );
}
// app/actions/auth.ts
'use server';

import { z } from 'zod';
import { hash } from 'bcryptjs';

const registerSchema = z.object({
  username: z.string().min(3, 'A felhasználónév legalább 3 karakter legyen'),
  email: z.string().email('Érvényes e-mail címet adj meg'),
  password: z.string().min(8, 'A jelszó legalább 8 karakter legyen'),
});

export async function registerUser(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const rawData = {
    username: formData.get('username'),
    email: formData.get('email'),
    password: formData.get('password'),
  };

  const validatedFields = registerSchema.safeParse(rawData);

  if (!validatedFields.success) {
    return {
      success: false,
      message: 'Kérjük, javítsd a hibákat.',
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }

  const { username, email, password } = validatedFields.data;

  // Ellenőrizzük, hogy létezik-e már ilyen felhasználó
  const existingUser = await db.query.users.findFirst({
    where: eq(users.email, email),
  });

  if (existingUser) {
    return {
      success: false,
      message: 'Ez az e-mail cím már regisztrálva van.',
    };
  }

  const hashedPassword = await hash(password, 12);

  await db.insert(users).values({
    username,
    email,
    password: hashedPassword,
  });

  return {
    success: true,
    message: 'Sikeres regisztráció! Most már bejelentkezhetsz.',
  };
}

useFormStatus: Betöltési állapot jelzése

A useFormStatus hook a react-dom csomagból származik, és lehetővé teszi, hogy a form gyermekkomponenseiben hozzáférj a beküldési állapothoz. Ez különösen hasznos egy újrafelhasználható submit gombnál (és higgyétek el, ilyet minden projektben fogatok csinálni):

// components/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

interface SubmitButtonProps {
  label?: string;
  pendingLabel?: string;
}

export function SubmitButton({
  label = 'Küldés',
  pendingLabel = 'Feldolgozás...',
}: SubmitButtonProps) {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? pendingLabel : label}
    </button>
  );
}

Fontos: a useFormStatus hookot a <form> elemen belül kell használni, mert automatikusan megtalálja a legközelebbi szülő form elemet. Ha kívülre teszed, egyszerűen nem fog működni — ezt a hibát sajnos sokan elkövetik.

Szerver oldali validáció Zod-dal

Miért nem elég a kliens oldali validáció?

A kliens oldali validáció (HTML required, pattern, stb.) javítja a felhasználói élményt, de semmiféle biztonságot nem nyújt. Bárki megkerülheti a böngésző validációját egyetlen curl paranccsal vagy a DevTools segítségével.

Mivel a Server Action-ök nyilvános HTTP végpontok, minden bemeneti adatot a szerveren kell validálni. Pont.

A Zod a legszélesebb körben használt validációs könyvtár a Next.js ökoszisztémában. TypeScript-natív, deklaratív sémákat kínál, és tökéletesen integrálódik a Server Actions-szel.

Komplex validációs séma

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

export const productSchema = z.object({
  name: z
    .string()
    .min(2, 'A terméknév legalább 2 karakter legyen')
    .max(100, 'A terméknév legfeljebb 100 karakter lehet'),
  description: z
    .string()
    .min(10, 'A leírás legalább 10 karakter legyen')
    .max(5000, 'A leírás legfeljebb 5000 karakter lehet'),
  price: z
    .number({ message: 'Érvényes árat adj meg' })
    .positive('Az ár pozitív szám kell legyen')
    .max(10_000_000, 'Az ár nem haladhatja meg a 10 000 000-t'),
  category: z.enum(['elektronika', 'ruhazat', 'konyv', 'egyeb'], {
    message: 'Válassz érvényes kategóriát',
  }),
  inStock: z.boolean().default(true),
  tags: z
    .array(z.string().min(1).max(30))
    .min(1, 'Legalább egy címkét adj meg')
    .max(10, 'Legfeljebb 10 címke adható meg'),
});

export type ProductInput = z.infer<typeof productSchema>;

Validáció integrálása a Server Action-be

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

import { productSchema } from '@/lib/schemas/product';
import { revalidatePath } from 'next/cache';

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

export async function createProduct(
  prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  // 1. Nyers adatok kinyerése a FormData-ból
  const rawData = {
    name: formData.get('name'),
    description: formData.get('description'),
    price: Number(formData.get('price')),
    category: formData.get('category'),
    inStock: formData.get('inStock') === 'on',
    tags: formData.getAll('tags').filter(Boolean),
  };

  // 2. Szerver oldali validáció Zod-dal
  const result = productSchema.safeParse(rawData);

  if (!result.success) {
    return {
      success: false,
      message: 'Érvényesítési hiba. Kérjük, ellenőrizd a beviteli mezőket.',
      errors: result.error.flatten().fieldErrors as Record<string, string[]>,
    };
  }

  // 3. Adatbázis művelet
  try {
    await db.insert(products).values(result.data);
  } catch (error) {
    return {
      success: false,
      message: 'Adatbázis hiba történt. Próbáld újra később.',
    };
  }

  // 4. Gyorsítótár frissítése
  revalidatePath('/products');

  return {
    success: true,
    message: 'A termék sikeresen létrehozva!',
  };
}

Figyeld meg a mintát: először validálunk, aztán ellenőrizzük a jogosultságokat (a következő fejezetben), és csak utána hajtjuk végre az adatbázis műveletet. Ez a sorrend garantálja, hogy soha ne dolgozzunk érvénytelen vagy jogosulatlan adatokkal. Egyszerű szabály, mégis meglepően sokan eltévesztik.

Hitelesítés és jogosultságkezelés minden Server Action-ben

A legfontosabb szabály

Korábban már említettük, de nem lehet elégszer hangsúlyozni: minden Server Action egy nyilvános HTTP POST végpont. Ez azt jelenti, hogy hiába érhető el az adott oldal csak bejelentkezett felhasználók számára — a Server Action-t bárki meghívhatja közvetlenül, akár egy curl paranccsal vagy a Postman-nel.

Éppen ezért minden védett Server Action-ben ellenőrizni kell a hitelesítést és a jogosultságokat. Nincs kivétel.

// app/actions/admin.ts
'use server';

import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export async function deleteUser(userId: string) {
  // 1. Hitelesítés ellenőrzése
  const session = await auth();

  if (!session?.user) {
    redirect('/bejelentkezes');
  }

  // 2. Jogosultság ellenőrzése
  if (session.user.role !== 'admin') {
    throw new Error('Nincs jogosultságod ehhez a művelethez.');
  }

  // 3. Önvédelmi ellenőrzés
  if (session.user.id === userId) {
    throw new Error('Nem törölheted a saját fiókodat.');
  }

  // 4. Művelet végrehajtása
  await db.delete(users).where(eq(users.id, userId));
  revalidatePath('/admin/users');
}

Újrafelhasználható hitelesítési wrapper

Hogy ne kelljen minden egyes action-ben külön-külön megírni a hitelesítési logikát (ami elég hamar unalmassá válik), érdemes egy wrapper függvényt létrehozni:

// lib/action-utils.ts
'use server';

import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

type Session = {
  user: { id: string; email: string; role: string };
};

export function authenticatedAction<T extends unknown[], R>(
  action: (session: Session, ...args: T) => Promise<R>
) {
  return async (...args: T): Promise<R> => {
    const session = await auth();

    if (!session?.user) {
      redirect('/bejelentkezes');
    }

    return action(session as Session, ...args);
  };
}

export function adminAction<T extends unknown[], R>(
  action: (session: Session, ...args: T) => Promise<R>
) {
  return authenticatedAction(async (session, ...args: T) => {
    if (session.user.role !== 'admin') {
      throw new Error('Admin jogosultság szükséges.');
    }
    return action(session, ...args);
  });
}

// Használat:
export const deleteUser = adminAction(async (session, userId: string) => {
  await db.delete(users).where(eq(users.id, userId));
  revalidatePath('/admin/users');
});

Gyorsítótár-frissítés és revalidáció mutáció után

revalidatePath vs. revalidateTag

Amikor egy Server Action módosítja az adatokat, a felhasználó természetesen elvárja, hogy a felhasználói felület azonnal tükrözze a változásokat. A Next.js két fő mechanizmust kínál erre:

revalidatePath — Egy adott útvonal gyorsítótárát érvényteleníti:

'use server';

import { revalidatePath } from 'next/cache';

export async function updateProfile(formData: FormData) {
  await db.update(users).set({
    name: formData.get('name') as string,
    bio: formData.get('bio') as string,
  }).where(eq(users.id, currentUserId));

  // Az egész profil oldal gyorsítótárát frissíti
  revalidatePath('/profil');

  // Vagy specifikus típust is megadhatsz:
  // revalidatePath('/profil', 'page'); — csak az oldalt
  // revalidatePath('/profil', 'layout'); — a layout-ot is
}

revalidateTag — Címkézett gyorsítótár-bejegyzéseket érvénytelenít. Ez rugalmasabb megoldás, mert nem útvonalhoz, hanem logikai csoporthoz kötődik:

// Adatlekérdezés címkézéssel
async function getProducts() {
  const response = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
  });
  return response.json();
}

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

// Server Action — csak az érintett termék gyorsítótárát frissíti
'use server';

import { revalidateTag } from 'next/cache';

export async function updateProduct(productId: string, formData: FormData) {
  await db.update(products).set({
    name: formData.get('name') as string,
    price: Number(formData.get('price')),
  }).where(eq(products.id, productId));

  // Csak az adott termék cache-ét érvényteleníti
  revalidateTag(`product-${productId}`);

  // Vagy az összes termékét egyszerre
  // revalidateTag('products');
}

Átirányítás mutáció után

Gyakori minta, hogy egy sikeres mutáció után átirányítjuk a felhasználót egy másik oldalra. Egy apró, de fontos részlet: a redirect() függvényt a revalidatePath vagy revalidateTag után kell meghívni, különben az átirányítás előtt nem frissül a gyorsítótár.

'use server';

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

export async function createArticle(formData: FormData) {
  const article = await db.insert(articles).values({
    title: formData.get('title') as string,
    content: formData.get('content') as string,
  }).returning();

  // Először frissítjük a gyorsítótárat
  revalidatePath('/articles');

  // Aztán átirányítunk — a redirect() kivételt dob, szóval ez legyen az utolsó
  redirect(`/articles/${article[0].id}`);
}

Típusbiztos Server Actions a next-safe-action könyvtárral

Miért érdemes használni?

Ahogy az alkalmazásod növekszik, egyre több Server Action-t írsz, és mindegyikben ugyanazt a logikát ismétled: validáció, hitelesítés, hibakezelés. Ismerős, ugye? A next-safe-action könyvtár pont erre kínál egy elegáns megoldást: egy middleware pipeline-t, ami a tRPC-hez hasonlóan működik.

Az action client beállítása

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

// Alap action client — publikus akciókhoz
export const actionClient = createSafeActionClient({
  handleServerError(e) {
    // Naplózás a szerveren
    console.error('Action hiba:', e.message);

    // A felhasználónak csak általános hibaüzenetet küldünk
    return 'Váratlan hiba történt. Próbáld újra később.';
  },
});

// Hitelesített action client — bejelentkezett felhasználóknak
export const authActionClient = actionClient.use(async ({ next }) => {
  const session = await auth();

  if (!session?.user) {
    throw new Error('Nem vagy bejelentkezve.');
  }

  // A session-t továbbadjuk a következő middleware-nek / az action-nek
  return next({ ctx: { session } });
});

// Admin action client — admin felhasználóknak
export const adminActionClient = authActionClient.use(
  async ({ next, ctx }) => {
    if (ctx.session.user.role !== 'admin') {
      throw new Error('Admin jogosultság szükséges.');
    }

    return next({ ctx });
  }
);

Action definiálása a safe-action-nel

// app/actions/products-safe.ts
'use server';

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

export const createProductAction = authActionClient
  .schema(productSchema)
  .action(async ({ parsedInput, ctx }) => {
    // parsedInput már validálva van a Zod sémával
    // ctx tartalmazza a session-t a middleware-ből
    const { name, description, price, category, inStock, tags } = parsedInput;
    const { session } = ctx;

    const product = await db.insert(products).values({
      name,
      description,
      price,
      category,
      inStock,
      tags,
      createdBy: session.user.id,
    }).returning();

    revalidatePath('/products');

    return { product: product[0] };
  });

Használat a kliensen

// components/CreateProductForm.tsx
'use client';

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

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

  const handleSubmit = (formData: FormData) => {
    execute({
      name: formData.get('name') as string,
      description: formData.get('description') as string,
      price: Number(formData.get('price')),
      category: formData.get('category') as string,
      inStock: formData.get('inStock') === 'on',
      tags: formData.getAll('tags') as string[],
    });
  };

  return (
    <form action={handleSubmit}>
      {/* Mezők */}

      {result.validationErrors && (
        <div className="errors">
          {Object.entries(result.validationErrors).map(([field, errors]) => (
            <p key={field}>{field}: {errors?.join(', ')}</p>
          ))}
        </div>
      )}

      {result.serverError && (
        <div className="error">{result.serverError}</div>
      )}

      {result.data && (
        <div className="success">
          Termék létrehozva: {result.data.product.name}
        </div>
      )}

      <button type="submit" disabled={isExecuting}>
        {isExecuting ? 'Létrehozás...' : 'Termék létrehozása'}
      </button>
    </form>
  );
}

A next-safe-action legnagyobb előnye, hogy a middleware pipeline automatikusan gondoskodik a hitelesítésről, validációról és hibakezelésről. Nem kell minden egyes action-ben külön megírnod ezeket — a pipeline központilag kezeli az egészet. Egy nagyobb projektnél ez óriási időmegtakarítás.

Progresszív fejlesztés: Működő űrlapok JavaScript nélkül

Miért fontos ez?

A Server Actions egyik legjobb tulajdonsága — és ezt nem győzöm hangsúlyozni —, hogy natívan támogatják a progresszív fejlesztést (progressive enhancement). Ha a felhasználó böngészőjében nincs betöltve a JavaScript (akár lassú hálózat, akár letiltott JS miatt), az űrlap továbbra is működik!

A form ilyenkor egy hagyományos HTTP POST kérésként küldi el az adatokat, és a szerver feldolgozza azokat. Ez automatikusan működik, ha a form action attribútumot használod.

De hogyan őrizzük meg ezt az előnyt, miközben a JavaScript-es felhasználók is élvezik a dinamikus visszajelzéseket? Lássuk:

// app/feedback/page.tsx
import { submitFeedback } from '@/app/actions/feedback';
import { FeedbackForm } from './FeedbackForm';

// A Server Component biztosítja a progresszív fejlesztést
export default function FeedbackPage() {
  return (
    <div>
      <h1>Visszajelzés küldése</h1>
      <FeedbackForm action={submitFeedback} />
    </div>
  );
}

// components/FeedbackForm.tsx
'use client';

import { useActionState } from 'react';
import { SubmitButton } from '@/components/SubmitButton';

type FeedbackState = {
  success: boolean;
  message: string;
};

export function FeedbackForm({
  action,
}: {
  action: (prevState: FeedbackState, formData: FormData) => Promise<FeedbackState>;
}) {
  const [state, formAction, isPending] = useActionState(action, {
    success: false,
    message: '',
  });

  return (
    <form action={formAction}>
      <textarea
        name="feedback"
        placeholder="Írd le a véleményed..."
        required
        minLength={10}
      />

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

      <SubmitButton
        label="Visszajelzés küldése"
        pendingLabel="Küldés folyamatban..."
      />
    </form>
  );
}

A kulcs itt az, hogy a formAction — amit a useActionState ad vissza — tökéletesen működik mindkét esetben. Ha JavaScript elérhető, az űrlapbeküldés aszinkron módon történik, és a felhasználó azonnal lát egy betöltési állapotot. Ha nincs JavaScript, a form hagyományos módon küldi el az adatokat, és a szerver teljes oldal-újratöltéssel válaszol. Mindkét esetben működik — ez a progresszív fejlesztés lényege.

Hibakezelés: Robusztus Server Actions minden helyzetben

Várt hibák vs. váratlan hibák

Fontos megkülönböztetni a várt és a váratlan hibákat, mert teljesen másképp kell kezelni őket:

  • Várt hibák — Validációs hibák, duplikált e-mail, érvénytelen adatok. Ezeket az action visszatérési értékében kezeljük.
  • Váratlan hibák — Adatbázis-kapcsolat megszakadása, hálózati hiba, programozási hiba. Ezeket az error.tsx Error Boundary kezeli.

Nézzünk egy komplett példát, ami mindkét típust lefedi:

// app/actions/orders.ts
'use server';

import { z } from 'zod';

const orderSchema = z.object({
  productId: z.string().uuid('Érvénytelen termékazonosító'),
  quantity: z.number().int().positive().max(100),
  shippingAddress: z.string().min(10, 'Add meg a teljes szállítási címet'),
});

type OrderResult = {
  success: boolean;
  message: string;
  orderId?: string;
  errors?: Record<string, string[]>;
};

export async function createOrder(
  prevState: OrderResult,
  formData: FormData
): Promise<OrderResult> {
  // 1. Validáció
  const parsed = orderSchema.safeParse({
    productId: formData.get('productId'),
    quantity: Number(formData.get('quantity')),
    shippingAddress: formData.get('shippingAddress'),
  });

  if (!parsed.success) {
    return {
      success: false,
      message: 'Érvénytelen adatok.',
      errors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
    };
  }

  // 2. Hitelesítés
  const session = await auth();
  if (!session?.user) {
    return {
      success: false,
      message: 'A rendeléshez be kell jelentkezned.',
    };
  }

  // 3. Üzleti logika — várt hibák kezelése
  const product = await db.query.products.findFirst({
    where: eq(products.id, parsed.data.productId),
  });

  if (!product) {
    return {
      success: false,
      message: 'A termék nem található.',
    };
  }

  if (product.stock < parsed.data.quantity) {
    return {
      success: false,
      message: `Nincs elegendő készlet. Elérhető: ${product.stock} db.`,
    };
  }

  // 4. Adatbázis művelet — váratlan hibák kezelése try-catch-csel
  try {
    const order = await db.transaction(async (tx) => {
      // Készlet csökkentése
      await tx.update(products)
        .set({ stock: product.stock - parsed.data.quantity })
        .where(eq(products.id, parsed.data.productId));

      // Rendelés létrehozása
      const [newOrder] = await tx.insert(orders).values({
        userId: session.user.id,
        productId: parsed.data.productId,
        quantity: parsed.data.quantity,
        shippingAddress: parsed.data.shippingAddress,
        status: 'pending',
      }).returning();

      return newOrder;
    });

    revalidatePath('/orders');
    revalidateTag(`product-${parsed.data.productId}`);

    return {
      success: true,
      message: 'Rendelés sikeresen leadva!',
      orderId: order.id,
    };
  } catch (error) {
    // Váratlan hiba — naplózzuk, de a felhasználónak általános üzenetet mutatunk
    console.error('Rendelés létrehozási hiba:', error);
    return {
      success: false,
      message: 'Hiba történt a rendelés feldolgozása során. Kérjük, próbáld újra.',
    };
  }
}

Error Boundary a váratlan hibákhoz

Ha egy Server Action váratlan hibát dob (amelyet nem kapsz el try-catch-csel), a Next.js Error Boundary automatikusan elkapja:

// app/orders/error.tsx
'use client';

export default function OrdersError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="error-container">
      <h2>Hiba történt</h2>
      <p>A rendelések betöltése közben hiba lépett fel.</p>
      <button onClick={reset}>Próbáld újra</button>
    </div>
  );
}

Optimista frissítések: Azonnali felhasználói visszajelzés

useOptimistic a React 19-ben

Az optimista frissítés lényege, hogy a felhasználói felületet azonnal frissítjük — még mielőtt a szerver megerősítené a műveletet. Ha a szerver hiba nélkül válaszol, a frissítés megmarad. Ha hiba történik, visszaállítjuk az eredeti állapotot. Ez adja azt az azonnali, „snappy" érzetet, amit a felhasználók annyira szeretnek.

// components/TodoList.tsx
'use client';

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

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

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (currentTodos, update: { type: 'toggle' | 'delete'; id: string }) => {
      switch (update.type) {
        case 'toggle':
          return currentTodos.map((todo) =>
            todo.id === update.id
              ? { ...todo, completed: !todo.completed }
              : todo
          );
        case 'delete':
          return currentTodos.filter((todo) => todo.id !== update.id);
        default:
          return currentTodos;
      }
    }
  );

  async function handleToggle(id: string) {
    addOptimistic({ type: 'toggle', id });
    await toggleTodo(id);
  }

  async function handleDelete(id: string) {
    addOptimistic({ type: 'delete', id });
    await deleteTodo(id);
  }

  return (
    <ul>
      {optimisticTodos.map((todo) => (
        <li key={todo.id}>
          <label>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggle(todo.id)}
            />
            <span className={todo.completed ? 'line-through' : ''}>
              {todo.text}
            </span>
          </label>
          <button onClick={() => handleDelete(todo.id)}>Törlés</button>
        </li>
      ))}
    </ul>
  );
}

Az optimista frissítés különösen jól működik olyan felületeknél, ahol a szerver válaszideje érezhető, és a felhasználó azonnali visszajelzést vár — gondolj például egy teendőlistára, kedvencek kezelésére vagy szavazásra.

Gyakorlati tippek és bevált minták

1. Szervezd logikusan a Server Action-öket

Ahogy a projekt növekszik, érdemes a Server Action-öket logikai csoportok szerint külön fájlokba szervezni. Hidd el, a jövőbeli éned hálás lesz érte:

app/
  actions/
    auth.ts        // Bejelentkezés, regisztráció, kijelentkezés
    posts.ts       // Bejegyzések CRUD műveletei
    comments.ts    // Hozzászólások kezelése
    products.ts    // Termékkezelés
    orders.ts      // Rendeléskezelés
    upload.ts      // Fájlfeltöltés

2. Kerüld az érzékeny adatok visszaküldését

A Server Action visszatérési értéke eljut a kliensre. Soha ne küldj vissza érzékeny adatokat — jelszó hash-ek, belső azonosítók, API kulcsok mind tabu:

// ROSSZ — Érzékeny adatok a válaszban
export async function getUser(userId: string) {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
  });
  return user; // Ez tartalmazza a passwordHash mezőt is!
}

// JÓ — Csak a szükséges mezőket adjuk vissza
export async function getUser(userId: string) {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
    columns: {
      id: true,
      name: true,
      email: true,
      avatarUrl: true,
    },
  });
  return user;
}

3. Rate limiting érzékeny akciókhoz

A nyilvános Server Action-ök ki vannak téve a brute-force támadásoknak. Érdemes (sőt, kötelező) rate limiting-et alkalmazni a bejelentkezés, regisztráció és hasonló érzékeny végpontoknál:

// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '60 s'), // 5 kérés / 60 másodperc
});

export async function checkRateLimit(identifier: string) {
  const { success, remaining } = await ratelimit.limit(identifier);

  if (!success) {
    throw new Error(
      'Túl sok kérés. Kérjük, várj egy percet, majd próbáld újra.'
    );
  }

  return { remaining };
}

// Használat egy Server Action-ben
'use server';

export async function loginAction(formData: FormData) {
  const email = formData.get('email') as string;

  // Rate limiting az e-mail cím alapján
  await checkRateLimit(`login:${email}`);

  // ... bejelentkezési logika
}

Összefoglalás: Server Actions a gyakorlatban

A Next.js Server Actions alapjaiban változtatják meg, ahogyan webes alkalmazásokban adatokat módosítunk. Az API route-ok manuális kezelése helyett közvetlenül a React komponensekből hívhatunk szerver oldali függvényeket — típusbiztosan, validációval és progresszív fejlesztéssel.

Foglaljuk össze a legfontosabb tanulságokat:

  • Minden Server Action egy nyilvános végpont — Mindig validáld a bemenetet és ellenőrizd a hitelesítést. Ezt nem lehet elégszer ismételni.
  • Használj Zod-ot a validációhoz — A kliens oldali validáció nem biztonsági eszköz, csak UX javítás.
  • useActionState a form állapothoz — A React 19 beépített hookja az űrlapállapot és a hibaüzenetek kezelésére.
  • revalidatePath és revalidateTag — Frissítsd a gyorsítótárat mutáció után, hogy a felhasználó friss adatokat lásson.
  • next-safe-action a nagyobb projektekhez — Middleware pipeline a hitelesítéshez, validációhoz és hibakezeléshez.
  • Progresszív fejlesztés — Az űrlapok JavaScript nélkül is működnek, de JavaScript-tel jobb felhasználói élményt nyújtanak.
  • Optimista frissítések — A useOptimistic hook azonnali visszajelzést biztosít a felhasználónak.

Ha az adatlekérdezésről szóló korábbi cikkünket már elolvastad, most a teljes képet látod: a React Server Components az olvasási oldalon, a Server Actions pedig a módosítási oldalon biztosítják a hatékony, biztonságos és felhasználóbarát adatkezelést a Next.js alkalmazásokban. Kezdj egy egyszerű űrlappal, és fokozatosan építs rá a validációt, a hitelesítést és az optimista frissítéseket — megéri az erőfeszítést.

A Szerzőről Editorial Team

Our team of expert writers and editors.