# Š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#
| Zahtjev | Verzija | Napomene |
|---|---|---|
| Next.js | 15+ | App Router, Server Actions uključeni |
| React | 19+ | useActionState i moderni obrasci za forme |
| TypeScript | 5.5+ | Preporučeno za inferenciju iz shema |
| React Hook Form | 7.50+ | Dobro radi sa Zod resolverom |
| Zod | 3.23+ | Validacija i inferencija shema |
# Pregled arhitekture: jedna shema, dva konteksta izvršavanja#
Skalabilna postava obrazaca obično se dijeli na četiri sloja:
| Sloj | Gdje se izvršava | Odgovornost | Mora biti pouzdano |
|---|---|---|---|
| UI + RHF | Klijent | Stanje inputa, trenutni feedback, pristupačnost | Ne |
| Zajednička Zod shema | Klijent i server | Format i poslovna pravila koja ne traže tajne | Djelomično |
| Server Action | Server | Autentikacija, autorizacija, upisi u DB, finalna validacija | Da |
| DB i storage | Server i provideri | Perzistencija, jedinstvenost, integritet | Da |
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:
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:
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: truesdataok: falsesfieldErrorsi opcionalnimformError
To čini klijentski kod predvidljivim i sprječava jednokratna (one-off) rukovanja greškama.
Kreirajte src/lib/action-result.ts:
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:
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:
- 1Autentificirajte korisnika
- 2Parsirajte i validirajte input
- 3Izvedite server-only provjere
- 4Zapišite u DB
- 5Vratite normalizirani rezultat
Kreirajte src/features/profile/profile.actions.ts:
"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):
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:
"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-invalidiaria-describedbyna temeljuformState.errorsradi 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:
"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.
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:
- 1Server Action autorizira upload i vraća presigned URL
- 2Klijent uploada direktno u object storage
- 3Klijent pošalje dobiveni
avatarKeyu glavni action - 4Server 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#
| Polje | Tip | Odakle dolazi | Sprema se u DB |
|---|---|---|---|
avatarKey | string | Nakon uspješnog uploada | Da |
avatarMime | string | Opcionalno s klijenta | Opcionalno, treba provjeriti |
avatarSize | number | Opcionalno s klijenta | Opcionalno, 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:
"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ške | Gdje se prikazuje | Primjer | Kako obraditi |
|---|---|---|---|
| Greška polja | Ispod inputa | “Email nije ispravan” | setError(field) |
| Greška forme | Vrh ili dno forme | “Morate biti prijavljeni” | setError("root") |
| Toast / globalno | Izvan forme | “Server nedostupan” | Upute za ponovni pokušaj |
| Samo logiranje | Ne prikazuje se korisniku | Stack trace | Pošaljite u alat za logiranje |
Normalizirajte neočekivane kvarove#
U server actionu, obavijte DB upise i vratite sigurnu poruku:
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:
pendingizuseActionStateza mutacije prema serveru koje su u tijekuform.formState.isSubmittingza RHF-kontrolirane flowove- Opcionalno:
form.formState.isValidza 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
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
Vraćanje nedosljednih oblika grešaka kroz actions
Koristite tipiziraniActionResulti uvijek vraćajtefieldErrorsiformError. - 3
Pokušaj uploada datoteka kroz Server Actions
Koristite presigned URL-ove i kroz formu šaljite samo ključeve. - 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
fieldErrorsiformError, pa ga mapirajte u RHF prekosetError. - Implementirajte progressive enhancement korištenjem
actionatributa 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
Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.
Više iz kategorije Web razvoj
Sve →React aplikacije prilagođene radu offline uz TanStack Query: perzistencija, ponovni pokušaji i optimistični UI (vodič za 2026.)
Izgradite otporan offline-first UX uz TanStack Query: offline perzistencija za React Query, strategije ponovnih pokušaja i backoffa, sigurni optimistični updateovi, obrasci djelomičnog offline rada i testiranje s MSW-om.
Vodič za virtualizaciju u Reactu: Windowing velikih lista i gridova s TanStack Virtual (i kada to ne raditi)
Praktičan vodič za 2026. za virtualizaciju u Reactu s TanStack Virtual: izgradite brze liste i gridove, dodajte beskonačno učitavanje i sticky zaglavlja, profilirajte mjerljiva poboljšanja i izbjegnite česte rubne slučajeve poput dinamičkih visina redaka i zamki pristupačnosti.
Next.js Supabase Realtime u 2026: End-to-End nacrt za chat, presence i suradnju
Izgradite production-ready realtime UI s Next.js App Routerom i Supabase Realtime: dizajn sheme, RLS, optimistička ažuriranja, presence, skaliranje i rješavanje problema s dupliciranim eventima i neusklađenim dozvolama.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
Server Actions u Next.js App Routeru: produkcijski obrasci za validaciju, greške i optimistični UI
Vodič spreman za produkciju za validaciju formi u Next.js Server Actions uz Zod, strukturirano rukovanje greškama, progresivno poboljšanje, optimistični UI i ograničavanje brzine — plus kada odabrati Server Actions umjesto API ruta.
React obrasci u velikim aplikacijama: React Hook Form + Zod obrasci za složene proizvode
Najbolje prakse za React obrasce u velikim aplikacijama uz React Hook Form i Zod: validacija po shemi, višekratno upotrebljiva polja, asinkrone provjere, višekoračni tokovi, performanse, pristupačnost i obrasci integracije sa serverom/API-jem.
UX obrasci u Next.js App Routeru: Error Boundaries, Loading UI i streaming kako treba
Praktičan vodič za otporan UX u Next.js App Routeru: struktura route segmenata, error.tsx, loading.tsx, not-found.tsx i Suspense streaming obrasci za djelomično renderiranje, sigurnije dohvaćanje podataka i manje pomaka layouta.