Web razvoj
Next.jsReactApp RouterServer ActionsZodObrasciPristupačnost

Izrada višekoračnog čarobnjaka u Next.js App Routeru uz Server Actions + Zod (bez dodatnog API sloja)

AO
Adrijan Omićević
·16 min čitanja

# Što ćete izgraditi#

Višekoračni čarobnjak u Next.js App Routeru koji šalje svaki korak putem Server Actions, validira sa Zod‑om i sprema stanje nacrta bez dodavanja API ruta.

Vidjet ćete 3 pristupa implementaciji, kako odabrati između njih i kako isporučiti produkcijske osnove poput optimističnog UX‑a, rukovanja greškama i pristupačnosti.

Ako želite prvo produbiti osnove, pročitajte:

# Zašto Server Actions za višekoračne obrasce#

Server Actions uklanjaju “dodatni API sloj” koji obično duplicira logiku kroz rute, kontrolere i validatore. Možete validirati i spremiti na serveru, a zatim preusmjeriti na sljedeći korak u jednom toku.

To je važno jer višekoračni čarobnjaci imaju više točaka kvara od obrazaca na jednoj stranici: navigacija između koraka, djelomično spremanje, ponašanje Back gumba i konkurentnost. Centralizacija validacije u server actions smanjuje razilaženje i pojednostavljuje revizije.

ℹ️ Napomena: Server Actions nisu “besplatna izvedba”. Svaka predaja koraka je round-trip. Dobitak je u ispravnosti i jednostavnosti: server postaje jedini izvor istine za validaciju i spremanje.

# Tri strategije stanja (i kada koju koristiti)#

Glavna odluka je gdje “živi” vaš nacrt između koraka. Ispod je praktična usporedba koja pomaže u planiranju.

StrategijaGdje nacrt živiNajbolje zaPrednostiNedostaciIzbjegavati kada
Kolačići ili sesijaEnkriptirani cookie ili server sesijaKratki čarobnjaci, podaci niskog rizikaBrzo postavljanje, bez DB‑a, jednostavni redirectiOgraničenja veličine cookieja, rizik osjetljivih podataka, teže na više uređajaPodaci o plaćanju, dugi nacrti, veliki payloadi
DB nacrtRed u bazi vezan uz draftIdDugi čarobnjaci, prijavljeni korisnici, više uređajaTrajno, auditabilno, podržava nastavakTreba DB, cleanup/TTL, više logikeMali obrasci gdje je DB overkill
URL stanjeQuery parametri po korakuFilteri, odabir plana, neosjetljivi izboriDijeljivi URL‑ovi, odlično ponašanje Back gumbaIzlaže podatke, ograničenja duljine URL‑aBilo što osjetljivo, velika tekstualna polja

Dobro pravilo: ako korisnik razumno može nastaviti kasnije, koristite DB nacrt. Ako je to obrazac za ponudu u 2 do 4 koraka i podaci nisu osjetljivi, cookie nacrt može biti dovoljan. Ako je to samo tok tipa “odaberi plan, odaberi dodatke”, URL stanje je najjednostavnije.

# Model podataka i Zod sheme (validacija po koraku i puna validacija)#

Prvo dizajnirajte sheme. Višekoračni tokovi često skupljaju polja koja su “opcionalna sada, obavezna kasnije”, pa trebate dva sloja:

  1. 1
    Shemu po koraku za validaciju onoga što je korisnik upravo poslao.
  2. 2
    Punu shemu za validaciju svega prije finalne predaje.

Primjer čarobnjaka: Upit za projekt#

Koraci:

  1. 1
    Kontakt
  2. 2
    Detalji projekta
  3. 3
    Budžet i pregled

Nacrt ćemo prikazati kao jedan objekt.

TypeScript
// lib/schemas/projectInquiry.ts
import { z } from "zod";
 
export const ContactStepSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Enter a valid email"),
});
 
export const DetailsStepSchema = z.object({
  projectType: z.enum(["web", "mobile", "automation"]),
  timeline: z.enum(["asap", "1-3m", "3-6m", "6m+"]),
  notes: z.string().max(1000, "Notes must be 1000 chars or less").optional(),
});
 
export const BudgetStepSchema = z.object({
  budgetEur: z.coerce.number().int().min(1000, "Budget must be at least 1000 EUR"),
  consent: z.coerce.boolean().refine((v) => v === true, "Consent is required"),
});
 
export const FullInquirySchema = ContactStepSchema
  .merge(DetailsStepSchema)
  .merge(BudgetStepSchema);
 
export type InquiryDraft = z.infer<typeof FullInquirySchema>;

Oblik grešaka za renderiranje obrasca#

Server actions trebaju vraćati predvidljivu strukturu za greške na poljima. Ne bacajte exception za očekivane validation greške.

TypeScript
// lib/forms/errors.ts
export type FieldErrors = Record<string, string | undefined>;
 
export type ActionState<TFields extends FieldErrors = FieldErrors> = {
  ok: boolean;
  message?: string;
  fieldErrors?: TFields;
};

Ovo je najbrži način da isporučite čarobnjak kada:

  • Nacrt je malen.
  • Podaci nisu osjetljivi.
  • Ne trebate nastavak na više uređaja.
  • Možete tolerirati gubitak nacrta kad se kolačići obrišu.

Kako radi#

  • Svaka akcija koraka validira unos.
  • Ako je valjan, spaja se u objekt nacrta.
  • Nacrt se sprema u cookie.
  • Radi se redirect na sljedeći korak.

Pomoćne funkcije za cookies#

Držite cookies malima. Realno imate nekoliko kilobajta. Izbjegavajte velika free‑text polja.

TypeScript
// lib/draft/cookieDraft.ts
"use server";
 
import { cookies } from "next/headers";
 
const DRAFT_COOKIE = "inquiry_draft_v1";
 
export function getDraftFromCookie(): Record<string, unknown> {
  const raw = cookies().get(DRAFT_COOKIE)?.value;
  if (!raw) return {};
  try {
    return JSON.parse(raw);
  } catch {
    return {};
  }
}
 
export function setDraftCookie(draft: Record<string, unknown>) {
  const value = JSON.stringify(draft);
  cookies().set(DRAFT_COOKIE, value, {
    httpOnly: true,
    sameSite: "lax",
    secure: true,
    path: "/wizard",
    maxAge: 60 * 60,
  });
}
 
export function clearDraftCookie() {
  cookies().delete(DRAFT_COOKIE);
}

⚠️ Upozorenje: Ne spremajte tajne ili regulirane osobne podatke u cookies osim ako ih enkriptirate i razumijete svoje compliance zahtjeve. Čak i s httpOnly, cookies se i dalje šalju u svakom requestu, što povećava izloženost i može napuhati headere.

Primjer akcije koraka (Kontakt)#

Vratite validation greške kao state, a na uspjeh redirect.

TypeScript
// app/wizard/contact/actions.ts
"use server";
 
import { redirect } from "next/navigation";
import { ContactStepSchema } from "@/lib/schemas/projectInquiry";
import { getDraftFromCookie, setDraftCookie } from "@/lib/draft/cookieDraft";
import type { ActionState } from "@/lib/forms/errors";
 
export async function submitContact(
  _prev: ActionState,
  formData: FormData
): Promise<ActionState> {
  const input = {
    name: String(formData.get("name") ?? ""),
    email: String(formData.get("email") ?? ""),
  };
 
  const parsed = ContactStepSchema.safeParse(input);
  if (!parsed.success) {
    const fieldErrors = parsed.error.flatten().fieldErrors;
    return {
      ok: false,
      fieldErrors: {
        name: fieldErrors.name?.[0],
        email: fieldErrors.email?.[0],
      },
      message: "Molimo ispravite greške i pokušajte ponovno.",
    };
  }
 
  const existing = getDraftFromCookie();
  setDraftCookie({ ...existing, ...parsed.data });
 
  redirect("/wizard/details");
}

Pristupačna stranica koraka s optimističnim UX‑om#

Koristite useActionState za prikaz grešaka i useFormStatus za pending UI. Onemogućite submit dok je pending kako biste spriječili duple predaje.

TSX
// app/wizard/contact/page.tsx
"use client";
 
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { submitContact } from "./actions";
 
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Spremanje..." : "Dalje"}
    </button>
  );
}
 
export default function ContactStep() {
  const [state, action] = useActionState(submitContact, { ok: true });
 
  return (
    <main>
      <h1>Korak 1 od 3: Kontakt</h1>
 
      {state.message && !state.ok ? (
        <p role="alert">{state.message}</p>
      ) : null}
 
      <form action={action} noValidate>
        <label htmlFor="name">Ime</label>
        <input
          id="name"
          name="name"
          aria-invalid={state.fieldErrors?.name ? "true" : "false"}
          aria-describedby={state.fieldErrors?.name ? "name-error" : undefined}
        />
        {state.fieldErrors?.name ? (
          <p id="name-error" role="alert">
            {state.fieldErrors.name}
          </p>
        ) : null}
 
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          inputMode="email"
          aria-invalid={state.fieldErrors?.email ? "true" : "false"}
          aria-describedby={state.fieldErrors?.email ? "email-error" : undefined}
        />
        {state.fieldErrors?.email ? (
          <p id="email-error" role="alert">
            {state.fieldErrors.email}
          </p>
        ) : null}
 
        <div style={{ display: "flex", gap: 12 }}>
          <a href="/">Odustani</a>
          <SubmitButton />
        </div>
      </form>
    </main>
  );
}
  • Veliki nacrti: cookies napuhuju request headere i mogu udariti size limite.
  • Osjetljive informacije: ne biste ih trebali izlagati u mediju pohranjenom kod klijenta.
  • Nastavak na više uređaja: cookies su po browseru.
  • Auditing: nema traga.

Ako se bilo što od ovoga odnosi na vaš slučaj, prijeđite na DB nacrte.

# Pristup 2: Nacrt u bazi (najpouzdanije)#

DB nacrti su najrobusnija opcija za produkciju:

  • Korisnici mogu nastaviti kasnije.
  • Možete podržati više uređaja.
  • Možete implementirati TTL cleanup.
  • Možete sigurno spremati veće payloade.

Minimalna tablica nacrta#

Neka bude jednostavno: id, userId ili anonKey, data json, status, timestampovi.

StupacTipNapomene
idstringIdentifikator nacrta
userIdstring nullableObavezno ako je korisnik prijavljen
anonKeystring nullableAko je nacrt anoniman
dataJSONDjelomični payload spajan po koracima
statusenumdraft, submitted, expired
updatedAttimestampZa cleanup i konkurentnost
createdAttimestampZa TTL

💡 Savjet: Dodajte updatedAt i nametnite optimistic concurrency. Ako se updatedAt promijenio otkad je korisnik učitao korak, možete prikazati “Ovaj nacrt je ažuriran drugdje” umjesto da ga prepišete.

Rukovanje draft ID‑jem#

Treba vam stabilan draftId. Uobičajeni obrasci:

  • Prijavljen korisnik: jedan aktivni nacrt po tipu čarobnjaka.
  • Anonimno: draftId spremljen u httpOnly cookie.

Server action s DB mergeom (pseudo DB sloj)#

Akcija:

  1. 1
    Validira input koraka.
  2. 2
    Učita nacrt po draftId.
  3. 3
    Spoji podatke.
  4. 4
    Spremi.
  5. 5
    Redirect.
TypeScript
// app/wizard/details/actions.ts
"use server";
 
import { redirect } from "next/navigation";
import { DetailsStepSchema } from "@/lib/schemas/projectInquiry";
import type { ActionState } from "@/lib/forms/errors";
 
async function getDraftId(): Promise<string> {
  // Implementirajte: iz sesije, cookieja ili korisnika.
  return "draft_123";
}
 
async function loadDraft(draftId: string): Promise<Record<string, unknown>> {
  // Zamijenite sa svojim DB pozivom.
  return {};
}
 
async function saveDraft(draftId: string, data: Record<string, unknown>) {
  // Zamijenite sa svojim DB pozivom.
  return;
}
 
export async function submitDetails(
  _prev: ActionState,
  formData: FormData
): Promise<ActionState> {
  const input = {
    projectType: String(formData.get("projectType") ?? ""),
    timeline: String(formData.get("timeline") ?? ""),
    notes: String(formData.get("notes") ?? "") || undefined,
  };
 
  const parsed = DetailsStepSchema.safeParse(input);
  if (!parsed.success) {
    const f = parsed.error.flatten().fieldErrors;
    return {
      ok: false,
      fieldErrors: {
        projectType: f.projectType?.[0],
        timeline: f.timeline?.[0],
        notes: f.notes?.[0],
      },
      message: "Molimo ispravite greške i pokušajte ponovno.",
    };
  }
 
  const draftId = await getDraftId();
  const existing = await loadDraft(draftId);
  await saveDraft(draftId, { ...existing, ...parsed.data });
 
  redirect("/wizard/budget");
}

Finalna submit akcija: puna validacija + kreiranje zapisa#

Na kraju nemojte vjerovati samo validaciji po koracima. Validirajte cijeli nacrt punom shemom.

TypeScript
// app/wizard/review/actions.ts
"use server";
 
import { redirect } from "next/navigation";
import { FullInquirySchema } from "@/lib/schemas/projectInquiry";
import type { ActionState } from "@/lib/forms/errors";
 
async function getDraftId(): Promise<string> {
  return "draft_123";
}
 
async function loadDraft(draftId: string): Promise<Record<string, unknown>> {
  return {};
}
 
async function markSubmitted(draftId: string) {
  return;
}
 
async function createInquiry(data: unknown) {
  // Insert u DB, enqueue email, itd.
  return { id: "inq_001" };
}
 
export async function submitFinal(
  _prev: ActionState,
  _formData: FormData
): Promise<ActionState> {
  const draftId = await getDraftId();
  const draft = await loadDraft(draftId);
 
  const parsed = FullInquirySchema.safeParse(draft);
  if (!parsed.success) {
    return {
      ok: false,
      message: "Neke obavezne informacije nedostaju. Molimo pregledajte prethodne korake.",
    };
  }
 
  const inquiry = await createInquiry(parsed.data);
  await markSubmitted(draftId);
 
  redirect(`/wizard/success?id=${encodeURIComponent(inquiry.id)}`);
}

Cleanup i trošak DB nacrta#

Ako imate 10.000 nacrta mjesečno i držite ih 7 dana, pohranjujete 70.000 redaka. Uz mali JSON payload to je trivijalno za Postgres, ali trebate implementirati TTL cleanup da kontrolirate rast.

Na primjer, dnevni job koji briše nacrte gdje je updatedAt stariji od 30 dana je obično dovoljan za B2B tokove. Ako podržavate resume linkove, produžite TTL i tražite verifikaciju emaila.

# Pristup 3: URL stanje (najbolje za neosjetljive, dijeljive korake)#

URL stanje radi kada:

  • “Nacrt” su uglavnom odabiri, ne upisani tekst.
  • U redu je da se podaci vide u address baru.
  • Želite dijeljivo i back‑button friendly ponašanje.

Primjeri:

  • Konfigurator cijena.
  • Odabir plana.
  • Feature toggleovi.

Primjer: Odabir plana u query parametrima#

Na submit napravite redirect na sljedeći korak s query parametrima. Na serveru validirajte query parametre sa Zod‑om.

TypeScript
// lib/schemas/plan.ts
import { z } from "zod";
 
export const PlanQuerySchema = z.object({
  plan: z.enum(["starter", "pro", "enterprise"]),
  seats: z.coerce.number().int().min(1).max(500),
});
TypeScript
// app/wizard/plan/page.tsx
import { redirect } from "next/navigation";
 
export default function PlanStep({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  async function next(formData: FormData) {
    "use server";
    const plan = String(formData.get("plan") ?? "");
    const seats = String(formData.get("seats") ?? "1");
    redirect(`/wizard/details?plan=${encodeURIComponent(plan)}&seats=${encodeURIComponent(seats)}`);
  }
 
  return (
    <main>
      <h1>Korak 1: Odaberite plan</h1>
      <form action={next}>
        <label>
          Plan
          <select name="plan" defaultValue="starter">
            <option value="starter">Starter</option>
            <option value="pro">Pro</option>
            <option value="enterprise">Enterprise</option>
          </select>
        </label>
 
        <label>
          Broj mjesta
          <input name="seats" defaultValue="1" inputMode="numeric" />
        </label>
 
        <button type="submit">Dalje</button>
      </form>
    </main>
  );
}

Validacija URL stanja na sljedećem koraku#

TypeScript
// app/wizard/details/page.tsx
import { PlanQuerySchema } from "@/lib/schemas/plan";
 
export default async function DetailsStep({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const sp = await searchParams;
 
  const parsed = PlanQuerySchema.safeParse({
    plan: sp.plan,
    seats: sp.seats,
  });
 
  if (!parsed.success) {
    // Prikažite korisnu poruku i link natrag; izbjegavajte bacanje greške za korisničke pogreške.
    return (
      <main>
        <h1>Neispravan odabir plana</h1>
        <p>Molimo vratite se i odaberite valjan plan.</p>
        <a href="/wizard/plan">Natrag na plan</a>
      </main>
    );
  }
 
  return (
    <main>
      <h1>Korak 2: Detalji</h1>
      <p>
        Odabrali ste {parsed.data.plan} s {parsed.data.seats} mjesta.
      </p>
      {/* Nastavite čarobnjak */}
    </main>
  );
}

Ograničenja koja trebate prihvatiti unaprijed#

  • URL‑ovi imaju ograničenja duljine. Browseri se razlikuju, ali dugačke bilješke i bogati podaci ne dolaze u obzir.
  • Query parametri su izloženi u logovima, analitici i referrerima. Nikad ne stavljajte email, broj telefona ili free‑text korisnički unos tamo.
  • I dalje trebate server-side validaciju na finalnom submitu.

# Dizajn navigacije: Back, izravan pristup koraku i guardovi#

Većina čarobnjaka treba predvidljiva pravila navigacije:

  1. 1
    Back ne smije odbaciti podatke.
  2. 2
    Korisnici ne smiju preskočiti na Korak 3 bez Koraka 1, osim ako tok to dopušta.
  3. 3
    Refresh ne smije obrisati napredak.

Guardovi koraka#

Na svakoj stranici koraka (server komponenta) učitajte nacrt i napravite redirect ako preduvjeti nedostaju.

Primjer: ako email nedostaje, redirect na kontakt.

TypeScript
// app/wizard/budget/page.tsx
import { redirect } from "next/navigation";
import { getDraftFromCookie } from "@/lib/draft/cookieDraft";
 
export default function BudgetStep() {
  const draft = getDraftFromCookie();
 
  if (!draft.email) {
    redirect("/wizard/contact");
  }
 
  return (
    <main>
      <h1>Korak 3 od 3: Budžet</h1>
      {/* render form */}
    </main>
  );
}

Za DB nacrte, guard učitava iz DB‑a. Ovo sprječava probleme tipa “direktan URL na korak” i smanjuje zbunjujuće djelomične predaje.

# Optimistični UX bez obmanjivanja korisnika#

Optimistični UX u čarobnjacima je uglavnom:

  • Trenutna povratna informacija tijekom predaje.
  • Sprječavanje duplih predaja.
  • Održavanje stabilnog UI‑a.

Koristite:

  • useFormStatus za pending stanje.
  • Onemogućite submit dok je pending.
  • Mali label “Spremanje…” blizu gumba.

Ako trebate inline optimistične preglede, neka budu izvedeni iz client state‑a, ali nikad ne pretpostavljajte da je server prihvatio promjene dok se redirect ne dogodi.

🎯 Ključna poruka: Redirect tretirajte kao signal uspjeha. Ako je server action napravio redirect, podaci su spremljeni i korak je završen. Ako je vratio state, prikažite greške i zadržite korisnika na istom koraku.

# Rukovanje greškama: validation greške vs sistemski kvarovi#

Trebate dva puta:

  • Očekivane greške: Zod validacija, nedostajuća polja, nevaljani prijelazi. Vratite strukturirane greške da se prikažu u formi.
  • Neočekivani kvarovi: DB nedostupan, bačeni exceptioni. Pustite da odu u error boundaries i prikažite pouzdan fallback.

Gdje staviti error boundaries#

Dodajte error.tsx po wizard segmentu i loading.tsx ako neki koraci dohvaćaju podatke nacrta.

Ovaj vodič pomaže strukturirati boundaries ispravno: Next.js error boundaries, loading, streaming obrasci

Primjer: Error UI segmenta#

TSX
// app/wizard/error.tsx
"use client";
 
export default function WizardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <main>
      <h1>Nešto je pošlo po zlu</h1>
      <p>Nismo mogli spremiti vaš napredak. Molimo pokušajte ponovno.</p>
      <button onClick={reset}>Pokušaj ponovno</button>
      <p style={{ opacity: 0.7 }}>Referenca: {error.digest ?? "n/a"}</p>
    </main>
  );
}

Obrazac za sistemske kvarove u Server Actions#

Za DB greške nemojte ih tiho progutati. Bacite grešku i prepustite error boundaryju, ili vratite generičnu poruku samo ako se možete oporaviti.

Razmotrite i idempotentnost: ako korisnik dvaput klikne i napravite dva finalna zapisa, to je skupo. Za finalnu predaju koristite unique constraint ili provjeru “draft already submitted”.

# Napomene o pristupačnosti koje stvarno utječu na konverziju#

Pristupačnost nije samo usklađenost; smanjuje odustajanje. Baymardova istraživanja checkouta u velikom opsegu više puta pokazuju da su nejasne greške i izgubljen napredak među glavnim razlozima odustajanja u višekoračnim tokovima.

Praktičan checklist koji možete brzo implementirati:

StavkaZašto je bitnaImplementacija
Naslov koraka i tekst napretkaOrijentira čitače ekrana i smanjuje konfuzijuh1 poput “Korak 2 od 3”
Greške polja se najaveKorisnici razumiju što je pošlo po zlurole="alert" na tekstu greške
Povežite input s greškomČitači ekrana mogu pronaći greškuaria-describedby="field-error-id"
Označite neispravna poljaPomaže AT‑u i nekim browserimaaria-invalid="true"
Upravljanje fokusomBrz oporavak nakon submit-aFokusirajte prvo neispravno polje kad state ima greške
Gumbi su pravi gumbiTipkovnica i semantikabutton type="submit", izbjegavajte klikabilne divove

Fokusiranje prvog neispravnog polja#

Možete implementirati malu client-side helper funkciju. Držite je minimalnom i pokrećite samo kad se state promijeni u nevaljan.

TSX
// app/wizard/_components/useFocusFirstError.ts
"use client";
 
import { useEffect } from "react";
 
export function useFocusFirstError(fieldErrors?: Record<string, string | undefined>) {
  useEffect(() => {
    if (!fieldErrors) return;
    const firstKey = Object.keys(fieldErrors).find((k) => fieldErrors[k]);
    if (!firstKey) return;
    const el = document.querySelector(`[name="${firstKey}"]`) as HTMLElement | null;
    el?.focus();
  }, [fieldErrors]);
}

Koristite u koraku:

TSX
// app/wizard/contact/page.tsx (snippet)
import { useFocusFirstError } from "../_components/useFocusFirstError";
 
export default function ContactStep() {
  const [state, action] = useActionState(submitContact, { ok: true });
  useFocusFirstError(state.fieldErrors);
 
  // ...
}

# Kako sve složiti: preporučena arhitektura#

Za većinu produkt timova najčišća struktura je:

  • wizard route group sa folderima koraka.
  • Stranice koraka kao client komponente samo kada trebaju interaktivno renderiranje grešaka.
  • Server actions po koraku u actions.ts.
  • Zod sheme u lib/schemas.
  • Draft sloj u lib/draft s izmjenjivim storageom.

Ovo drži čarobnjak konzistentnim s obrascima pokrivenima u:

# Česte zamke (i kako ih izbjeći)#

  1. 1

    Oslanjanje samo na validaciju po koracima
    Uvijek napravite završnu punu validaciju prije kreiranja zapisa. Korisnici mogu preskočiti korake direktnom navigacijom ili kroz zastarjele tabove.

  2. 2

    Pretjerano korištenje cookiesa
    Cookie nacrti pucaju na veličini i privatnosti. Ako imate free‑text bilješke, privitke ili duge tokove, koristite DB nacrte.

  3. 3

    Bacanje Zod grešaka kao exceptiona
    Validation greške su očekivane. Vratite ih kao action state da obrazac može prikazati poruke na razini polja.

  4. 4

    Ignoriranje duple predaje/idempotentnosti
    Onemogućite submit dok je pending i zaštitite finalno kreiranje unique constraintom ili provjerom “draft already submitted”.

  5. 5

    Bez guardova koraka
    Uvijek učitajte nacrt i nametnite preduvjete. Inače ćete dobiti slomljene review stranice i zbunjujuće kvarove tipa “missing field”.

# Ključne poruke#

  • Odaberite persistenciju nacrta prema riziku i trajanju: cookies za kratke neosjetljive tokove, DB nacrti za pouzdan nastavak i više uređaja, URL stanje za dijeljive neosjetljive odabire.
  • Zod koristite dvaput: sheme po koraku za brzu povratnu informaciju i punu shemu prije finalne predaje kako biste spriječili rupe zbog preskočenih koraka.
  • Redirect tretirajte kao signal uspjeha za svaki korak; za očekivane validation kvarove vraćajte strukturirani fieldErrors state.
  • Implementirajte osnove pristupačnosti koje smanjuju odustajanje: role="alert", aria-describedby, aria-invalid i fokus na prvo neispravno polje nakon submit-a.
  • Odvojite očekivane greške od sistemskih kvarova: validation greške renderirajte inline, a za exceptione i infrastrukturne probleme oslonite se na route error boundaries.

# Zaključak#

Next.js višekoračni obrazac sa server actions može biti jednostavniji i pouzdaniji od API‑teškog pristupa, pod uvjetom da odaberete ispravnu strategiju nacrta i validirate na svakom koraku i na finalnom submitu.

Ako želite da Samioda implementira ovaj čarobnjak end-to-end s produkcijskom razinom persistencije, pristupačnosti i analitike, kontaktirajte nas putem samioda.com i podijelite svoj tok i zahtjeve.

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.

Više iz kategorije Web razvoj

Sve

Trebate pomoć s projektom?

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