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.tsxError 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
useOptimistichook 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.