Learning Next.js - Forms & Validation
Episode 10 of 24

Learning Next.js - Forms & Validation

This episode covers handling forms in React components, client-side validation with React Hook Form and Zod, server-side validation and submission to an API with Server Actions, and form accessibility with good UX feedback.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Forms are the gateway through which users enter an application: registration, login, search, and even payments. A bad form — confusing validation, unclear errors, input lost on failure — frustrates users and drives them away.

Episode 10 covers handling forms in React components, client-side validation with React Hook Form and Zod, server-side validation and data submission with Server Actions, and form accessibility with UX feedback.

Form Handling in React Components

Basic State and Submit

Without a library, React forms are managed with state per input. For small forms, this approach is enough:

Simple form with state
"use client"
 
import { useState } from "react"
 
export default function ContactForm() {
  const [email, setEmail] = useState("")
 
  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
    </form>
  )
}

The input above is controlled by the email state. For forms with many fields, complex validation, and smooth error handling, React Hook Form offers better control.

Client-Side Validation with React Hook Form

useForm and the Zod Resolver

React Hook Form manages input registration and avoids excessive re-renders. Combine it with Zod via zodResolver for a clear validation schema:

Validation with React Hook Form and Zod
"use client"
 
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
 
const schema = z.object({
  email: z.string().email("Format email tidak valid"),
  nama: z.string().min(3, "Nama minimal 3 karakter"),
})
 
type FormValues = z.infer<typeof schema>
 
export default function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  })
 
  const onSubmit = (data) => console.log(data)
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} type="email" />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">Daftar</button>
    </form>
  )
}

The Zod schema above validates the email format and name length. errors.email.message shows the error message below the input that failed validation — immediate feedback without submitting.

The Two-Layer Approach

Client-side validation is only for UX; server validation is the real line of defense. Users can easily bypass JavaScript, so the server must re-validate all input. Both layers should use the same rules — and Zod allows the schema to be shared between client and server.

Server-Side Validation and API Submission

Server Actions with Validation

Server Actions let a function on the server be called directly from a form without writing an API endpoint. Write the function with the "use server" directive and validate with the same schema:

Server Action with Zod validation
"use server"
 
import { z } from "zod"
 
const schema = z.object({
  email: z.string().email(),
})
 
export async function daftarPengguna(prevState, formData) {
  const parsed = schema.safeParse({
    email: formData.get("email"),
  })
 
  if (!parsed.success) {
    return { error: "Data tidak valid" }
  }
 
  return { success: "Pendaftaran berhasil" }
}

schema.safeParse(formData) validates data from the form without throwing an exception — success or failure is read from the returned object. The Server Action returns state that's displayed in the form.

Integrating with useActionState

In the client component, use the useActionState hook to bind the Server Action to form state:

useActionState for a Server Action
"use client"
 
import { useActionState } from "react"
import { daftarPengguna } from "./actions"
 
export default function Form() {
  const [state, formAction] = useActionState(daftarPengguna, null)
 
  return (
    <form action={formAction}>
      <input name="email" type="email" />
      {state && <p>{state.error ?? state.success}</p>}
      <button type="submit">Kirim</button>
    </form>
  )
}

useActionState(daftarPengguna, null) runs the Server Action and displays its result. The form still works without JavaScript — a progressive enhancement pattern that search engines favor.

Accessibility and UX Feedback in Forms

Semantic HTML and ARIA

An accessible form starts with semantic HTML: labels connected to inputs via htmlFor, error messages linked with the aria-describedby attribute, and required fields marked required. Screen readers use these relationships to read errors correctly. Good UX feedback: show the error right below the relevant field, don't clear user input when a submit fails, and show a loading indicator while submission is in progress.

Closing

Here's what to take away:

  • React forms are managed with controlled state or React Hook Form.
  • Zod provides a schema usable on both client and server.
  • Client validation for UX, server validation for security.
  • Server Actions send data without a separate API endpoint.
  • useActionState manages the submit result state of a Server Action.
  • Semantic HTML and ARIA make forms accessible to everyone.

In the next episode, episode 11, we'll discuss internationalization (i18n) — multi-language support with next-intl, localized routing and content translation, and SEO and metadata for multi-language applications. Your application will be ready to reach a global audience.

Learning Next.js - Forms & Validation | Learn Next.js