Web razvoj
Next.jsServer ActionsFormeZodApp RouterSigurnostUX

Server Actions u Next.js App Routeru: produkcijski obrasci za validaciju, greške i optimistični UI

AO
Adrijan Omićević
·13 min čitanja

# Š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.

ZahtjevServer ActionsAPI ruta
Koristi se samo kroz vaš Next.js UINajbolje odgovaraRadi, ali više boilerplatea
Treba vanjske potrošače, mobilne aplikacije, partnereNije idealnoNajbolje odgovara
Treba eksplicitne HTTP kodove, headere, semantiku cacheiranjaOgraničenoNajbolje odgovara
Webhookovi i third-party callbackoviNije podržano kao javni endpointNajbolje odgovara
Streaming uploadovi ili specijalizirano parsiranje bodyjaOgraničenoNajbolje odgovara
Co-locate logiku mutacije uz UI i izbjeći fetchNajbolje odgovaraZahtijeva fetch
Progresivno poboljšanje sa standardnom HTML formomNajbolje odgovaraZahtijeva ručno povezivanje
Rate limiting i kontrole zloupotrebeVi implementirateVi 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:

BrigaZašto je bitnoMinimalni standard
Server-side validacijaClient provjere se mogu zaobićiZod shema na serveru
Greške na razini poljaKorisnici trebaju precizne popravkeerrors.fieldName mapiranje
Greške na razini formeNeočekivani kvarovi se događajuerrors._form poruka
Progresivno poboljšanjeBolja pristupačnost i otpornostRadi bez client JS-a
Optimistični UIBrža percipirana izvedbauseOptimistic uz usklađivanje
Rate limitingSprječava brute force i spampo korisniku ili po IP-u, server enforced
IdempotentnostSprječava dvostruko slanjerequest key ili unique constraint
Logiranje i observabilityDebugiranje produkcijskih kvarovastrukturirani 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:

TypeScript
// 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.

TypeScript
// 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.

TSX
// 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:

  • noValidate isključ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.

TypeScript
// 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škePrimjerŠto korisnik vidiŠto logirate
Validacijaime prekratkogreške po poljimaopcionalno
Auth i dozvolenema sesije, pogrešna ulogaporuka na razini forme ili redirectdogađaj s korisnikom i rutom
Očekivane domain greškeemail je već zauzetgreška na polju emaildogađaj s error kodom
Neočekivani kvaroviDB nedostupna, buggenerička greška formestack trace i request ID

Obrazac: domain greške kao tipizirani rezultati#

Izbjegavajte throw za domain konflikte. Umjesto toga vratite predvidljive greške.

TypeScript
// 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.

TSX
"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.

TypeScript
// 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:

TypeScript
"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:

TypeScript
// 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:

  1. 1
    Dodajte skriveno polje requestId i nametnite jedinstvenost u bazi.
  2. 2
    Koristite 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.

TSX
"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 website koji 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#

  1. 1
    Validacija samo na klijentu — napadači je mogu zaobići direktnim POST-om ili modificiranom formom.
  2. 2
    Throwanje validacijskih grešaka — gubite mapiranje na razini polja i završite s generičkim kvarovima.
  3. 3
    Vraćanje nekonzistentnih shapeova — UI postaje labirint uvjetnog renderiranja.
  4. 4
    Neobrađeno optimistično usklađivanje — korisnici vide fantomske stavke ili pogrešne brojke.
  5. 5
    Bez rate limitinga — prije ili kasnije dobit ćete automatizirani spam ili brute force pokušaje.

# Ključne poruke#

  • Koristite Zod safeParse u Server Actions i vraćajte tipiziranu mapu errors za pouzdane poruke na razini polja.
  • Standardizirajte jedan shape rezultata actiona s ok, data i errors._form kako 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 useOptimistic za liste i pending stanja s useActionState za “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

Share
A
Adrijan OmićevićSamioda Team
All articles →

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.