Skip to main content

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.

Prerequisites

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.

app/login/page.tsx
"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.

app/login/actions.ts
"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);
}
}

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.

Handle Redirect Errors

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.

app/login/actions.ts
"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 },
};
}
}

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.

Why useActionState?

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.

app/login/page.tsx
"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.

Validation library

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.

types/ActionState.ts
export type ActionState<TData> = {
fieldErrors?: Partial<Record<keyof TData, string[]>>;
formErrors?: string[];
values?: Partial<TData>;
};

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.

hooks/useForm.ts
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,
};
}

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.

app/login/page.tsx
"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.

Key Accessibility Improvements
AttributePurpose
useId()Generates unique, stable IDs to prevent conflicts
aria-live="polite"Announces errors to screen readers without interrupting the user
aria-invalidMarks fields with validation errors for assistive technologies
aria-describedbyConnects error messages to their corresponding inputs
autoCompleteEnables 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.

components/TextField.tsx
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>
);
}

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.

What's Missing

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.

Missing your form library?

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

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
components/conform/TextField.tsx
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>
);
}

Formik

Formik is a well-established form library for React. See the tutorial for more details.

Maintenance Status

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
components/formik/TextField.tsx
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>
);
}

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
components/react-hook-form/TextField.tsx
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>
);
}

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>;