Building Forms
This guide shows you how to build forms that feel great to use. Starting with a simple login form, we'll add validation, loading feedback, and accessibility features step-by-step. The key insight? Your form works perfectly without JavaScript, then becomes even more responsive and helpful as the page comes to life. Already committed to a form library? Don't worry, we've got integration examples for Conform, React Hook Form, and Formik waiting for you at the end.
Complete the Getting Started - App Router
guide first. You'll need a working lib/auth/server.ts file that exports the
login function from createAppRouterAuth.
Traditional approach
If you've worked with React before, you're probably used to creating forms like
this: a client component with an onSubmit handler that makes a fetch request
and handles navigation manually.
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState<string>();
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const response = await fetch("/api/auth/login", {
method: "POST",
body: new FormData(event.currentTarget),
});
if (response.ok) {
router.push("/dashboard");
} else {
setError("Login failed. Please try again.");
}
}
return (
<form onSubmit={handleSubmit}>
{error && <p>{error}</p>}
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
</div>
<button type="submit">Login</button>
</form>
);
}
This works, but has several limitations. Users don't know when the form is submitting, and you need to create and maintain separate API endpoints for each form action.
Let's fix these issues one by one in the following sections.
Server actions
The traditional approach requires JavaScript and manual API routes. Let's fix that using Next.js Server Actions: a simpler, more robust approach that makes your forms interactive immediately, even before React hydrates.
- Server Action
- Page
"use server";
import { unstable_rethrow } from "next/navigation";
import { login } from "@/lib/auth/server";
export async function loginAction(formData: FormData) {
const email = formData.get("email");
const password = formData.get("password");
if (
typeof email !== "string" ||
typeof password !== "string" ||
!email ||
!password
) {
return;
}
try {
return await login({ input: { email, password } });
} catch (error) {
unstable_rethrow(error);
console.error("Login failed:", error);
}
}
import { loginAction } from "./actions";
export default function LoginPage() {
return (
<form action={loginAction}>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
</div>
<button type="submit">Login</button>
</form>
);
}
Your form is now interactive immediately, even before React hydrates! Simply
using action={loginAction} instead of onSubmit enables progressive
enhancement. The form submits naturally, and Next.js handles the rest. However,
if users submit before React hydrates, they lose their input on errors: let's
fix that next.
When login() succeeds, it calls redirect() which throws a special error. You
must re-throw this error or users won't be redirected after successful login.
Use unstable_rethrow(error) to handle all Next.js internal errors including
redirects.
Progressive enhancement
Our form is now interactive immediately, but users lose their input if they
submit before React hydrates and the submission fails. Let's preserve form state
across submissions using React's useActionState hook. This works even before
the page hydrates: when users submit early, the server returns state that React
picks up once it loads.
- Server Action
- Page
"use server";
import { unstable_rethrow } from "next/navigation";
import { login } from "@/lib/auth/server";
export type LoginState = {
values?: { email: string; password: string };
};
export async function loginAction(
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
const email = formData.get("email");
const password = formData.get("password");
if (
typeof email !== "string" ||
!email ||
typeof password !== "string" ||
!password
) {
return {
values: {
email: typeof email === "string" ? email : "",
password: typeof password === "string" ? password : "",
},
};
}
try {
return await login({ input: { email, password } });
} catch (error) {
unstable_rethrow(error);
console.error("Login failed:", error);
return {
values: { email, password },
};
}
}
"use client";
import { useActionState } from "react";
import { loginAction } from "./actions";
export default function LoginPage() {
const [state, action] = useActionState(loginAction, null);
return (
<form action={action}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={state?.values?.email ?? ""}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
defaultValue={state?.values?.password ?? ""}
required
/>
</div>
<button type="submit">Login</button>
</form>
);
}
Now when login fails, users keep their input. This works with or without JavaScript, then gets even better with client-side validation, which is coming next.
useActionState connects your form to the Server Action and manages state
across submissions. It's a React 19 hook: if you're on React 18, use
useFormState from react-dom (same API).
Pending state
We're preserving state, but users still don't know when the form is submitting
and can accidentally submit multiple times. Let's add loading feedback using the
third parameter from useActionState.
"use client";
import { useActionState } from "react";
import { loginAction } from "./actions";
export default function LoginPage() {
const [state, action, isPending] = useActionState(loginAction, null);
return (
<form action={action}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={state?.values?.email ?? ""}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
defaultValue={state?.values?.password ?? ""}
required
/>
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
}
Users now see loading feedback and can't accidentally submit twice. Note that
the isPending flag requires JavaScript: before the page hydrates, the form
still works but won't show loading feedback.
Server-side validation
We have state preservation and loading feedback. Now let's add validation to show clear error messages when input is invalid or authentication fails.
Create a schema to define validation rules, then update the Server Action to validate input and return field-level and form-level errors.
This guide uses Zod v4 for schema validation, but you can use any library you prefer (e.g., Yup, Joi) or even custom validation logic.
- Type
- Schema
- Server Action
- Page
export type ActionState<TData> = {
fieldErrors?: Partial<Record<keyof TData, string[]>>;
formErrors?: string[];
values?: Partial<TData>;
};
import * as z from "zod";
import type { ActionState } from "@/types/ActionState";
export const loginSchema = z.object({
email: z.email({
error({ input }) {
if (input === undefined) return "Email is required";
return "Please enter a valid email";
},
}),
password: z.string("Password is required").min(1, "Password is required"),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type LoginState = ActionState<LoginInput>;
"use server";
import { unstable_rethrow } from "next/navigation";
import * as z from "zod";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginState } from "./schema";
export async function loginAction(
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
const email = formData.get("email");
const password = formData.get("password");
const validated = loginSchema.safeParse({ email, password });
if (!validated.success) {
return {
...z.flattenError(validated.error),
values: {
email: typeof email === "string" ? email : "",
password: typeof password === "string" ? password : "",
},
};
}
try {
return await login({ input: validated.data });
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: validated.data,
};
}
}
"use client";
import { useActionState } from "react";
import { loginAction } from "./actions";
export default function LoginPage() {
const [state, action, isPending] = useActionState(loginAction, null);
return (
<form action={action}>
<div>
{state?.formErrors?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={state?.values?.email ?? ""}
required
/>
<div>
{state?.fieldErrors?.email?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
defaultValue={state?.values?.password ?? ""}
required
/>
<div>
{state?.fieldErrors?.password?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
}
Your form now validates user input and shows clear, helpful error messages. Whether it's a typo in an email address or incorrect credentials, users get specific feedback about what went wrong and can quickly fix it. The form state is preserved, so they don't lose their work when correcting mistakes.
Client-side validation
Now let's add client-side validation to catch errors instantly when users submit. This validates inputs in the browser using the same schema and prevents invalid forms from reaching the server, giving users immediate feedback while maintaining server-side validation as the source of truth.
We'll start by creating a custom hook that handles client-side validation, making it easy to reuse this pattern across different forms in your application.
- Hook
- Page
import {
startTransition,
useActionState,
useState,
type FormEvent,
} from "react";
import * as z from "zod";
import type { ActionState } from "@/types/ActionState";
type UseFormOptions<Schema extends z.ZodType> = {
action: (
state: ActionState<z.output<Schema>> | null | undefined,
formData: FormData,
) => Promise<ActionState<z.output<Schema>>>;
schema: Schema;
};
export function useForm<Schema extends z.ZodType>({
action,
schema,
}: UseFormOptions<Schema>) {
const [serverState, formAction, isPending] = useActionState(action, null);
const [clientState, setClientState] = useState<
Omit<ActionState<z.output<Schema>>, "values">
>({});
function validateForm(event: FormEvent<HTMLFormElement>) {
const formData = new FormData(event.currentTarget);
const validated = schema.safeParse(Object.fromEntries(formData));
if (!validated.success) {
event.preventDefault();
setClientState(z.flattenError(validated.error));
return;
}
// When validation passes, clear errors and let the form submit normally.
startTransition(() => {
setClientState({});
});
}
// Hide stale server errors during submission by showing only client state,
// then fall back to server errors once the action completes
const fieldErrors = isPending
? clientState.fieldErrors
: clientState.fieldErrors || serverState?.fieldErrors;
const formErrors = isPending
? clientState.formErrors
: clientState.formErrors || serverState?.formErrors;
return {
fieldErrors,
formAction,
formErrors,
isPending,
validateForm,
values: serverState?.values,
};
}
"use client";
import { loginAction } from "./actions";
import { loginSchema } from "./schema";
import { useForm } from "@/hooks/useForm";
export default function LoginPage() {
const {
fieldErrors,
formAction,
formErrors,
isPending,
validateForm,
values,
} = useForm({
action: loginAction,
schema: loginSchema,
});
return (
<form action={formAction} onSubmit={validateForm}>
<div>
{formErrors?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={values?.email ?? ""}
required
/>
<div>
{fieldErrors?.email?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
defaultValue={values?.password ?? ""}
required
/>
<div>
{fieldErrors?.password?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
}
Users now get instant validation feedback when they submit the form, before the request is sent to the server. Invalid forms are caught immediately, while server-side validation still runs as a safety net for network errors, JavaScript failures, and malicious requests that bypass client-side validation.
Accessibility
Finally, let's ensure our form works well for everyone. We'll add ARIA
attributes for screen readers and keyboard navigation, and use React's useId
hook to generate unique element IDs. This prevents conflicts when multiple forms
appear on the same page, while helping screen readers properly connect error
messages to their inputs.
"use client";
import { useId } from "react";
import { loginAction } from "./actions";
import { loginSchema } from "./schema";
import { useForm } from "@/hooks/useForm";
export default function LoginPage() {
const {
fieldErrors,
formAction,
formErrors,
isPending,
validateForm,
values,
} = useForm({
action: loginAction,
schema: loginSchema,
});
const id = useId();
const formErrorId = `${id}-form-error`;
const emailId = `${id}-email`;
const emailErrorId = `${emailId}-error`;
const passwordId = `${id}-password`;
const passwordErrorId = `${passwordId}-error`;
return (
<form
action={formAction}
onSubmit={validateForm}
aria-describedby={formErrors ? formErrorId : undefined}
>
<div id={formErrorId} aria-live="polite">
{formErrors?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
<div>
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
name="email"
type="email"
autoComplete="username"
defaultValue={values?.email ?? ""}
required
aria-invalid={fieldErrors?.email ? true : undefined}
aria-describedby={fieldErrors?.email ? emailErrorId : undefined}
/>
<div id={emailErrorId} aria-live="polite">
{fieldErrors?.email?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
</div>
<div>
<label htmlFor={passwordId}>Password</label>
<input
id={passwordId}
name="password"
type="password"
autoComplete="current-password"
defaultValue={values?.password ?? ""}
required
aria-invalid={fieldErrors?.password ? true : undefined}
aria-describedby={fieldErrors?.password ? passwordErrorId : undefined}
/>
<div id={passwordErrorId} aria-live="polite">
{fieldErrors?.password?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
}
Your form now meets WCAG 2.1 AA standards, making it accessible to everyone. The combination of ARIA labels, error announcements, and autocomplete support creates a smooth experience for all users, whether they're using screen readers, keyboard navigation, or password managers.
| Attribute | Purpose |
|---|---|
useId() | Generates unique, stable IDs to prevent conflicts |
aria-live="polite" | Announces errors to screen readers without interrupting the user |
aria-invalid | Marks fields with validation errors for assistive technologies |
aria-describedby | Connects error messages to their corresponding inputs |
autoComplete | Enables browser autofill and password manager integration |
Putting it all together
Here's the full implementation combining all best practices from the previous
sections. We've also done some cleanup by extracting a reusable TextField
component to reduce repetition and make the form easier to maintain.
- TextField
- Type
- Hook
- Schema
- Server Action
- Page
import { useId, type InputHTMLAttributes } from "react";
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
errors?: string[];
label: string;
name: string;
}
export function TextField({
errors,
id,
label,
name,
...props
}: TextFieldProps) {
const fallbackId = useId();
const inputId = id || `${fallbackId}-${name}`;
const errorId = `${inputId}-error`;
return (
<div>
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
name={name}
aria-invalid={errors ? true : undefined}
aria-describedby={errors ? errorId : undefined}
{...props}
/>
<div id={errorId} aria-live="polite">
{errors?.map((err, i) => (
<p key={i}>{err}</p>
))}
</div>
</div>
);
}
export type ActionState<TData> = {
fieldErrors?: Partial<Record<keyof TData, string[]>>;
formErrors?: string[];
values?: Partial<TData>;
};
import {
startTransition,
useActionState,
useState,
type FormEvent,
} from "react";
import * as z from "zod";
import type { ActionState } from "@/types/ActionState";
type UseFormOptions<Schema extends z.ZodType> = {
action: (
state: ActionState<z.output<Schema>> | null | undefined,
formData: FormData,
) => Promise<ActionState<z.output<Schema>>>;
schema: Schema;
};
export function useForm<Schema extends z.ZodType>({
action,
schema,
}: UseFormOptions<Schema>) {
const [serverState, formAction, isPending] = useActionState(action, null);
const [clientState, setClientState] = useState<
Omit<ActionState<z.output<Schema>>, "values">
>({});
function validateForm(event: FormEvent<HTMLFormElement>) {
const formData = new FormData(event.currentTarget);
const validated = schema.safeParse(Object.fromEntries(formData));
if (!validated.success) {
event.preventDefault();
setClientState(z.flattenError(validated.error));
return;
}
// When validation passes, clear errors and let the form submit normally.
startTransition(() => {
setClientState({});
});
}
// Hide stale server errors during submission by showing only client state,
// then fall back to server errors once the action completes
const fieldErrors = isPending
? clientState.fieldErrors
: clientState.fieldErrors || serverState?.fieldErrors;
const formErrors = isPending
? clientState.formErrors
: clientState.formErrors || serverState?.formErrors;
return {
fieldErrors,
formAction,
formErrors,
isPending,
validateForm,
values: serverState?.values,
};
}
import * as z from "zod";
import type { ActionState } from "@/types/ActionState";
export const loginSchema = z.object({
email: z.email({
error({ input }) {
if (input === undefined) return "Email is required";
return "Please enter a valid email";
},
}),
password: z.string("Password is required").min(1, "Password is required"),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type LoginState = ActionState<LoginInput>;
"use server";
import { unstable_rethrow } from "next/navigation";
import * as z from "zod";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginState } from "./schema";
export async function loginAction(
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
// 1. Validate input
const email = formData.get("email");
const password = formData.get("password");
const validated = loginSchema.safeParse({ email, password });
if (!validated.success) {
// 2. Preserve form values on validation error
return {
...z.flattenError(validated.error),
values: {
email: typeof email === "string" ? email : "",
password: typeof password === "string" ? password : "",
},
};
}
try {
// 2. Attempt login (redirects on success)
return await login({ input: validated.data });
} catch (error) {
// 3. Re-throw redirect errors (critical!)
unstable_rethrow(error);
// 4. Return consistent error structure
return {
formErrors: ["Invalid email or password"],
values: validated.data,
};
}
}
"use client";
import { useId } from "react";
import { TextField } from "@/components/TextField";
import { loginAction } from "./actions";
import { loginSchema } from "./schema";
import { useForm } from "@/hooks/useForm";
export default function LoginPage() {
const {
fieldErrors,
formAction,
formErrors,
isPending,
validateForm,
values,
} = useForm({
action: loginAction,
schema: loginSchema,
});
const id = useId();
const formErrorId = `${id}-form-error`;
return (
<form
action={formAction}
onSubmit={validateForm}
aria-describedby={formErrors ? formErrorId : undefined}
>
<div id={formErrorId} aria-live="polite">
{formErrors?.map((error, index) => (
<p key={`${index}-${error}`}>{error}</p>
))}
</div>
<TextField
name="email"
label="Email"
type="email"
autoComplete="username"
defaultValue={values?.email ?? ""}
errors={fieldErrors?.email}
required
/>
<TextField
name="password"
label="Password"
type="password"
autoComplete="current-password"
defaultValue={values?.password ?? ""}
errors={fieldErrors?.password}
required
/>
<button type="submit" disabled={isPending}>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
}
You've built a form that handles the messy reality of the web gracefully. Server-side validation is the foundation that enables progressive enhancement. It protects your backend, handles requests before JavaScript loads, and catches cases where JavaScript fails or gets bypassed. Client-side validation builds on top of this foundation to give users instant feedback and a more responsive experience.
Form values persist across failed submissions during that critical window before the page becomes interactive. Once React hydrates, it handles state preservation automatically. But users on slow connections often submit forms before JavaScript finishes loading. Server-side validation catches those early submissions and returns their input, so they don't have to retype everything when fixing validation errors.
Loading states inform users during submission, redirects work correctly even when errors occur, and screen readers get proper announcements throughout. The form works from the moment HTML arrives, then enhances as the page becomes interactive. It's resilient, accessible, and handles real-world conditions like slow networks, impatient users, and everything in between.
This useForm hook covers the essentials but lacks features you'll often need
in production. It validates only on submit, so users don't get feedback as they
type or when they leave a field. It doesn't track which fields have been
touched, making it hard to control when errors appear. There's no concept of
dirty or pristine state, and no debouncing for performance.
The implementation uses uncontrolled inputs, where the DOM manages field values. For some use cases, you might need controlled inputs (React state controlling input values) to enable features like live character counts, formatting as you type, or conditional field visibility. Controlled inputs require field-level optimizations to prevent the entire form from re-rendering on every keystroke.
These are all solvable problems, but form libraries have already solved them with battle-tested implementations. The next section shows how to integrate popular libraries while keeping the Server Action patterns you've learned.
Form libraries
If your project already uses a form library, these examples show how to integrate them with the patterns from the previous sections.
If we don't have an example for your form library of choice, please reach out, we'd be happy to add it.
Conform
Conform is a type-safe, progressive enhancement-focused form library with excellent accessibility support. Learn more in the tutorial or Next.js integration guide.
Conform V2 is currently available using /future exports but is still unstable.
Once stable, we'll update this guide to use the new API. For more information,
see the
Conform V2 announcement.
Code example
- TextField
- Schema
- Server Action
- Page
import { useField, getInputProps, type FieldName } from "@conform-to/react";
import { type InputHTMLAttributes } from "react";
interface TextFieldProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"name" | "id"
> {
name: FieldName<string>;
label: string;
}
export function TextField({ name, label, ...props }: TextFieldProps) {
const [field] = useField(name);
return (
<div>
<label htmlFor={field.id}>{label}</label>
<input
{...getInputProps(field, { type: props.type || "text" })}
{...props}
/>
<div id={field.errorId} aria-live="polite">
{field.errors?.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
</div>
);
}
import * as z from "zod";
export const loginSchema = z.object({
email: z.email({
error({ input }) {
if (input === undefined) return "Email is required";
return "Please enter a valid email";
},
}),
password: z.string("Password is required").min(1, "Password is required"),
});
export type LoginInput = z.infer<typeof loginSchema>;
"use server";
import { type Submission, parseWithZod } from "@conform-to/zod";
import { unstable_rethrow } from "next/navigation";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginInput } from "./schema";
export async function loginAction(
_prevState: Submission<LoginInput> | null,
formData: FormData,
) {
const submission = parseWithZod(formData, { schema: loginSchema });
if (submission.status !== "success") {
return submission.reply();
}
const { email, password } = submission.value;
try {
return await login({ input: { email, password } });
} catch (error) {
unstable_rethrow(error);
return submission.reply({
formErrors: ["Invalid email or password."],
});
}
}
Conform's parseWithZod() validates the form data and submission.reply()
returns errors in the format expected by useForm.
"use client";
import { getFormProps, useForm, FormProvider } from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { useActionState } from "react";
import { TextField } from "@/components/conform/TextField";
import { loginAction } from "./actions";
import { loginSchema } from "./schema";
export default function LoginPage() {
const [lastResult, action, pending] = useActionState(loginAction, null);
const [form, fields] = useForm({
lastResult,
constraint: getZodConstraint(loginSchema),
onValidate({ formData }) {
return parseWithZod(formData, { schema: loginSchema });
},
shouldValidate: "onBlur",
shouldRevalidate: "onInput",
});
return (
<FormProvider context={form.context}>
<form {...getFormProps(form)} action={action}>
<div id={form.errorId} aria-live="polite">
{form.errors?.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
<TextField
name={fields.email.name}
label="Email"
type="email"
autoComplete="username"
/>
<TextField
name={fields.password.name}
label="Password"
type="password"
autoComplete="current-password"
/>
<button type="submit" disabled={pending}>
{pending ? "Logging in..." : "Login"}
</button>
</form>
</FormProvider>
);
}
Formik
Formik is a well-established form library for React. See the tutorial for more details.
Despite recent activity, Formik has been largely unmaintained for a while, with significant gaps between releases. Consider this when choosing it for new projects.
Code example
- TextField
- Type
- Schema
- Server Action
- Page
import { ErrorMessage, useField } from "formik";
import { useId, type InputHTMLAttributes } from "react";
interface TextFieldProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"name"
> {
name: string;
label: string;
}
export function TextField({ id, name, label, ...props }: TextFieldProps) {
const [field, meta] = useField(name);
const fallbackId = useId();
const inputId = id || fallbackId;
const errorId = `${inputId}-error`;
const hasError = meta.touched && meta.error;
return (
<div>
<label htmlFor={inputId}>{label}</label>
<input
{...field}
{...props}
id={inputId}
aria-invalid={hasError ? true : undefined}
aria-describedby={hasError ? errorId : undefined}
/>
<div id={errorId} aria-live="polite">
<ErrorMessage name={name}>{(msg) => <p>{msg}</p>}</ErrorMessage>
</div>
</div>
);
}
export type ActionState<TData> = {
fieldErrors?: Partial<Record<keyof TData, string[]>>;
formErrors?: string[];
values?: Partial<TData>;
};
import * as z from "zod";
import type { ActionState } from "@/types/ActionState";
export const loginSchema = z.object({
email: z.email({
error({ input }) {
if (input === undefined) return "Email is required";
return "Please enter a valid email";
},
}),
password: z.string("Password is required").min(1, "Password is required"),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type LoginState = ActionState<LoginInput>;
"use server";
import { unstable_rethrow } from "next/navigation";
import * as z from "zod";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginState } from "./schema";
export async function loginAction(
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
const email = formData.get("email");
const password = formData.get("password");
const validated = loginSchema.safeParse({ email, password });
if (!validated.success) {
return {
...z.flattenError(validated.error),
values: {
email: typeof email === "string" ? email : "",
password: typeof password === "string" ? password : "",
},
};
}
try {
return await login({ input: validated.data });
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: validated.data,
};
}
}
This is the same Server Action from the Validation & Error Handling section. Formik only changes the client-side handling.
"use client";
import { Formik, Form } from "formik";
import { toFormikValidationSchema } from "zod-formik-adapter";
import { startTransition, useActionState } from "react";
import { TextField } from "@/components/formik/TextField";
import { loginAction } from "./actions";
import { loginSchema } from "./schema";
export default function LoginPage() {
const [state, action, pending] = useActionState(loginAction, {});
return (
<Formik
initialValues={{
email: state.values?.email || "",
password: state.values?.password || "",
}}
initialErrors={{
email: state.fieldErrors?.email?.[0],
password: state.fieldErrors?.password?.[0],
}}
initialTouched={{
email: !!state.fieldErrors?.email,
password: !!state.fieldErrors?.password,
}}
validationSchema={toFormikValidationSchema(loginSchema)}
onSubmit={(values, { setSubmitting }) => {
const formData = new FormData();
formData.set("email", values.email);
formData.set("password", values.password);
startTransition(() => {
action(formData);
setSubmitting(false);
});
}}
>
{({ handleSubmit, isSubmitting }) => (
<form action={action} onSubmit={handleSubmit} aria-describedby="login-form-errors">
<div aria-live="polite" id="login-form-errors">
{state.formErrors?.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
<TextField
name="email"
label="Email"
type="email"
autoComplete="username"
/>
<TextField
name="password"
label="Password"
type="password"
autoComplete="current-password"
/>
<button type="submit" disabled={pending || isSubmitting}>
{pending || isSubmitting ? "Logging in..." : "Login"}
</button>
</Form>
)}
</Formik>
);
}
React Hook Form
React Hook Form is a popular form library focused on performance and minimal re-renders. Check out the getting started guide to learn more.
Code example
- TextField
- Type
- Schema
- Server Action
- Page
import { useFormContext } from "react-hook-form";
import { useId, type InputHTMLAttributes } from "react";
interface TextFieldProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"name"
> {
name: string;
label: string;
serverErrors?: string[];
}
export function TextField({
id,
name,
label,
serverErrors,
...props
}: TextFieldProps) {
const {
register,
formState: { errors },
} = useFormContext();
const fallbackId = useId();
const inputId = id || fallbackId;
const errorId = `${inputId}-error`;
const clientError = errors[name]?.message;
const allErrors = [
...(clientError ? [String(clientError)] : []),
...(serverErrors || []),
];
const hasError = allErrors.length > 0;
return (
<div>
<label htmlFor={inputId}>{label}</label>
<input
{...props}
{...register(name)}
id={inputId}
aria-invalid={hasError ? true : undefined}
aria-describedby={hasError ? errorId : undefined}
/>
<div id={errorId} aria-live="polite">
{allErrors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
</div>
);
}
export type ActionState<TData> = {
fieldErrors?: Partial<Record<keyof TData, string[]>>;
formErrors?: string[];
values?: Partial<TData>;
};
import * as z from "zod";
import type { ActionState } from "@/types/ActionState";
export const loginSchema = z.object({
email: z.email({
error({ input }) {
if (input === undefined) return "Email is required";
return "Please enter a valid email";
},
}),
password: z.string("Password is required").min(1, "Password is required"),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type LoginState = ActionState<LoginInput>;
"use server";
import { unstable_rethrow } from "next/navigation";
import * as z from "zod";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginState } from "./schema";
export async function loginAction(
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
const email = formData.get("email");
const password = formData.get("password");
const validated = loginSchema.safeParse({ email, password });
if (!validated.success) {
return {
...z.flattenError(validated.error),
values: {
email: typeof email === "string" ? email : "",
password: typeof password === "string" ? password : "",
},
};
}
try {
return await login({ input: validated.data });
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: validated.data,
};
}
}
This is the same Server Action from the Validation & Error Handling section. React Hook Form only changes the client-side handling.
"use client";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useActionState } from "react";
import { TextField } from "@/components/react-hook-form/TextField";
import { loginAction } from "./actions";
import { loginSchema } from "./schema";
export default function LoginPage() {
const [state, action, pending] = useActionState(loginAction, {});
const methods = useForm({
resolver: zodResolver(loginSchema),
defaultValues: {
email: state.values?.email || "",
password: state.values?.password || "",
},
});
return (
<FormProvider {...methods}>
<form
action={action}
onSubmit={methods.handleSubmit((values) => {
const formData = new FormData();
formData.set("email", values.email);
formData.set("password", values.password);
action(formData);
})}
>
<div aria-live="polite">
{state.formErrors?.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
<TextField
name="email"
label="Email"
type="email"
autoComplete="username"
serverErrors={state.fieldErrors?.email}
/>
<TextField
name="password"
label="Password"
type="password"
autoComplete="current-password"
serverErrors={state.fieldErrors?.password}
/>
<button
type="submit"
disabled={pending || methods.formState.isSubmitting}
>
{pending || methods.formState.isSubmitting
? "Logging in..."
: "Login"}
</button>
</form>
</FormProvider>
);
}
Common pitfalls
Forgetting to re-throw redirect errors
Wrong:
import { login } from "@/lib/auth/server";
try {
return await login({ input: validated.data });
} catch (error) {
console.error(error); // This catches redirect errors too!
return {
formErrors: ["Invalid email or password"],
values: validated.data,
};
}
Why it's wrong: When login() succeeds, it calls redirect() which throws
an error. If you catch this error without re-throwing it, the user stays on the
login page even after successful authentication.
Correct:
import { unstable_rethrow } from "next/navigation";
import { login } from "@/lib/auth/server";
try {
return await login({ input: validated.data });
} catch (error) {
unstable_rethrow(error); // ← Critical! Let Next.js handle the redirect
return {
formErrors: ["Invalid email or password"],
values: validated.data,
};
}
Not validating before calling login()
Wrong:
import { login } from "@/lib/auth/server";
const email = formData.get("email");
const password = formData.get("password");
// No validation - might be null or wrong type!
return await login({ input: { email, password } });
Why it's wrong: formData.get() returns FormDataEntryValue | null, which
could be null or a File object. TypeScript won't catch this if you don't
validate.
Correct:
import * as z from "zod";
import { login } from "@/lib/auth/server";
const email = formData.get("email");
const password = formData.get("password");
const validated = loginSchema.safeParse({ email, password });
if (!validated.success) {
return {
...z.flattenError(validated.error),
values: {
email: typeof email === "string" ? email : "",
password: typeof password === "string" ? password : "",
},
};
}
// Now email and password are type-safe strings
return await login({ input: validated.data });
Returning values after successful redirect
Wrong:
import { unstable_rethrow } from "next/navigation";
import { login } from "@/lib/auth/server";
export async function loginAction(
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
// ... validation code
try {
await login({ input: validated.data }); // Missing return!
return {}; // TypeScript error: unreachable code
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: validated.data,
};
}
}
Why it's wrong: The function signature returns Promise<LoginState>, but
when login() succeeds and redirects, there's no return statement. TypeScript
correctly identifies this as missing a return value. Adding return {} after
the login call doesn't help because it's unreachable code (the redirect throws
before it executes).
Correct:
import { unstable_rethrow } from "next/navigation";
import { login } from "@/lib/auth/server";
export async function loginAction(
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
// ... validation code
try {
return await login({ input: validated.data }); // ← Satisfies TypeScript
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: validated.data,
};
}
}
Why this works: Even though login() redirects (throws) and never actually
returns a value, TypeScript needs a return statement to satisfy the function
signature. The return await login(...) pattern provides this while making the
code path explicit.
Not preserving form values on error
Wrong:
import * as z from "zod";
const email = formData.get("email");
const password = formData.get("password");
const validated = loginSchema.safeParse({ email, password });
if (!validated.success) {
return z.flattenError(validated.error); // User loses their input!
}
Why it's wrong: When validation fails, the user has to retype everything, including correctly entered values.
Correct:
import * as z from "zod";
const email = formData.get("email");
const password = formData.get("password");
const validated = loginSchema.safeParse({ email, password });
if (!validated.success) {
return {
...z.flattenError(validated.error),
values: {
email: typeof email === "string" ? email : "",
password: typeof password === "string" ? password : "",
},
};
}
Using value instead of defaultValue
Wrong:
import { useState } from "react";
const [email, setEmail] = useState("");
<input
value={email}
onChange={(e) => setEmail(e.target.value)} // Extra state management needed
/>;
Why it's wrong: Server Actions work with uncontrolled forms. Using value
creates a controlled component, requiring extra state management and onChange
handlers.
Correct:
<input
name="email"
defaultValue={state?.values?.email ?? ""}
// No onChange needed - form data comes from FormData
/>
Returning inconsistent error structures
Wrong:
// Sometimes returning field errors
if (!email) {
return { errors: { email: "Email required" } }; // String instead of array
}
// Sometimes returning form errors
if (loginFailed) {
return { error: "Login failed" }; // Different property name
}
Why it's wrong: Inconsistent error structures break your UI error display logic.
Correct:
import type { ActionState } from "@/types/ActionState";
// Always use the same structure from Zod's flatten()
export type ActionState<TData> = {
fieldErrors?: Partial<Record<keyof TData, string[]>>;
formErrors?: string[];
values?: Partial<TData>;
};
export type LoginState = ActionState<LoginInput>;