Web razvoj
Next.jsReactReact Hook FormZodServer ActionsValidacijaUXSigurnost

Next.js App Router obrasci u 2026.: React Hook Form + Zod + Server Actions (validacija, greške, UX)

AO
Adrijan Omićević
·13 min čitanja

# Što ćete izgraditi#

Ovaj vodič prikazuje robusnu, ponovljivu arhitekturu za izradu obrazaca u Next.js App Routeru u 2026. uz:

  • React Hook Form za performantno klijentsko stanje i pristupačne inpute
  • Zod za validaciju zajedničkih shema i inferenciju tipova
  • Server Actions za sigurne mutacije i autoritativnu validaciju
  • Dosljedno rukovanje greškama kroz klijent i server, s jednim oblikom rezultata
  • Progresivno poboljšanje (progressive enhancement), tako da obrazac radi i bez JavaScripta
  • Uzorci za upload datoteka, async validaciju te UX-friendly stanja učitavanja i grešaka

Ako već isporučujete obrasce, ovo je postava koja sprječava uobičajene “kvarove” iz 2026.: nedosljedne formate grešaka, dupliciranu validacijsku logiku, propusne flowove za upload datoteka i server actions koje s vremenom postanu netestabilne “blob” funkcije.

Za dublju pozadinu ovih gradivnih elemenata, pogledajte:

# Preduvjeti#

ZahtjevVerzijaNapomene
Next.js15+App Router, Server Actions uključeni
React19+useActionState i moderni obrasci za forme
TypeScript5.5+Preporučeno za inferenciju iz shema
React Hook Form7.50+Dobro radi sa Zod resolverom
Zod3.23+Validacija i inferencija shema

# Pregled arhitekture: jedna shema, dva konteksta izvršavanja#

Skalabilna postava obrazaca obično se dijeli na četiri sloja:

SlojGdje se izvršavaOdgovornostMora biti pouzdano
UI + RHFKlijentStanje inputa, trenutni feedback, pristupačnostNe
Zajednička Zod shemaKlijent i serverFormat i poslovna pravila koja ne traže tajneDjelomično
Server ActionServerAutentikacija, autorizacija, upisi u DB, finalna validacijaDa
DB i storageServer i provideriPerzistencija, jedinstvenost, integritetDa

Ključno je da se iz actiona vraća jedan normalizirani oblik rezultata. To je ono što rukovanje greškama čini dosljednim i održivim.

🎯 Ključna poruka: Validaciju na klijentu tretirajte kao UX, validaciju na serveru kao sigurnost, i neka dijele jednu shemu i jedan format greške.

# Korak 1: Definirajte zajedničke sheme i tipove#

Krenite sa shemom koju možete sigurno ponovno koristiti i na klijentu i na serveru: provjere duljine, formatiranja i cross-field pravila koja ne zahtijevaju pristup bazi.

Kreirajte src/features/profile/profile.schemas.ts:

TypeScript
import { z } from "zod";
 
export const profileSchema = z.object({
  fullName: z
    .string()
    .trim()
    .min(2, "Puno ime mora imati najmanje 2 znaka")
    .max(80, "Puno ime može imati najviše 80 znakova"),
  email: z
    .string()
    .trim()
    .email("Unesite ispravnu email adresu")
    .max(254, "Email je predugačak"),
  bio: z
    .string()
    .trim()
    .max(500, "Bio može imati najviše 500 znakova")
    .optional()
    .or(z.literal("")),
  avatarKey: z
    .string()
    .trim()
    .max(300, "Neispravna referenca datoteke")
    .optional()
    .or(z.literal("")),
});
 
export type ProfileInput = z.infer<typeof profileSchema>;

Dodajte server-only validaciju bez narušavanja ponovne upotrebe#

Provjere jedinstvenosti i provjere dozvola pripadaju serveru. Držite ih izvan zajedničke sheme kako ne biste slučajno pokrenuli DB pozive s klijenta. Koristite server-only validator koji “obavija” zajedničku shemu.

Kreirajte src/features/profile/profile.validators.server.ts:

TypeScript
import { profileSchema } from "./profile.schemas";
 
export async function validateProfileOnServer(input: unknown) {
  const parsed = profileSchema.safeParse(input);
  if (!parsed.success) return parsed;
 
  // Primjer server-only pravila: forsiraj korporativnu domenu
  if (!parsed.data.email.endsWith("@example.com")) {
    return {
      success: false as const,
      error: {
        issues: [
          { path: ["email"], message: "Koristite službenu email adresu tvrtke" },
        ],
      },
    };
  }
 
  return parsed;
}

Ovaj obrazac održava zajedničku shemu čistom, a serveru i dalje omogućuje dodavanje autoritativnih pravila.

# Korak 2: Standardizirajte rezultate actiona i mapiranje grešaka#

Želite da svaki mutation action vraća isti oblik:

  • ok: true s data
  • ok: false s fieldErrors i opcionalnim formError

To čini klijentski kod predvidljivim i sprječava jednokratna (one-off) rukovanja greškama.

Kreirajte src/lib/action-result.ts:

TypeScript
export type FieldErrors<TFields extends string> = Partial<
  Record<TFields, string>
>;
 
export type ActionResult<TData, TFields extends string> =
  | { ok: true; data: TData }
  | { ok: false; fieldErrors?: FieldErrors<TFields>; formError?: string };

Sada dodajte helper koji pretvara Zod greške u fieldErrors.

Kreirajte src/lib/zod-to-field-errors.ts:

TypeScript
import type { ZodError } from "zod";
 
export function zodToFieldErrors<TFields extends string>(err: ZodError) {
  const out: Partial<Record<TFields, string>> = {};
  for (const issue of err.issues) {
    const key = issue.path[0];
    if (typeof key === "string" && !out[key as TFields]) {
      out[key as TFields] = issue.message;
    }
  }
  return out;
}

⚠️ Upozorenje: Nemojte vraćati “raw” Zod greške klijentu. Mogu sadržavati interne putanje, neočekivane poruke i nisu stabilan API. Normalizirajte ih.

# Korak 3: Izgradite Server Action sa sigurnom validacijom#

Server Actions su vaša pouzdana granica. Sve ovo radite unutar actiona:

  1. 1
    Autentificirajte korisnika
  2. 2
    Parsirajte i validirajte input
  3. 3
    Izvedite server-only provjere
  4. 4
    Zapišite u DB
  5. 5
    Vratite normalizirani rezultat

Kreirajte src/features/profile/profile.actions.ts:

TypeScript
"use server";
 
import type { ActionResult } from "@/lib/action-result";
import { zodToFieldErrors } from "@/lib/zod-to-field-errors";
import { validateProfileOnServer } from "./profile.validators.server";
 
type Fields = "fullName" | "email" | "bio" | "avatarKey";
 
export async function updateProfileAction(
  _prev: ActionResult<{ id: string }, Fields> | null,
  formData: FormData
): Promise<ActionResult<{ id: string }, Fields>> {
  // 1) Auth (pseudo)
  const userId = "user_123";
  if (!userId) return { ok: false, formError: "Morate biti prijavljeni." };
 
  // 2) Izvucite primitivne vrijednosti
  const input = {
    fullName: String(formData.get("fullName") || ""),
    email: String(formData.get("email") || ""),
    bio: String(formData.get("bio") || ""),
    avatarKey: String(formData.get("avatarKey") || ""),
  };
 
  // 3) Validacija
  const parsed = await validateProfileOnServer(input);
  if (!parsed.success) {
    const zodErr = "error" in parsed ? parsed.error : parsed.error;
    return { ok: false, fieldErrors: zodToFieldErrors<Fields>(zodErr as any) };
  }
 
  // 4) Spremanje (pseudo)
  const updatedId = userId;
 
  return { ok: true, data: { id: updatedId } };
}

Dvije važne sigurnosne napomene:

  • Nikad nemojte vjerovati FormData. Uvijek prisilno pretvorite tipove (coerce) i validirajte.
  • Ne prihvaćajte file blobove za velike uploade kroz actions. Koristite presigned URL-ove i prihvaćajte samo ključeve datoteka.

# Korak 4: Prvo progressive enhancement, zatim React Hook Form#

Progressive enhancement znači da se obrazac može poslati i kroz čisti HTML. React Hook Form potom poboljšava UX: inline greške, async provjere, stanja gumba te očuvanje lokalnog stanja.

Server Component stranica koja renderira Client Component formu#

Kreirajte app/settings/profile/page.tsx (Server Component):

TypeScript
import { ProfileForm } from "@/features/profile/ProfileForm";
 
export default async function ProfilePage() {
  // Učitajte inicijalne vrijednosti na serveru (pseudo)
  const initialValues = {
    fullName: "Ada Lovelace",
    email: "ada@example.com",
    bio: "Gradim pouzdane sustave.",
    avatarKey: "",
  };
 
  return <ProfileForm initialValues={initialValues} />;
}

Client Component forma uz useActionState i RHF#

Kreirajte src/features/profile/ProfileForm.tsx:

TypeScript
"use client";
 
import { useEffect } from "react";
import { useActionState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
 
import { profileSchema, type ProfileInput } from "./profile.schemas";
import { updateProfileAction } from "./profile.actions";
 
type Fields = "fullName" | "email" | "bio" | "avatarKey";
 
export function ProfileForm({ initialValues }: { initialValues: ProfileInput }) {
  const [state, action, pending] = useActionState(updateProfileAction, null);
 
  const form = useForm<ProfileInput>({
    defaultValues: initialValues,
    resolver: zodResolver(profileSchema),
    mode: "onBlur",
  });
 
  useEffect(() => {
    if (!state || state.ok) return;
    if (state.fieldErrors) {
      for (const [name, message] of Object.entries(state.fieldErrors)) {
        form.setError(name as Fields, { type: "server", message });
      }
    }
    if (state.formError) {
      form.setError("root", { type: "server", message: state.formError });
    }
  }, [state, form]);
 
  return (
    <form action={action} noValidate>
      <label>
        Puno ime
        <input {...form.register("fullName")} name="fullName" />
      </label>
      <p>{form.formState.errors.fullName?.message}</p>
 
      <label>
        Email
        <input {...form.register("email")} name="email" />
      </label>
      <p>{form.formState.errors.email?.message}</p>
 
      <label>
        Bio
        <textarea {...form.register("bio")} name="bio" />
      </label>
      <p>{form.formState.errors.bio?.message}</p>
 
      <input type="hidden" {...form.register("avatarKey")} name="avatarKey" />
 
      <p>{form.formState.errors.root?.message}</p>
 
      <button type="submit" disabled={pending}>
        {pending ? "Spremanje..." : "Spremi promjene"}
      </button>
    </form>
  );
}

Ovo radi tri korisne stvari:

  • Radi bez JavaScripta jer je action i dalje handler forme.
  • RHF radi validaciju na klijentu i izbjegava rerender cijele forme na svaki pritisak tipke.
  • Greške sa servera se dosljedno mapiraju natrag u RHF greške.

💡 Savjet: Dodajte aria-invalid i aria-describedby na temelju formState.errors radi pristupačnosti. To također poboljšava konverziju za stvarne korisnike, a ne samo Lighthouse rezultate.

# Korak 5: Async validacija bez “spamanja” servera#

Async validacija je obično vezana uz provjere poput “email je već zauzet”. Ne pokrećite to na svaki pritisak tipke.

Koristite debounce na blur ili nakon što korisnik prestane tipkati, i validirajte kroz zaseban server action ili route handler. Izbjegnite spajanje (coupling) ovoga sa submit actionom.

Primjer: server action za provjeru dostupnosti#

Kreirajte src/features/profile/profile.async.actions.ts:

TypeScript
"use server";
 
export async function checkEmailAvailableAction(email: string) {
  const normalized = email.trim().toLowerCase();
  if (!normalized) return { ok: false as const, message: "Email je obavezan" };
 
  // Pseudo DB lookup
  const taken = normalized === "taken@example.com";
  if (taken) return { ok: false as const, message: "Email je već u upotrebi" };
 
  return { ok: true as const };
}

Klijentski dio: okidanje na blur#

Držite jednostavno. Provjere na blur drastično smanjuju broj poziva u odnosu na validaciju na svaki znak.

TypeScript
import { checkEmailAvailableAction } from "./profile.async.actions";
 
async function onEmailBlur(value: string) {
  const res = await checkEmailAvailableAction(value);
  if (!res.ok) {
    form.setError("email", { type: "async", message: res.message });
  }
}

Za forme u velikim sustavima, praktičan cilj je manje od 1 async poziva po polju po sesiji uređivanja. Validacija na blur to u pravilu postiže.

# Korak 6: Upload datoteka s presigned URL-ovima i server provjerom#

U produkciji upload najčešće “pukne” kad timovi pokušaju slati datoteke kroz server actions, pa nalete na limite veličine, timeoute i skokove memorije. Robustan obrazac je:

  1. 1
    Server Action autorizira upload i vraća presigned URL
  2. 2
    Klijent uploada direktno u object storage
  3. 3
    Klijent pošalje dobiveni avatarKey u glavni action
  4. 4
    Server Action provjeri da ključ pripada korisniku i da je unutar ograničenja

Za kompletan walkthrough, pogledajte: Next.js upload datoteka sa S3 ili R2 presigned URL-ovima

Upload flow: što spremiti u formu#

PoljeTipOdakle dolaziSprema se u DB
avatarKeystringNakon uspješnog uploadaDa
avatarMimestringOpcionalno s klijentaOpcionalno, treba provjeriti
avatarSizenumberOpcionalno s klijentaOpcionalno, treba provjeriti

Samo avatarKey treba poslati kroz profile formu. Sve ostalo može se verificirati server-side putem HEAD zahtjeva ili metapodataka providera, ovisno o storageu.

Minimalni presign action#

Kreirajte src/features/uploads/upload.actions.ts:

TypeScript
"use server";
 
export async function createAvatarUploadAction() {
  const userId = "user_123";
  if (!userId) return { ok: false as const, message: "Neautorizirano" };
 
  const key = `avatars/${userId}/${Date.now()}.jpg`;
 
  // Return a presigned URL (pseudo)
  const url = "https://storage.example.com/presigned-put-url";
 
  return { ok: true as const, key, url };
}

Upload na klijentu i postavljanje hidden polja#

Na klijentu, uploadajte i zatim postavite avatarKey u RHF. Submit action ostaje isti.

Praktična UX poboljšanja:

  • Prikažite progres
  • Onemogućite submit dok upload ne završi
  • Prikažite greške uploada pored avatar polja

# Korak 7: Dosljedno rukovanje greškama za UX i observability#

Forma koja “tiho” ne uspije košta novac. Industrijski benchmarkovi variraju po domeni, ali istraživanje checkout UX-a Baymard Institutea dosljedno pokazuje da su nejasne greške veliki “ubojica” konverzije u stvarnim flowovima. Čak i za interne aplikacije, nejasne greške povećavaju broj upita supportu i usporavaju timove.

Učinite greške dosljednima kroz cijelu aplikaciju:

Tip greškeGdje se prikazujePrimjerKako obraditi
Greška poljaIspod inputa“Email nije ispravan”setError(field)
Greška formeVrh ili dno forme“Morate biti prijavljeni”setError("root")
Toast / globalnoIzvan forme“Server nedostupan”Upute za ponovni pokušaj
Samo logiranjeNe prikazuje se korisnikuStack tracePošaljite u alat za logiranje

Normalizirajte neočekivane kvarove#

U server actionu, obavijte DB upise i vratite sigurnu poruku:

TypeScript
try {
  // DB write
  return { ok: true, data: { id: userId } };
} catch {
  return { ok: false, formError: "Nešto je pošlo po zlu. Pokušajte ponovno." };
}

To sprječava curenje detalja i drži ponašanje UI-ja predvidljivim.

ℹ️ Napomena: Za debugging, logirajte stvarnu grešku na serveru uz kontekst zahtjeva. Nemojte slati “raw” poruke klijentu.

# Korak 8: UX detalji koji su bitni u 2026.#

Sitni detalji u formama utječu na stopu dovršavanja više nego što većina timova očekuje. Ovo su poboljšanja s malim trudom i velikim učinkom:

Onemogućite submit na temelju RHF i action stanja#

Koristite oba signala:

  • pending iz useActionState za mutacije prema serveru koje su u tijeku
  • form.formState.isSubmitting za RHF-kontrolirane flowove
  • Opcionalno: form.formState.isValid za raniji feedback

Poruke grešaka neka budu stabilne i specifične#

Loše: “Neispravan unos.”
Dobro: “Lozinka mora imati najmanje 12 znakova” ili “Upload mora biti JPG ili PNG manji od 2 MB”.

Ne brišite formu kod server greške#

Sačuvajte korisnikov unos. Ako nakon uspjeha radite redirect, koristite poruku uspjeha (flash) ili ažurirajte UI podacima iz odgovora.

Server redirect koristite namjerno#

Za settings forme, ostanak na istoj stranici uz inline uspjeh često je bolji od redirecta. Za višekoračne flowove, redirect je obično bolji.

# Česte zamke i kako ih izbjeći#

  1. 1

    Dupliciranje shema u odvojenim client i server datotekama
    Držite zajednička pravila u jednoj Zod shemi i dodajte server-only pravila u server wrapperu.

  2. 2

    Vraćanje nedosljednih oblika grešaka kroz actions
    Koristite tipizirani ActionResult i uvijek vraćajte fieldErrors i formError.

  3. 3

    Pokušaj uploada datoteka kroz Server Actions
    Koristite presigned URL-ove i kroz formu šaljite samo ključeve.

  4. 4

    Pokretanje async validacije na svaki pritisak tipke
    Validirajte na blur ili s debounceom i cacheirajte rezultate unutar sesije.

Za više obrazaca i dublje primjere, pogledajte i:

# Ključne poruke#

  • Koristite React Hook Form za brz UX na klijentu, ali neka Server Actions budu jedini pouzdani put mutacija.
  • Držite zajedničke Zod sheme za ponovno iskoristiva pravila, a sloj server-only validacije za provjere baze ili dozvola.
  • Vraćajte jedan normalizirani oblik rezultata s fieldErrors i formError, pa ga mapirajte u RHF preko setError.
  • Implementirajte progressive enhancement korištenjem action atributa na formi, tako da slanje radi i bez JavaScripta.
  • Upload datoteka rješavajte preko presigned URL-ova, a zatim šaljite samo ključ u storageu radi server-side provjere.
  • Koristite async validaciju na blur kako biste spriječili pretjerane pozive serveru, a i dalje rano hvatali probleme jedinstvenosti.

# Zaključak#

Forma spremna za produkciju u Next.js App Routeru u 2026. nije pitanje biranja između validacije na klijentu ili serveru. Radi se o kombiniranju RHF-a za UX, Zod-a za zajednička pravila i Server Actions za sigurnost, uz standardizirano rukovanje greškama kako bi se svaka forma ponašala jednako.

Ako želite da Samioda implementira ovu arhitekturu kroz vašu aplikaciju, uključujući upload datoteka, async validaciju i ojačane server-side provjere, kontaktirajte nas — pregledat ćemo vaše postojeće forme i brzo isporučiti dosljedan obrazac.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

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