Úvod
Server Actions jsou jedním z nejdůležitějších stavebních kamenů moderního Next.js. Umožňují spouštět asynchronní kód přímo na serveru z React komponent – bez nutnosti vytvářet API endpointy, psát fetch požadavky nebo řešit serializaci dat. V Next.js 16 s React 19 jsou plně stabilní a připravené pro produkční nasazení.
Pokud jste v předchozích verzích Next.js vytvářeli API route handlery pro každou jednotlivou mutaci – přidání položky do košíku, odeslání formuláře, smazání komentáře – tak přesně tohle Server Actions zásadně zjednodušují. Místo celého řetězce klient → fetch → API route → databáze → odpověď → aktualizace stavu máte jedinou funkci, kterou zavoláte přímo z komponenty. O zbytek se postará Next.js.
V tomto průvodci projdeme Server Actions od základů až po pokročilé vzory. Ukážeme si formuláře, validaci vstupů přes Zod, správu stavu formuláře pomocí useActionState, optimistické aktualizace UI s useOptimistic, revalidaci cache a – což je asi nejdůležitější část – bezpečnostní praktiky, bez kterých nemá smysl Server Actions do produkce pouštět.
Co jsou Server Actions a jak fungují
Server Action je asynchronní funkce označená direktivou "use server", která běží výhradně na serveru. Když ji zavoláte z klientské komponenty, Next.js automaticky vytvoří POST požadavek na server, spustí funkci a vrátí výsledek zpět – vše transparentně, bez jediného řádku fetch kódu.
Definovat je můžete dvěma způsoby.
Inline definice v Server Component
Nejjednodušší varianta – definujete Server Action přímo uvnitř serverové komponenty:
// app/page.tsx (Server Component)
export default function Page() {
async function createItem(formData: FormData) {
"use server";
const name = formData.get("name") as string;
await db.items.create({ data: { name } });
}
return (
<form action={createItem}>
<input type="text" name="name" />
<button type="submit">Přidat</button>
</form>
);
}
Dedikované soubory s akcemi
Pro lepší organizaci kódu (a hlavně možnost sdílení akcí napříč komponentami) je vhodnější vytvořit dedikovaný soubor. Direktiva "use server" na začátku souboru označí všechny exportované funkce jako Server Actions:
// app/actions/items.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createItem(formData: FormData) {
const name = formData.get("name") as string;
await db.items.create({ data: { name } });
revalidatePath("/items");
}
export async function deleteItem(id: string) {
await db.items.delete({ where: { id } });
revalidatePath("/items");
}
Osobně doporučuji skupinovat akce podle domény – třeba actions/users.ts, actions/orders.ts, actions/comments.ts. Kód je pak přehlednější a při údržbě se v tom neztratíte.
Co se děje pod kapotou
Když Next.js narazí na direktivu "use server", vytvoří pro každou takovou funkci unikátní identifikátor (action ID). Na klientu se místo skutečné funkce objeví stub, který při zavolání odešle POST požadavek s tímto ID a serializovanými argumenty na server. Server požadavek přijme, dekóduje argumenty, spustí funkci a vrátí výsledek.
Celý proces proběhne v rámci jednoho roundtripu.
Důležitá věc: argumenty i návratové hodnoty Server Actions musí být serializovatelné. Nemůžete předávat funkce, třídy ani objekty s cyklickými referencemi. Tohle omezení prostě plyne z toho, že data překračují síťovou hranici mezi klientem a serverem – není se čemu divit.
Formuláře a Server Actions
Nejpřirozenější způsob, jak Server Actions používat, je přes HTML formuláře. React 19 rozšiřuje atribut action na elementu <form> tak, aby přijímal funkce – včetně Server Actions.
// app/contact/page.tsx
import { submitContactForm } from "@/app/actions/contact";
export default function ContactPage() {
return (
<form action={submitContactForm}>
<div>
<label htmlFor="name">Jméno</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">E-mail</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="message">Zpráva</label>
<textarea id="message" name="message" rows={5} required />
</div>
<button type="submit">Odeslat</button>
</form>
);
}
Tenhle přístup má jednu skvělou vlastnost – progressive enhancement. Formulář funguje i bez JavaScriptu. Pokud se JS nenačte (pomalé připojení, starší prohlížeč), odešle se jako běžný HTML POST požadavek a stránka se načte znovu. Jakmile se JavaScript aktivuje, odeslání proběhne asynchronně bez reloadu. Prostě to funguje v obou případech.
Server Action mimo formulář
Server Actions ale nemusíte používat jen s formuláři. Klidně je volejte z event handlerů nebo efektů v klientských komponentách:
"use client";
import { deleteItem } from "@/app/actions/items";
export function DeleteButton({ id }: { id: string }) {
const handleDelete = async () => {
const confirmed = window.confirm("Opravdu chcete smazat tuto položku?");
if (confirmed) {
await deleteItem(id);
}
};
return (
<button onClick={handleDelete}>
Smazat
</button>
);
}
Správa stavu formuláře s useActionState
V praxi formuláře potřebují víc než jen odeslání dat. Potřebujete zobrazovat chybové zprávy, indikovat stav odesílání a reagovat na výsledek akce. Přesně k tomu slouží hook useActionState z Reactu 19.
Hook přijímá Server Action a počáteční stav. Vrací trojici: aktuální stav, funkci pro formulářový action a boolean indikující, jestli zrovna probíhá odesílání.
"use client";
import { useActionState } from "react";
import { createUser } from "@/app/actions/users";
const initialState = {
message: "",
errors: {} as Record<string, string[]>,
};
export function SignupForm() {
const [state, formAction, isPending] = useActionState(
createUser,
initialState
);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Jméno</label>
<input id="name" name="name" type="text" />
{state.errors?.name && (
<p className="error">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email">E-mail</label>
<input id="email" name="email" type="email" />
{state.errors?.email && (
<p className="error">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password">Heslo</label>
<input id="password" name="password" type="password" />
{state.errors?.password && (
<p className="error">{state.errors.password[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Odesílám..." : "Registrovat"}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
Pozor na jeden důležitý detail: při použití useActionState se signatura Server Action změní. Jako první parametr přibyde prevState (předchozí stav), až druhý je formData. Tohle lidi často zaskočí.
// app/actions/users.ts
"use server";
export async function createUser(prevState: any, formData: FormData) {
// prevState obsahuje předchozí návratovou hodnotu
// formData obsahuje data z formuláře
const name = formData.get("name") as string;
const email = formData.get("email") as string;
// ... validace a uložení
return {
message: "Uživatel vytvořen",
errors: {},
};
}
Indikace odesílání pomocí useFormStatus
Pokud potřebujete zobrazit stav odesílání v komponentě uvnitř formuláře (typicky u submit tlačítka), použijte hook useFormStatus z react-dom. Tenhle hook ale musí být v komponentě, která je potomkem elementu <form> – jinak nebude fungovat:
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Odesílám..." : label}
</button>
);
}
Validace vstupů pomocí Zod
Tak, a tady přichází klíčový moment. TypeScript typy existují pouze v době kompilace – za běhu zmizí. Pokud vaše Server Action přijímá FormData a vy si hodnoty prostě přetypujete pomocí as string, spoléháte se na to, že klient pošle správná data.
Jenže Server Actions jsou veřejné POST endpointy. Kdokoliv může odeslat libovolná data přímo přes cURL nebo Postman. A to je problém.
Zod tohle řeší elegantně. Definujete schéma očekávaných dat a Zod za běhu ověří, že vstup odpovídá. Pokud ne, vrátí strukturované chybové zprávy – žádné hádání, co se pokazilo.
// lib/schemas/user.ts
import { z } from "zod";
export const createUserSchema = z.object({
name: z
.string()
.min(2, "Jméno musí mít alespoň 2 znaky")
.max(100, "Jméno je příliš dlouhé"),
email: z
.string()
.email("Zadejte platnou e-mailovou adresu"),
password: z
.string()
.min(8, "Heslo musí mít alespoň 8 znaků")
.regex(/[A-Z]/, "Heslo musí obsahovat velké písmeno")
.regex(/[0-9]/, "Heslo musí obsahovat číslici"),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
Schéma pak použijete v Server Action:
// app/actions/users.ts
"use server";
import { createUserSchema } from "@/lib/schemas/user";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
type ActionState = {
message: string;
errors: Record<string, string[]>;
};
export async function createUser(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
};
const validated = createUserSchema.safeParse(rawData);
if (!validated.success) {
return {
message: "",
errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
};
}
try {
await db.users.create({
data: {
name: validated.data.name,
email: validated.data.email,
password: await hashPassword(validated.data.password),
},
});
} catch (error) {
return {
message: "Nepodařilo se vytvořit účet. Zkuste to prosím znovu.",
errors: {},
};
}
revalidatePath("/users");
return { message: "Účet byl úspěšně vytvořen!", errors: {} };
}
Metoda safeParse() nikdy nevyhodí výjimku. Vrátí objekt s vlastností success a buď data (validovaná data), nebo error (strukturované chyby). To je zásadní rozdíl oproti parse(), která při nevalidním vstupu rovnou vyhodí výjimku – v kontextu Server Actions byste ji pak museli chytat ručně, což je zbytečná práce navíc.
Sdílení validačního schématu mezi klientem a serverem
Jednou z největších výhod Zod je, že schéma můžete definovat v samostatném souboru a sdílet ho mezi klientem a serverem. Klient ho použije pro okamžitou zpětnou vazbu, server jako bezpečnostní vrstvu. Jedna definice, dvě použití.
"use client";
import { createUserSchema } from "@/lib/schemas/user";
import { useActionState, useState } from "react";
import { createUser } from "@/app/actions/users";
export function SignupForm() {
const [clientErrors, setClientErrors] = useState<Record<string, string[]>>({});
const [state, formAction, isPending] = useActionState(createUser, {
message: "",
errors: {},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const formData = new FormData(e.currentTarget);
const result = createUserSchema.safeParse(
Object.fromEntries(formData.entries())
);
if (!result.success) {
e.preventDefault();
setClientErrors(
result.error.flatten().fieldErrors as Record<string, string[]>
);
return;
}
setClientErrors({});
// Formulář se odešle normálně přes formAction
};
const errors = { ...state.errors, ...clientErrors };
return (
<form action={formAction} onSubmit={handleSubmit}>
{/* ... pole formuláře s errors */}
</form>
);
}
Klientská validace zlepšuje uživatelský zážitek – uživatel dostane zpětnou vazbu okamžitě, bez čekání na server. Ale nikdy nezapomeňte na jedno pravidlo: klientská validace je pro UX, serverová validace je pro bezpečnost. Klientskou validaci může kdokoliv obejít, takže se na ni nikdy nespoléhejte jako na jedinou ochranu.
Optimistické aktualizace s useOptimistic
Při práci se Server Actions existuje přirozená latence – klient odešle požadavek, server ho zpracuje a vrátí výsledek. U jednoduchých operací jako přidání do oblíbených, toggle stavu nebo odeslání komentáře může tahle prodleva působit neohrabaně.
Hook useOptimistic z React 19 tohle řeší tak, že aktualizuje UI okamžitě, ještě před dokončením serverové akce. Uživatel vidí změnu hned a server si to řeší na pozadí.
"use client";
import { useOptimistic } from "react";
import { toggleFavorite } from "@/app/actions/favorites";
type Product = {
id: string;
name: string;
isFavorite: boolean;
};
export function ProductList({ products }: { products: Product[] }) {
const [optimisticProducts, setOptimisticProduct] = useOptimistic(
products,
(state, productId: string) =>
state.map((p) =>
p.id === productId ? { ...p, isFavorite: !p.isFavorite } : p
)
);
return (
<ul>
{optimisticProducts.map((product) => (
<li key={product.id}>
{product.name}
<form
action={async () => {
setOptimisticProduct(product.id);
await toggleFavorite(product.id);
}}
>
<button type="submit">
{product.isFavorite ? "★" : "☆"}
</button>
</form>
</li>
))}
</ul>
);
}
Když uživatel klikne na hvězdičku, UI se okamžitě aktualizuje. Server Action běží na pozadí. Pokud server odpoví úspěšně, optimistická hodnota se nahradí skutečnou. A pokud akce selže? React automaticky provede rollback a UI se vrátí do původního stavu. Žádný extra kód navíc.
Tenhle vzor je ideální pro interakce, kde uživatel očekává okamžitou odezvu – like tlačítka, přepínání stavů, přidávání do seznamu. V podstatě všude, kde by čekání na server působilo nepřirozeně.
Revalidace cache po mutacích
Po každé mutaci dat potřebujete zajistit, aby se uživateli zobrazila aktuální data. Next.js na to nabízí dva mechanismy: revalidatePath a revalidateTag.
revalidatePath
Invaliduje cache pro konkrétní cestu. Po další návštěvě se stránka vygeneruje znovu s aktuálními daty:
"use server";
import { revalidatePath } from "next/cache";
export async function updateProduct(id: string, formData: FormData) {
await db.products.update({
where: { id },
data: { name: formData.get("name") as string },
});
revalidatePath("/products"); // invaliduje seznam produktů
revalidatePath(`/products/${id}`); // invaliduje detail produktu
}
revalidateTag
Pro jemnější kontrolu můžete data označit tagy a invalidovat je selektivně. Tag může být sdílen napříč více stránkami, což je docela užitečné:
// Označení dat tagem při načítání
const products = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});
// Invalidace tagu v Server Action
"use server";
import { revalidateTag } from "next/cache";
export async function createProduct(formData: FormData) {
await db.products.create({ /* ... */ });
revalidateTag("products"); // invaliduje všechna data s tagem "products"
}
revalidateTag je vhodnější než revalidatePath, pokud stejná data zobrazujete na více stránkách. Invalidujete jeden tag a všechny stránky, které ho používají, zobrazí čerstvá data. Nemusíte přemýšlet, které všechny cesty byste museli invalidovat.
Redirect po mutaci
Běžný vzor je po úspěšné mutaci uživatele přesměrovat. Tady je ale jeden důležitý detail – volejte revalidatePath nebo revalidateTag před redirect, aby cílová stránka zobrazila aktuální data:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(prevState: any, formData: FormData) {
const post = await db.posts.create({
data: {
title: formData.get("title") as string,
content: formData.get("content") as string,
},
});
revalidatePath("/blog");
redirect(`/blog/${post.slug}`);
}
Bezpečnost Server Actions
Tohle je sekce, kterou byste měli číst dvakrát. Upřímně, možná i třikrát.
Server Actions jsou veřejné HTTP POST endpointy. Každá funkce označená "use server" je dosažitelná přímým POST požadavkem – nejen přes vaše formuláře. Útočník nepotřebuje vaši aplikaci, stačí mu znát URL a action ID. A to je důvod, proč je bezpečnost tady tak kritická.
1. Vždy ověřujte autentizaci a autorizaci
I když je formulář zobrazen pouze přihlášeným uživatelům, Server Action samotná nemá žádný kontext o tom, kdo ji volá – pokud si ho explicitně neověříte:
"use server";
import { auth } from "@/lib/auth";
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) {
throw new Error("Neautorizovaný přístup");
}
const post = await db.posts.findUnique({ where: { id: postId } });
if (post?.authorId !== session.user.id) {
throw new Error("Nemáte oprávnění smazat tento příspěvek");
}
await db.posts.delete({ where: { id: postId } });
revalidatePath("/blog");
}
Kontrola autentizace na úrovni stránky nezajišťuje bezpečnost Server Action. Stránka a akce jsou dva nezávislé endpointy – tohle si prosím zapamatujte. Ověřujte v každé akci zvlášť.
2. Nepředávejte citlivá data přes closures
Když definujete Server Action inline uvnitř komponenty, může zachytit proměnné z okolního scope (closure). Next.js tyto proměnné serializuje a pošle na klient v zašifrované podobě. Přesto se vyvarujte zachytávání citlivých dat – radši to přesuňte do samostatného souboru:
// ❌ ŠPATNĚ – secret je zachycen v closure
export default async function Page() {
const secret = process.env.API_SECRET;
async function callApi() {
"use server";
// secret je serializován a odeslán na klient (zašifrovaně)
await fetch(`https://api.example.com?key=${secret}`);
}
return <form action={callApi}>...</form>;
}
// ✅ SPRÁVNĚ – přesuňte akci do samostatného souboru
// app/actions/api.ts
"use server";
export async function callApi() {
const secret = process.env.API_SECRET; // čte se přímo na serveru
await fetch(`https://api.example.com?key=${secret}`);
}
3. Nikdy nevracejte interní chybové zprávy
Stack trace, databázové chyby nebo cesty k souborům nemají co dělat v odpovědi pro klienta. Zní to samozřejmě, ale v praxi na to spousta vývojářů zapomíná:
// ❌ ŠPATNĚ
export async function createUser(formData: FormData) {
try {
await db.users.create({ /* ... */ });
} catch (error) {
return { error: error.message }; // může prozradit strukturu DB
}
}
// ✅ SPRÁVNĚ
export async function createUser(formData: FormData) {
try {
await db.users.create({ /* ... */ });
} catch (error) {
console.error("Chyba při vytváření uživatele:", error);
return { error: "Nepodařilo se vytvořit účet. Zkuste to znovu." };
}
}
4. CSRF ochrana
Next.js automaticky porovnává origin požadavku s doménou hostitele. Pokud se neshodují, požadavek je odmítnut. Tohle funguje ve výchozím stavu, ale nespoléhejte se pouze na to. Vrstevnatá bezpečnost (defense in depth) je vždy lepší přístup.
5. Rate limiting
Server Actions nemají vestavěný rate limiting. Pokud máte akce, které provádějí nákladné operace (odesílání e-mailů, zápis do databáze), určitě implementujte omezení počtu požadavků:
"use server";
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";
export async function sendContactEmail(prevState: any, formData: FormData) {
const headersList = await headers();
const ip = headersList.get("x-forwarded-for") ?? "unknown";
const { success } = await rateLimit.limit(ip);
if (!success) {
return { error: "Příliš mnoho požadavků. Zkuste to za chvíli." };
}
// ... odeslání e-mailu
}
Typově bezpečné akce s next-safe-action
Pokud vás unavuje v každé Server Action opakovat stejný vzor – validace Zod, kontrola autentizace, error handling – tak knihovna next-safe-action (aktuálně ve verzi 8) tohle všechno abstrahuje do čisté, typově bezpečné API.
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { auth } from "@/lib/auth";
export const actionClient = createSafeActionClient();
export const authActionClient = createSafeActionClient({
middleware: async () => {
const session = await auth();
if (!session?.user) {
throw new Error("Neautorizovaný přístup");
}
return { user: session.user };
},
});
// app/actions/posts.ts
"use server";
import { authActionClient } from "@/lib/safe-action";
import { z } from "zod";
export const createPost = authActionClient
.schema(z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
}))
.action(async ({ parsedInput, ctx }) => {
// ctx.user je garantovaně definován díky middleware
const post = await db.posts.create({
data: {
title: parsedInput.title,
content: parsedInput.content,
authorId: ctx.user.id,
},
});
return { post };
});
Výhoda je jasná – validace, autentizace a error handling jsou vyřešeny na jednom místě. Každá akce pak obsahuje jenom svou specifickou logiku. A jako bonus TypeScript automaticky inferuje typy vstupů i výstupů, takže klientská komponenta přesně ví, jakou strukturu dat může očekávat.
Časté chyby a jak se jim vyhnout
Na základě zkušeností komunity (a pár vlastních bolestivých lekcí) tady jsou nejčastější úskalí práce se Server Actions.
Používání Server Actions pro čtení dat
Server Actions používají POST požadavky a jsou navrženy pro mutace – vytvoření, aktualizaci, smazání. Pro načítání dat používejte Server Components nebo Route Handlery. Volání Server Action pro čtení dat sice technicky funguje, ale porušuje sémantiku HTTP a nemůžete využít cachování.
Zapomenutí na revalidaci
Tohle je překvapivě častá chyba. Po úspěšné mutaci musíte invalidovat relevantní cache, jinak uživatel uvidí zastaralá data. Vždy volejte revalidatePath nebo revalidateTag po zápisu do databáze.
Chybějící error boundary
Pokud Server Action vyhodí neočekávanou výjimku, celý formulář může přestat fungovat. Obalte formulářové komponenty do React Error Boundary, abyste zachytili neočekávané chyby a zobrazili uživateli srozumitelnou zprávu místo bílé obrazovky.
Přehnané používání inline akcí
Inline Server Actions (definované přímo v komponentě) jsou pohodlné pro jednoduché případy. U složitějších akcí ale vedou k méně přehlednému kódu a problémům s closures. Přesuňte je do dedikovaných souborů – budoucí vy vám poděkuje.
Často kladené otázky (FAQ)
Jaký je rozdíl mezi Server Actions a API Routes v Next.js?
Server Actions jsou funkce označené "use server", které voláte přímo z React komponent bez nutnosti psát fetch požadavky. API Routes jsou tradiční REST endpointy definované v souborech route.ts. Server Actions se hodí víc pro formuláře a mutace uvnitř aplikace, API Routes jsou lepší pro veřejná API, webhooky nebo integrace s externími systémy.
Jsou Server Actions bezpečné pro produkční nasazení?
Ano, od Next.js 15 jsou plně stabilní. Next.js poskytuje vestavěnou CSRF ochranu a šifrování action ID. Klíčové ale je vždy validovat vstupy (Zod), ověřovat autentizaci a autorizaci v každé akci a nevracet citlivé chybové zprávy. Bez těchto opatření jsou stejně zranitelné jako jakýkoliv nezabezpečený API endpoint.
Mohu používat Server Actions s React Hook Form?
Ano, jde to. React Hook Form lze integrovat se Server Actions přes hook useForm s resolverem @hookform/resolvers/zod. Formulář validujete na klientu pomocí React Hook Form a Zod schématu, a při úspěšné validaci odešlete data přes Server Action. Tím získáte klientskou validaci pro UX a serverovou pro bezpečnost.
Jak řešit nahrávání souborů přes Server Actions?
Server Actions přijímají FormData, která nativně podporuje soubory. Z formData.get("file") získáte objekt typu File, který můžete přečíst pomocí arrayBuffer() nebo streamovat do úložiště. Pro větší soubory (řekněme nad 10 MB) doporučuji přímý upload do cloud storage jako S3 nebo Cloudflare R2 s presigned URL a Server Action použijte jen pro uložení metadat.
Proč mi useActionState nefunguje s existující Server Action?
Nejčastější příčinou je chybějící parametr prevState. Při použití s useActionState musí Server Action přijímat dva parametry: prevState (předchozí stav) a formData. Pokud vaše akce přijímá pouze formData, přidejte prevState jako první parametr. Tohle je zdaleka nejčastější zdroj zmatku u lidí, kteří se Server Actions teprve učí.