# Što ćete izgraditi#
Ovaj vodič prikazuje produkcijske obrasce za validaciju formi u Next.js Server Actions u App Routeru: Zod sheme, tipizirano stanje actiona, greške na razini polja, progresivno poboljšanje, optimistični UI i ograničavanje brzine.
Na kraju ćete imati ponovno iskoristive isječke koje možete kopirati u bilo koju Next.js aplikaciju, plus jasan okvir odluke kada su Server Actions pravi alat, a kada su API rute sigurniji izbor.
Interni kontekst ako migrirate ili standardizirate obrasce: checklist za migraciju na Next.js App Router, a ako vaše forme ovise o sesijama i autentikaciji: vodič za Next.js autentikaciju. Za veće sustave formi i ergonomiju na klijentu pogledajte React forme u velikom mjerilu uz React Hook Form i Zod.
# Zašto Server Actions mijenjaju arhitekturu formi#
Server Actions omogućuju da pozovete server-side funkciju izravno iz forme bez upravljanja zasebnim API endpointom i client fetch logikom. Time se smanjuje količina boilerplatea, poboljšava type safety i postaje prirodnije držati business logiku uz rute.
U praksi, Server Actions su najbolji kada:
- mutaciju pokreće vaš vlastiti UI
- trebate pristup server-only tajnama, bazi ili sesiji
- želite ugrađeno progresivno poboljšanje putem običnih HTML formi
- radije vraćate strukturirano stanje nego HTTP odgovore
Nisu čarobni štapić. I dalje morate dizajnirati validaciju, greške, zaštitu od zloupotrebe i idempotentnost na isti način kao i s API rutama.
# Server Actions vs API rute: kako odabrati#
Koristite tablicu odluke ispod kako biste odabrali pravu apstrakciju po formi.
| Zahtjev | Server Actions | API ruta |
|---|---|---|
| Koristi se samo kroz vaš Next.js UI | Najbolje odgovara | Radi, ali više boilerplatea |
| Treba vanjske potrošače, mobilne aplikacije, partnere | Nije idealno | Najbolje odgovara |
| Treba eksplicitne HTTP kodove, headere, semantiku cacheiranja | Ograničeno | Najbolje odgovara |
| Webhookovi i third-party callbackovi | Nije podržano kao javni endpoint | Najbolje odgovara |
| Streaming uploadovi ili specijalizirano parsiranje bodyja | Ograničeno | Najbolje odgovara |
| Co-locate logiku mutacije uz UI i izbjeći fetch | Najbolje odgovara | Zahtijeva fetch |
| Progresivno poboljšanje sa standardnom HTML formom | Najbolje odgovara | Zahtijeva ručno povezivanje |
| Rate limiting i kontrole zloupotrebe | Vi implementirate | Vi implementirate |
🎯 Ključna poruka: Odaberite Server Actions za mutacije unutar aplikacije, a API rute za integracijske površine, webhookove i javne HTTP ugovore.
# Checklista produkcijskih zahtjeva za forme#
Robusna forma treba konzistentno pokriti ove brige:
| Briga | Zašto je bitno | Minimalni standard |
|---|---|---|
| Server-side validacija | Client provjere se mogu zaobići | Zod shema na serveru |
| Greške na razini polja | Korisnici trebaju precizne popravke | errors.fieldName mapiranje |
| Greške na razini forme | Neočekivani kvarovi se događaju | errors._form poruka |
| Progresivno poboljšanje | Bolja pristupačnost i otpornost | Radi bez client JS-a |
| Optimistični UI | Brža percipirana izvedba | useOptimistic uz usklađivanje |
| Rate limiting | Sprječava brute force i spam | po korisniku ili po IP-u, server enforced |
| Idempotentnost | Sprječava dvostruko slanje | request key ili unique constraint |
| Logiranje i observability | Debugiranje produkcijskih kvarova | strukturirani logovi i error ID-jevi |
# Osnovni obrazac: tipizirano stanje actiona za forme#
Najčišći obrazac je da action uvijek vraća isti shape. Izbjegavajte throw za validacijske greške koje korisnik treba vidjeti. throw rezervirajte za zaista iznimne slučajeve, pa ih zatim pretvorite u grešku na razini forme.
Napravite minimalni zajednički tip:
// app/lib/forms.ts
export type FieldErrors<T> = Partial<Record<keyof T, string[]>>;
export type ActionResult<TFields, TData = unknown> =
| { ok: true; data: TData }
| { ok: false; errors: FieldErrors<TFields> & { _form?: string[] } };Ovo vam daje:
- predvidljiv render put
- jednu komponentu za prikaz grešaka
- stabilan ugovor između actiona i UI-ja
Zod shema kao izvor istine#
Definirajte Zod shemu blizu actiona. Koristite safeParse kako biste mogli vratiti strukturirane greške bez iznimki.
// app/(account)/settings/actions.ts
"use server";
import { z } from "zod";
import type { ActionResult } from "@/app/lib/forms";
const updateProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(80),
company: z.string().max(120).optional().or(z.literal("")),
});
export type UpdateProfileFields = z.infer<typeof updateProfileSchema>;
function zodToFieldErrors<T>(issues: z.ZodIssue[]) {
const errors: Record<string, string[]> = {};
for (const issue of issues) {
const key = issue.path.join(".") || "_form";
errors[key] ||= [];
errors[key].push(issue.message);
}
return errors as any;
}
export async function updateProfileAction(
prevState: ActionResult<UpdateProfileFields>,
formData: FormData
): Promise<ActionResult<UpdateProfileFields, { updatedAt: string }>> {
const raw = {
name: String(formData.get("name") || ""),
company: String(formData.get("company") || ""),
};
const parsed = updateProfileSchema.safeParse(raw);
if (!parsed.success) {
return { ok: false, errors: zodToFieldErrors(parsed.error.issues) };
}
try {
// Replace with your auth and db calls
// await requireUser();
// await db.user.update(...)
return { ok: true, data: { updatedAt: new Date().toISOString() } };
} catch (e) {
return { ok: false, errors: { _form: ["Something went wrong. Please try again."] } };
}
}Ovo je namjerno dosadno. Dosadno je dobro u produkciji.
💡 Savjet: Držite sheme stroge i eksplicitne. Radije koristite
z.string().trim()plus.min(...)nego custom kondicionale, jer dobivate bolje poruke greške i manje rubnih slučajeva.
# Povezivanje forme na klijentu: useActionState i ponovno iskoristiv renderer grešaka#
U App Routeru, ergonomičan client obrazac je useActionState sa stabilnim početnim stanjem.
// app/(account)/settings/ProfileForm.tsx
"use client";
import { useActionState, useEffect } from "react";
import { updateProfileAction } from "./actions";
const initialState = { ok: true as const, data: null as any };
function FieldError({ errors }: { errors?: string[] }) {
if (!errors?.length) return null;
return errors.map((e) => (
<p key={e} className="text-sm text-red-600">
{e}
</p>
));
}
export function ProfileForm() {
const [state, action, pending] = useActionState(updateProfileAction as any, initialState);
useEffect(() => {
if (state.ok) {
// Optional: toast or inline status
}
}, [state]);
const errors = state.ok ? {} : state.errors;
return (
<form action={action} className="space-y-4" noValidate>
{!state.ok && <FieldError errors={errors._form} />}
<div>
<label className="block text-sm font-medium">Name</label>
<input name="name" className="border p-2 w-full" />
<FieldError errors={errors.name} />
</div>
<div>
<label className="block text-sm font-medium">Company</label>
<input name="company" className="border p-2 w-full" />
<FieldError errors={errors.company} />
</div>
<button className="border px-4 py-2" disabled={pending}>
{pending ? "Saving..." : "Save"}
</button>
</form>
);
}Dva ključna detalja:
noValidateisključuje nedosljedne browser popupove za validaciju, pa vi kontrolirate UX.- Greške se renderiraju na temelju vraćenog stanja, a ne iznimki.
# Progresivno poboljšanje: neka radi i bez client JavaScripta#
Server Actions već podržavaju plain slanje forme. Ako korisnik ima isključen JS, browser i dalje šalje formu, server izvrši action, a stranica se navigira s rezultatom.
Da progresivno poboljšanje ostane snažno:
- izbjegavajte client-only obaveznu logiku za prikaz uspjeha ili grešaka
- kad je moguće, ponudite server-rendered poruke uspjeha
- razmislite o redirect-after-success za kritične forme
Čest produkcijski obrazac je redirect na uspjeh i query param poput ?saved=1 ili flash poruka preko cookieja. To izbjegava duple submitove pri refreshu.
// app/(account)/settings/actions.ts
"use server";
import { redirect } from "next/navigation";
export async function updateProfileAction(prev: any, formData: FormData) {
// validation and db write ...
redirect("/settings?updated=1");
}ℹ️ Napomena: Vraćanje stanja je odlično za inline validacijske greške. Redirect je odličan za uspješne tokove koji trebaju biti idempotentni i sigurni pri refreshu.
# Rukovanje greškama: validacija, auth, DB i nepoznati kvarovi#
Tretirajte greške kao četiri kategorije, svaka sa svojom strategijom:
| Tip greške | Primjer | Što korisnik vidi | Što logirate |
|---|---|---|---|
| Validacija | ime prekratko | greške po poljima | opcionalno |
| Auth i dozvole | nema sesije, pogrešna uloga | poruka na razini forme ili redirect | događaj s korisnikom i rutom |
| Očekivane domain greške | email je već zauzet | greška na polju email | događaj s error kodom |
| Neočekivani kvarovi | DB nedostupna, bug | generička greška forme | stack trace i request ID |
Obrazac: domain greške kao tipizirani rezultati#
Izbjegavajte throw za domain konflikte. Umjesto toga vratite predvidljive greške.
// Example inside a Server Action
if (emailAlreadyUsed) {
return { ok: false, errors: { email: ["That email is already in use."] } };
}Obrazac: sigurna generička poruka za nepoznate kvarove#
Ne izlažite interne poruke UI-ju. Držite output grešaka stabilnim, a detalje logirajte odvojeno.
⚠️ Upozorenje: Vraćanje sirovih grešaka baze u klijent često otkriva nazive tablica, nazive unique indeksa i detalje implementacije. Tretirajte to kao rizik od otkrivanja informacija.
# Optimistični UI sa Server Actions: dva provjerena pristupa#
Optimistični UI najviše vrijedi za:
- lagane mutacije poput togglea, lajkova, stavki u checklisti
- višekoračne tokove gdje čekanje ubija momentum
Server Actions i dalje mogu biti optimistične, ali trebate logiku usklađivanja kada server odbije mutaciju.
Pristup A: useOptimistic za lokalne izmjene liste#
Ovo dobro radi za dodavanje stavki u listu.
"use client";
import { useOptimistic, useTransition } from "react";
import { addTodoAction } from "./actions";
export function TodoForm({ initialTodos }: { initialTodos: string[] }) {
const [isPending, startTransition] = useTransition();
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, newItem: string) => [...state, newItem]
);
return (
<div>
<ul>
{optimisticTodos.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
<form
action={(fd) => {
const title = String(fd.get("title") || "");
addOptimisticTodo(title);
startTransition(async () => {
await addTodoAction(fd);
});
}}
>
<input name="title" className="border p-2" />
<button disabled={isPending} className="border px-3 py-2">
Add
</button>
</form>
</div>
);
}Ovo je namjerno jednostavno: optimistično doda, a zatim pusti da server revalidira stvarnu listu kroz navigaciju ili pattern-e invalidacije cachea.
Pristup B: optimistični state submit gumba i inline uspjeh#
Kod “teških” formi, optimistični UI je manje o podacima, a više o responzivnosti:
- onemogućite submit dok je pending
- prikažite indikator spremanja
- držite inpute stabilnima
- prikažite stanje uspjeha bez “skakanja” layouta
Koristite useActionState plus mali banner za uspjeh, i brišite inpute tek nakon uspjeha.
# Ograničavanje brzine (rate limiting) za Server Actions (i zašto nije opcionalno)#
Svaku formu koju se može zloupotrijebiti treba rate limitati:
- login i reset lozinke
- kontakt forme
- invite tokovi
- signup i ponovna slanja email verifikacije
Osnovni cilj je ograničiti pokušaje po korisniku ili po IP-u. Tipična početna točka:
- auth tokovi: 5 do 10 pokušaja po 10 minuta
- kontakt i lead forme: 3 do 5 slanja po satu po IP-u
- komentiranje: 10 u minuti po korisniku za “power” korisnike, manje za anonimne
Točni brojevi ovise o vašem businessu, ali ključno je da limiti postoje.
Minimalni obrazac rate limitera uz Redis#
Ovaj isječak pretpostavlja da imate Redis klijent dostupan na serveru. Koristite bilo kojeg providera s atomskim incrementom i expiryjem.
// app/lib/rate-limit.ts
type RateLimitConfig = { key: string; limit: number; windowSec: number };
export async function rateLimit(cfg: RateLimitConfig) {
// Pseudocode: replace with your Redis client
// const count = await redis.incr(cfg.key);
// if (count === 1) await redis.expire(cfg.key, cfg.windowSec);
const count = 1; // placeholder
const remaining = Math.max(0, cfg.limit - count);
return {
ok: count <= cfg.limit,
remaining,
};
}Zatim ga primijenite na početku Server Actiona:
"use server";
import { rateLimit } from "@/app/lib/rate-limit";
export async function contactAction(prev: any, formData: FormData) {
const ip = "ip-placeholder";
const rl = await rateLimit({ key: `contact:${ip}`, limit: 5, windowSec: 3600 });
if (!rl.ok) {
return { ok: false, errors: { _form: ["Too many attempts. Try again later."] } };
}
// Validate, store, send email...
return { ok: true, data: null };
}U produkciji biste trebali koristiti stvarni izvor IP-a i, za autentificirane korisnike, preferirati key po user ID-u jer NAT može uzrokovati da više korisnika dijeli isti IP.
# Ponovno iskoristiv obrazac: sigurno i konzistentno parsirajte FormData#
FormData dolazi kao string | File | null. Produkcijski bugovi često nastaju zbog pretpostavke da vrijednost postoji ili da je broj.
Koristite male helper funkcije koje centraliziraju coercion:
// app/lib/formdata.ts
export function fdString(fd: FormData, key: string) {
return String(fd.get(key) || "").trim();
}
export function fdNumber(fd: FormData, key: string) {
const raw = String(fd.get(key) || "").trim();
const n = Number(raw);
return Number.isFinite(n) ? n : NaN;
}Uparite to sa Zod preprocessingom ako treba. Preferirajte da Zod bude izvor istine za coercion, ali helperi drže action kod čitljivim.
# Idempotentnost i dvostruki submitovi#
Korisnici dvaput kliknu. Mreže retryaju. Browseri u nekim rubnim slučajevima ponovno POSTaju pri back-forward navigaciji. Za svaki action koji kreira zapise ili okida vanjske side effecte, dodajte idempotentnost.
Dva pragmatična pristupa:
- 1Dodajte skriveno polje
requestIdi nametnite jedinstvenost u bazi. - 2Koristite unique constraint na “prirodnom” ključu, pa constraint grešku prevedite u grešku na razini polja.
Ako već imate auth, korištenje stabilnog requestId po submitu je jednostavno.
"use client";
export function RequestIdInput() {
const id = crypto.randomUUID();
return <input type="hidden" name="requestId" value={id} />;
}Spremite requestId uz mutaciju i odbijte duplikate.
# Kada i dalje trebate API rute u aplikaciji sa Server Actions#
Čak i ako standardizirate Server Actions za UI forme, API rute su i dalje važne za:
- webhookove iz Stripea, GitHuba i sličnih
- javne endpointove koje koriste mobilne aplikacije
- long-running taskove i endpointove za ingestion u queue
- multi-tenant integracije sa signed requestovima
- specijalizirane headere i tokove verifikacije potpisa (signature verification)
Server Actions mogu koegzistirati s API rutama. Ključ je konzistentnost: koristite iste Zod sheme i domain logiku kako ne biste “forkali” validaciju i rukovanje greškama.
# Praktičan primjer: cijeli produkcijski tok kontakt forme#
Kontakt forma je dobar stress test jer privlači spam i treba progresivno poboljšanje.
Preporučeni stack:
- Zod server validacija
- rate limiting po IP-u
- honeypot polje za osnovno filtriranje botova
- spremanje prijave u bazu i enqueue slanja emaila kroz background worker, ili barem fail-safe ponašanje
Možete krenuti s:
- honeypot inputom naziva
websitekoji ljudi nikad ne popunjavaju - ako sadrži tekst, vratite uspjeh bez da išta napravite
To smanjuje signal botovima bez davanja povratne informacije napadačima.
# Česte zamke sa Server Actions formama#
- 1Validacija samo na klijentu — napadači je mogu zaobići direktnim POST-om ili modificiranom formom.
- 2Throwanje validacijskih grešaka — gubite mapiranje na razini polja i završite s generičkim kvarovima.
- 3Vraćanje nekonzistentnih shapeova — UI postaje labirint uvjetnog renderiranja.
- 4Neobrađeno optimistično usklađivanje — korisnici vide fantomske stavke ili pogrešne brojke.
- 5Bez rate limitinga — prije ili kasnije dobit ćete automatizirani spam ili brute force pokušaje.
# Ključne poruke#
- Koristite Zod
safeParseu Server Actions i vraćajte tipiziranu mapuerrorsza pouzdane poruke na razini polja. - Standardizirajte jedan shape rezultata actiona s
ok,dataierrors._formkako biste izbjegli grananje UI-ja. - Preferirajte redirect-after-success za kritične forme kako biste spriječili duple submitove i ponovna slanja pri refreshu.
- Dodajte optimistični UI s
useOptimisticza liste i pending stanja suseActionStateza “teške” forme, zatim uskladite s rezultatima servera. - Rate limitajte sve forme sklone zloupotrebi po user ID-u ili IP-u prije upisa u bazu i vraćajte sigurne greške na razini forme.
- Koristite API rute za webhookove i vanjske potrošače, a Server Actions za mutacije unutar aplikacije tijesno povezane s UI-jem.
# Zaključak#
Server Actions mogu pogoniti forme produkcijske kvalitete u Next.js App Routeru, ali samo ako ih tretirate kao prave backend endpointove: stroga server validacija, strukturirane greške, idempotentnost i rate limiting.
Ako želite da Samioda napravi audit vaših trenutnih tokova formi ili standardizira obrasce kroz Next.js codebase, kontaktirajte nas i pomoći ćemo vam isporučiti forme koje su brze, sigurne i održive.
FAQ
Više iz kategorije Web razvoj
Sve →Next.js + Supabase RLS za multi‑tenant SaaS: pravila, uloge i siguran pristup podacima
Praktičan vodič za Next.js App Router i Supabase Row Level Security za multi-tenant SaaS: dizajn tablica, pravila, uloge, obrasci pristupa na serveru, česte zamke i kontrolna lista za deployment.
Dinamične Open Graph slike u Next.js-u: generiranje OG slika, predmemoriranje, fontovi i savjeti za Edge runtime
Praktičan vodič za 2026. o dinamičnim Open Graph slikama u Next.js-u: generiranje OG slika po stranici, učitavanje fontova, cache headeri, Edge runtime zamke i rješavanje problema pri deployu.
Kontrolni popis za tehnički SEO audit u Next.js-u (App Router): indeksiranje, metapodaci, Core Web Vitals i strukturirani podaci
Kontrolni popis za tehnički SEO audit u Next.js-u (App Router) korak po korak: kontrole crawlanja i indeksiranja, metapodaci i kanonikali, sitemapovi i robots, paginacija, Core Web Vitals te JSON-LD schema s kodom koji možete kopirati i zalijepiti.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
Next.js + Supabase RLS za multi‑tenant SaaS: pravila, uloge i siguran pristup podacima
Praktičan vodič za Next.js App Router i Supabase Row Level Security za multi-tenant SaaS: dizajn tablica, pravila, uloge, obrasci pristupa na serveru, česte zamke i kontrolna lista za deployment.
Next.js učitavanje datoteka kako treba: izravno na S3 i Cloudflare R2 s presigned URL-ovima, validacijom i sigurnošću
Praktičan vodič za 2026. o izradi sigurnih i pouzdanih izravnih uploadova u object storage u Next.js App Routeru uz presigned URL-ove, serversku validaciju, rukovanje ponovnim pokušajima i opcionalno antivirusno skeniranje.
Next.js multitenant SaaS arhitektura: modeli tenancije, rutiranje, autentikacija i izolacija podataka (Vodič za 2026.)
Praktičan vodič za Next.js multitenant SaaS arhitekturu: modeli tenancije, tenant-aware rutiranje uz App Router i middleware, obrasci autentikacije te učvršćivanje izolacije podataka kako bi se spriječila curenja.