# What You’ll Build#
This guide shows a robust, repeatable architecture for building forms in Next.js App Router in 2026 with:
- React Hook Form for performant client state and accessible inputs
- Zod for shared schema validation and type inference
- Server Actions for secure mutations and authoritative validation
- Consistent error handling across client and server, with a single result shape
- Progressive enhancement, so the form still works without JavaScript
- Patterns for file uploads, async validation, and UX-friendly loading and error states
If you’re already shipping forms, this is the setup that prevents the common 2026 failure modes: inconsistent error formats, duplicated validation logic, leaky file upload flows, and server actions that become untestable blobs.
For deeper background on these building blocks, see:
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Next.js | 15+ | App Router, Server Actions enabled |
| React | 19+ | useActionState and modern form patterns |
| TypeScript | 5.5+ | Recommended for schema inference |
| React Hook Form | 7.50+ | Works well with Zod resolver |
| Zod | 3.23+ | Schema validation and inference |
# Architecture Overview: One Schema, Two Execution Contexts#
A scalable form setup usually splits into four layers:
| Layer | Runs where | Responsibility | Must be trusted |
|---|---|---|---|
| UI + RHF | Client | Input state, immediate feedback, accessibility | No |
| Shared Zod schema | Client and server | Format and business rules that don’t require secrets | Partially |
| Server Action | Server | Authentication, authorization, DB writes, final validation | Yes |
| DB and storage | Server and providers | Persistence, uniqueness, integrity | Yes |
The key is one normalized result shape coming back from the action. That is what makes error handling consistent and maintainable.
🎯 Key Takeaway: Treat client validation as UX, server validation as security, and make them share a single schema and a single error format.
# Step 1: Define Shared Schemas and Types#
Start with a schema you can safely reuse on both client and server: length checks, formatting, and cross-field rules that do not require database access.
Create src/features/profile/profile.schemas.ts:
import { z } from "zod";
export const profileSchema = z.object({
fullName: z
.string()
.trim()
.min(2, "Full name must be at least 2 characters")
.max(80, "Full name must be 80 characters or less"),
email: z
.string()
.trim()
.email("Enter a valid email address")
.max(254, "Email is too long"),
bio: z
.string()
.trim()
.max(500, "Bio must be 500 characters or less")
.optional()
.or(z.literal("")),
avatarKey: z
.string()
.trim()
.max(300, "Invalid file reference")
.optional()
.or(z.literal("")),
});
export type ProfileInput = z.infer<typeof profileSchema>;Add server-only validation without breaking reuse#
Uniqueness checks and permission checks belong on the server. Keep them out of the shared schema to avoid accidental DB calls from the client. Use a server-only validator that wraps the shared schema.
Create 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;
// Example server-only rule: enforce corporate domain
if (!parsed.data.email.endsWith("@example.com")) {
return {
success: false as const,
error: {
issues: [
{ path: ["email"], message: "Use your company email address" },
],
},
};
}
return parsed;
}This pattern keeps the shared schema clean, and still lets the server add authoritative rules.
# Step 2: Standardize Action Results and Error Mapping#
You want every mutation action to return the same shape:
ok: truewithdataok: falsewithfieldErrorsand optionalformError
This makes client code predictable and prevents one-off error handling.
Create 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 };Now add a helper to convert Zod errors to fieldErrors.
Create 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;
}⚠️ Warning: Do not return raw Zod errors to the client. They can include internal paths, unexpected messages, and are not stable API. Normalize them.
# Step 3: Build the Server Action with Secure Validation#
Server Actions are your trusted boundary. Do all of this inside the action:
- 1Authenticate the user
- 2Parse and validate input
- 3Perform server-only checks
- 4Write to DB
- 5Return a normalized result
Create 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: "You must be signed in." };
// 2) Extract primitive values
const input = {
fullName: String(formData.get("fullName") || ""),
email: String(formData.get("email") || ""),
bio: String(formData.get("bio") || ""),
avatarKey: String(formData.get("avatarKey") || ""),
};
// 3) Validate
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) Persist (pseudo)
const updatedId = userId;
return { ok: true, data: { id: updatedId } };
}Two important security notes:
- Never trust
FormData. Always coerce types and validate. - Never accept file blobs for large uploads through actions. Use presigned URLs and only accept file keys.
# Step 4: Progressive Enhancement First, React Hook Form Second#
Progressive enhancement means the form can submit through plain HTML. React Hook Form then improves UX: inline errors, async checks, button states, and preserving local state.
Server Component page that renders a Client Component form#
Create app/settings/profile/page.tsx (Server Component):
import { ProfileForm } from "@/features/profile/ProfileForm";
export default async function ProfilePage() {
// Load initial values on the server (pseudo)
const initialValues = {
fullName: "Ada Lovelace",
email: "ada@example.com",
bio: "Building reliable systems.",
avatarKey: "",
};
return <ProfileForm initialValues={initialValues} />;
}Client Component form using useActionState and RHF#
Create 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>
Full name
<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 ? "Saving..." : "Save changes"}
</button>
</form>
);
}This does three useful things:
- Works without JavaScript because the action is still the form handler.
- RHF handles client validation and avoids rerendering entire forms on each keystroke.
- Server errors map back into RHF errors consistently.
💡 Tip: Add
aria-invalidandaria-describedbybased onformState.errorsfor accessibility. It also improves conversion for real users, not just Lighthouse scores.
# Step 5: Async Validation Without Spamming the Server#
Async validation is usually about checks like “email already taken”. Don’t run this on every keystroke.
Use debounce on blur or after user stops typing, and validate through a dedicated server action or route handler. Avoid coupling this to your submit action.
Example: server action for availability check#
Create 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 is required" };
// Pseudo DB lookup
const taken = normalized === "taken@example.com";
if (taken) return { ok: false as const, message: "Email is already in use" };
return { ok: true as const };
}Client side: trigger on blur#
Keep it simple. Blur-based checks reduce calls dramatically compared to per-keystroke validation.
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 });
}
}For forms at scale, a practical target is less than 1 async call per field per edit session. Blur-based validation typically achieves that.
# Step 6: File Uploads with Presigned URLs and Server Verification#
In production, file uploads fail when teams try to pass files through server actions, hit size limits, timeouts, and memory spikes. The robust pattern is:
- 1Server Action authorizes upload and returns a presigned URL
- 2Client uploads directly to object storage
- 3Client submits the resulting
avatarKeyto the main action - 4Server Action verifies the key belongs to the user and is within constraints
For a full walkthrough, see: Next.js file uploads with S3 or R2 presigned URLs
Upload flow: what to store in the form#
| Field | Type | Where it comes from | Stored in DB |
|---|---|---|---|
avatarKey | string | After successful upload | Yes |
avatarMime | string | Optional from client | Optional, should be verified |
avatarSize | number | Optional from client | Optional, should be verified |
Only avatarKey needs to be submitted through the profile form. Everything else can be verified server-side via a HEAD request or provider metadata, depending on your storage.
Minimal presign action#
Create src/features/uploads/upload.actions.ts:
"use server";
export async function createAvatarUploadAction() {
const userId = "user_123";
if (!userId) return { ok: false as const, message: "Unauthorized" };
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 };
}Client upload and set hidden field#
On the client, upload and then set avatarKey in RHF. The submit action stays the same.
Practical UX improvements:
- Show progress
- Disable submit until upload completes
- Surface upload errors next to the avatar field
# Step 7: Consistent Error Handling for UX and Observability#
A form that fails silently costs money. Industry benchmarks vary by domain, but Baymard Institute’s checkout UX research consistently shows that unclear errors are a major conversion killer in real-world flows. Even for internal apps, unclear errors increase support tickets and slow teams down.
Make errors consistent across your app:
| Error type | Where it appears | Example | How to handle |
|---|---|---|---|
| Field error | Under an input | “Email is invalid” | setError(field) |
| Form error | Top or bottom of form | “You must be signed in” | setError("root") |
| Toast / global | Outside form | “Server unavailable” | Retry guidance |
| Logging-only | Not shown to user | Stack trace | Send to logging tool |
Normalize unexpected failures#
In your server action, wrap DB writes and return a safe message:
try {
// DB write
return { ok: true, data: { id: userId } };
} catch {
return { ok: false, formError: "Something went wrong. Please try again." };
}This prevents leaking details and keeps UI behavior predictable.
ℹ️ Note: For debugging, log the real error server-side with request context. Do not send raw messages to the client.
# Step 8: UX Details That Matter in 2026#
Small form details drive completion rates more than most teams expect. These are low-effort, high-impact improvements:
Disable submit based on both RHF and action state#
Use both signals:
pendingfromuseActionStatefor in-flight server mutationsform.formState.isSubmittingfor RHF-controlled flows- Optional:
form.formState.isValidfor early feedback
Keep error messages stable and specific#
Bad: “Invalid input.”
Good: “Password must be at least 12 characters” or “Upload must be a JPG or PNG less than 2 MB”.
Don’t clear the form on server error#
Persist user input. If you redirect after success, use a success message (flash) or update the UI with the returned data.
Use server redirects deliberately#
For settings forms, staying on the same page with inline success often beats redirecting. For multi-step flows, redirect is usually better.
# Common Pitfalls and How to Avoid Them#
- 1
Duplicating schemas in separate client and server files
Keep shared rules in one Zod schema and add server-only rules in a server wrapper. - 2
Returning inconsistent error shapes across actions
Use a typedActionResultand always returnfieldErrorsandformError. - 3
Trying to upload files through Server Actions
Use presigned URLs and submit only keys through the form. - 4
Running async validation on each keystroke
Validate on blur or with debounce, and cache results for the session.
For more patterns and deeper examples, also see:
# Key Takeaways#
- Use React Hook Form for fast client-side UX, but make Server Actions the only trusted mutation path.
- Keep shared Zod schemas for reusable rules, and layer server-only validation for database or permission checks.
- Return a single normalized result shape with
fieldErrorsandformError, then map it into RHF viasetError. - Implement progressive enhancement by using the form
actionattribute, so submission still works without JavaScript. - Handle file uploads via presigned URLs, then submit only the resulting storage key for server-side verification.
- Use blur-based async validation to prevent excessive server calls while still catching uniqueness issues early.
# Conclusion#
A production-ready form in Next.js App Router in 2026 is not about choosing between client or server validation. It’s about combining RHF for UX, Zod for shared rules, and Server Actions for security, while standardizing error handling so every form behaves the same way.
If you want Samioda to implement this architecture across your app, including file uploads, async validation, and hardened server-side checks, contact us and we’ll review your current forms and ship a consistent pattern quickly.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Offline-Friendly React Apps with TanStack Query: Persistence, Retries, and Optimistic UI (2026 Guide)
Build resilient offline-first UX with TanStack Query: React Query offline persistence, retry and backoff strategies, safe optimistic updates, partial offline patterns, and MSW testing.
React Virtualization Guide: Windowing Large Lists and Grids with TanStack Virtual (and When Not To)
A practical 2026 guide to React virtualization with TanStack Virtual: build fast lists and grids, add infinite loading and sticky headers, profile measurable gains, and avoid common edge cases like dynamic row heights and accessibility pitfalls.
Next.js Supabase Realtime in 2026: End-to-End Blueprint for Chat, Presence, and Collaboration
Build production-ready realtime UI with Next.js App Router and Supabase Realtime: schema design, RLS, optimistic updates, presence, scaling, and troubleshooting duplicate events and permission mismatches.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Server Actions in Next.js App Router: Production Form Patterns for Validation, Errors, and Optimistic UI
A production-ready guide to Next.js Server Actions form validation with Zod, structured error handling, progressive enhancement, optimistic UI, and rate limiting — plus when to choose Server Actions vs API routes.
React Forms at Scale: React Hook Form + Zod Patterns for Complex Products
React forms best practices for large apps using React Hook Form and Zod: schema-first validation, reusable fields, async checks, multi-step flows, performance, accessibility, and server/API integration patterns.
Next.js App Router UX Patterns: Error Boundaries, Loading UI, and Streaming Done Right
A practical guide to resilient UX in Next.js App Router: route segment structure, error.tsx, loading.tsx, not-found.tsx, and Suspense streaming patterns for partial rendering, safer data fetching, and fewer layout shifts.