Building Forms
This guide shows how to build a login form. You will add validation, pending state, and accessibility features. The form works without JavaScript. The last section contains examples for Conform, Formik, and React Hook Form.
This guide requires Next.js 15.5 or later and React 19. The examples use
unstable_rethrow, useActionState, and the global PageProps type.
Complete the Getting Started: App Router
guide first. Create a working lib/auth/server.ts file. Export 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.
Create the JSON login endpoint used by this comparison:
import { createLoginHandler } from "@krakentech/blueprint-auth/server";
import { authConfig } from "@/lib/auth/config";
export const POST = createLoginHandler(authConfig);
Then create the client form:
"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 formData = new FormData(event.currentTarget);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: formData.get("email"),
password: formData.get("password"),
enableRedirect: false,
}),
});
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.
import type { AppRouterLoginParams } from "@krakentech/blueprint-auth/server";
export type SearchParams = AppRouterLoginParams.Redirect["searchParams"];
- Server Action
- Page
"use server";
import { AuthError, BlueprintAuthErrorCode } from "@krakentech/blueprint-auth";
import { unstable_rethrow } from "next/navigation";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
export async function loginAction(
searchParams: SearchParams,
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 },
searchParams,
});
} catch (error) {
unstable_rethrow(error);
console.error(
"Login failed:",
error instanceof AuthError
? error.errorCode
: BlueprintAuthErrorCode.Unknown,
);
}
}
import { loginAction } from "./actions";
export default function LoginPage({ searchParams }: PageProps<"/login">) {
const action = loginAction.bind(null, searchParams);
return (
<form action={action}>
<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 a bound Server Action instead of onSubmit enables progressive
enhancement. The page binds its searchParams Promise so login() can return
the user to the route that they requested before authentication. 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. Log only the error code, not the error message, object, or cause.
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
- Form
- Page
"use server";
import { AuthError, BlueprintAuthErrorCode } from "@krakentech/blueprint-auth";
import { unstable_rethrow } from "next/navigation";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
export type LoginState = {
values?: { email: string };
};
export type LoginAction = (
previousState: LoginState | null,
formData: FormData,
) => Promise<LoginState>;
export async function loginAction(
searchParams: SearchParams,
_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 : "",
},
};
}
try {
return await login({
input: { email, password },
searchParams,
});
} catch (error) {
unstable_rethrow(error);
console.error(
"Login failed:",
error instanceof AuthError
? error.errorCode
: BlueprintAuthErrorCode.Unknown,
);
return {
values: { email },
};
}
}
"use client";
import { useActionState } from "react";
import type { LoginAction } from "./actions";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const [state, formAction] = useActionState(action, null);
return (
<form action={formAction}>
<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" required />
</div>
<button type="submit">Login</button>
</form>
);
}
import { LoginForm } from "./LoginForm";
import { loginAction } from "./actions";
export default function LoginPage({ searchParams }: PageProps<"/login">) {
const action = loginAction.bind(null, searchParams);
return <LoginForm action={action} />;
}
The form now keeps the email address after a failed login. The user must enter the password again.
Return only non-sensitive values in Server Action state. Never return a password, token, or other secret to the browser. Never set a password input from returned state.
useActionState connects the form to the Server Action. It manages state across
submissions. This hook requires React 19.
Pending state​
The form keeps its non-sensitive state. It does not show when a submission is
in progress. Add pending feedback with the third value from useActionState.
"use client";
import { useActionState } from "react";
import type { LoginAction } from "./actions";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const [state, formAction, isPending] = useActionState(action, null);
return (
<form action={formAction}>
<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" 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
- Form
- Page
export type ActionState<TFields, TValues = Partial<TFields>> = {
fieldErrors?: Partial<Record<keyof TFields, string[]>>;
formErrors?: string[];
values?: TValues;
};
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, Pick<LoginInput, "email">>;
"use server";
import { unstable_rethrow } from "next/navigation";
import * as z from "zod";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginState } from "./schema";
export type LoginAction = (
previousState: LoginState | null,
formData: FormData,
) => Promise<LoginState>;
export async function loginAction(
searchParams: SearchParams,
_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 : "",
},
};
}
try {
return await login({
input: validated.data,
searchParams,
});
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: { email: validated.data.email },
};
}
}
"use client";
import { useActionState } from "react";
import type { LoginAction } from "./actions";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const [state, formAction, isPending] = useActionState(action, null);
return (
<form action={formAction}>
<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" 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>
);
}
import { LoginForm } from "./LoginForm";
import { loginAction } from "./actions";
export default function LoginPage({ searchParams }: PageProps<"/login">) {
const action = loginAction.bind(null, searchParams);
return <LoginForm action={action} />;
}
The form now validates the input. It shows field errors and form errors. It keeps the email address after an error. It clears the password.
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
- Form
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, Values> = {
action: (
state: ActionState<z.output<Schema>, Values> | null,
formData: FormData,
) => Promise<ActionState<z.output<Schema>, Values>>;
schema: Schema;
};
export function useForm<Schema extends z.ZodType, Values>({
action,
schema,
}: UseFormOptions<Schema, Values>) {
const [serverState, formAction, isPending] = useActionState(action, null);
const [clientState, setClientState] = useState<
Omit<ActionState<z.output<Schema>, Values>, "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;
}
// Clear client errors after successful validation.
startTransition(() => {
setClientState({});
});
}
// Hide stale server errors during submission.
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 type { LoginAction } from "./actions";
import { loginSchema } from "./schema";
import { useForm } from "@/hooks/useForm";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const {
fieldErrors,
formAction,
formErrors,
isPending,
validateForm,
values,
} = useForm({
action,
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" 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 type { LoginAction } from "./actions";
import { loginSchema } from "./schema";
import { useForm } from "@/hooks/useForm";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const {
fieldErrors,
formAction,
formErrors,
isPending,
validateForm,
values,
} = useForm({
action,
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"
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
- Form
- 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<TFields, TValues = Partial<TFields>> = {
fieldErrors?: Partial<Record<keyof TFields, string[]>>;
formErrors?: string[];
values?: TValues;
};
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, Values> = {
action: (
state: ActionState<z.output<Schema>, Values> | null,
formData: FormData,
) => Promise<ActionState<z.output<Schema>, Values>>;
schema: Schema;
};
export function useForm<Schema extends z.ZodType, Values>({
action,
schema,
}: UseFormOptions<Schema, Values>) {
const [serverState, formAction, isPending] = useActionState(action, null);
const [clientState, setClientState] = useState<
Omit<ActionState<z.output<Schema>, Values>, "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;
}
// Clear client errors after successful validation.
startTransition(() => {
setClientState({});
});
}
// Hide stale server errors during submission.
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, Pick<LoginInput, "email">>;
"use server";
import { unstable_rethrow } from "next/navigation";
import * as z from "zod";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginState } from "./schema";
export type LoginAction = (
previousState: LoginState | null,
formData: FormData,
) => Promise<LoginState>;
export async function loginAction(
searchParams: SearchParams,
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
// 1. Validate the input.
const email = formData.get("email");
const password = formData.get("password");
const validated = loginSchema.safeParse({ email, password });
if (!validated.success) {
// 2. Preserve only non-sensitive values.
return {
...z.flattenError(validated.error),
values: {
email: typeof email === "string" ? email : "",
},
};
}
try {
// 3. Attempt the login. A successful login redirects the user.
return await login({
input: validated.data,
searchParams,
});
} catch (error) {
// 4. Let Next.js handle redirect errors.
unstable_rethrow(error);
// 5. Return a consistent and non-sensitive error state.
return {
formErrors: ["Invalid email or password"],
values: { email: validated.data.email },
};
}
}
"use client";
import { useId } from "react";
import { TextField } from "@/components/TextField";
import type { LoginAction } from "./actions";
import { loginSchema } from "./schema";
import { useForm } from "@/hooks/useForm";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const {
fieldErrors,
formAction,
formErrors,
isPending,
validateForm,
values,
} = useForm({
action,
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"
errors={fieldErrors?.password}
required
/>
<button type="submit" disabled={isPending}>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
}
import { LoginForm } from "./LoginForm";
import { loginAction } from "./actions";
export default function LoginPage({ searchParams }: PageProps<"/login">) {
const action = loginAction.bind(null, searchParams);
return <LoginForm action={action} />;
}
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.
The email address persists after a failed submission. This also works before the page becomes interactive. The Server Action does not return the password. The user must enter the password again.
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
- Form
- 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 { parseWithZod } from "@conform-to/zod/v4";
import { unstable_rethrow } from "next/navigation";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
import { loginSchema } from "./schema";
export type LoginAction = (
previousState: unknown,
formData: FormData,
) => ReturnType<typeof loginAction>;
export async function loginAction(
searchParams: SearchParams,
_prevState: unknown,
formData: FormData,
) {
const submission = parseWithZod(formData, { schema: loginSchema });
if (submission.status !== "success") {
return submission.reply({ hideFields: ["password"] });
}
const { email, password } = submission.value;
try {
return await login({
input: { email, password },
searchParams,
});
} catch (error) {
unstable_rethrow(error);
return submission.reply({
formErrors: ["Invalid email or password."],
hideFields: ["password"],
});
}
}
parseWithZod() validates the form data. submission.reply() returns the
errors that useForm expects. The hideFields option removes the password from
the returned form state.
"use client";
import { FormProvider, getFormProps, useForm } from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod/v4";
import { useActionState } from "react";
import { TextField } from "@/components/conform/TextField";
import type { LoginAction } from "./actions";
import { loginSchema } from "./schema";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const [lastResult, formAction, pending] = useActionState(action, 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={formAction}>
<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>
);
}
import { LoginForm } from "./LoginForm";
import { loginAction } from "./actions";
export default function LoginPage({ searchParams }: PageProps<"/login">) {
const action = loginAction.bind(null, searchParams);
return <LoginForm action={action} />;
}
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
- Form
- 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<TFields, TValues = Partial<TFields>> = {
fieldErrors?: Partial<Record<keyof TFields, string[]>>;
formErrors?: string[];
values?: TValues;
};
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, Pick<LoginInput, "email">>;
"use server";
import { unstable_rethrow } from "next/navigation";
import * as z from "zod";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginState } from "./schema";
export type LoginAction = (
previousState: LoginState | null,
formData: FormData,
) => Promise<LoginState>;
export async function loginAction(
searchParams: SearchParams,
_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 : "",
},
};
}
try {
return await login({
input: validated.data,
searchParams,
});
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: { email: validated.data.email },
};
}
}
Use the Server Action from the server-side validation section. Formik changes only the client handling.
"use client";
import { Form, Formik } from "formik";
import { startTransition, useActionState } from "react";
import { toFormikValidationSchema } from "zod-formik-adapter";
import { TextField } from "@/components/formik/TextField";
import type { LoginAction } from "./actions";
import { loginSchema } from "./schema";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const [state, formAction, pending] = useActionState(action, {});
return (
<Formik
enableReinitialize
initialValues={{
email: state.values?.email ?? "",
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, { resetForm }) => {
const formData = new FormData();
formData.set("email", values.email);
formData.set("password", values.password);
startTransition(() => {
formAction(formData);
});
resetForm({ values: { email: values.email, password: "" } });
}}
>
{({ isSubmitting }) => (
<Form action={formAction} 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>
);
}
import { LoginForm } from "./LoginForm";
import { loginAction } from "./actions";
export default function LoginPage({ searchParams }: PageProps<"/login">) {
const action = loginAction.bind(null, searchParams);
return <LoginForm action={action} />;
}
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
- Form
- 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<TFields, TValues = Partial<TFields>> = {
fieldErrors?: Partial<Record<keyof TFields, string[]>>;
formErrors?: string[];
values?: TValues;
};
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, Pick<LoginInput, "email">>;
"use server";
import { unstable_rethrow } from "next/navigation";
import * as z from "zod";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
import { loginSchema, type LoginState } from "./schema";
export type LoginAction = (
previousState: LoginState | null,
formData: FormData,
) => Promise<LoginState>;
export async function loginAction(
searchParams: SearchParams,
_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 : "",
},
};
}
try {
return await login({
input: validated.data,
searchParams,
});
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: { email: validated.data.email },
};
}
}
Use the Server Action from the server-side validation section. React Hook Form changes only the client handling.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { startTransition, useActionState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { TextField } from "@/components/react-hook-form/TextField";
import type { LoginAction } from "./actions";
import { loginSchema, type LoginInput } from "./schema";
type LoginFormProps = {
action: LoginAction;
};
export function LoginForm({ action }: LoginFormProps) {
const [state, formAction, pending] = useActionState(action, {});
const methods = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: state.values?.email ?? "",
password: "",
},
});
return (
<FormProvider {...methods}>
<form
action={formAction}
onSubmit={methods.handleSubmit((values) => {
const formData = new FormData();
formData.set("email", values.email);
formData.set("password", values.password);
startTransition(() => {
formAction(formData);
});
methods.reset({ email: values.email, password: "" });
})}
>
<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>
);
}
import { LoginForm } from "./LoginForm";
import { loginAction } from "./actions";
export default function LoginPage({ searchParams }: PageProps<"/login">) {
const action = loginAction.bind(null, searchParams);
return <LoginForm action={action} />;
}
Common pitfalls​
The snippets in this section use the searchParams argument that the login
page binds to loginAction.
Forgetting to rethrow redirect errors
Wrong:
import { login } from "@/lib/auth/server";
try {
return await login({
input: validated.data,
searchParams,
});
} catch (error) {
console.error("Login failed:", error); // This also catches redirect errors.
return {
formErrors: ["Invalid email or password"],
values: { email: validated.data.email },
};
}
Why it is wrong: login() calls redirect() after a successful login.
redirect() throws an internal Next.js error. The catch block must rethrow this
error. Otherwise, the user stays on the login page.
Correct:
import { unstable_rethrow } from "next/navigation";
import { login } from "@/lib/auth/server";
try {
return await login({
input: validated.data,
searchParams,
});
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: { email: validated.data.email },
};
}
Not validating before calling login()
Wrong:
import { login } from "@/lib/auth/server";
const email = formData.get("email");
const password = formData.get("password");
// Without validation, these values might be null or have the wrong type.
return await login({
input: { email, password },
searchParams,
});
Why it is wrong: formData.get() returns FormDataEntryValue | null. The
value can be null or a File object. Validate the value before you use it.
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 : "",
},
};
}
// The validated email and password are strings.
return await login({
input: validated.data,
searchParams,
});
Omitting the return before login()
Wrong:
import { unstable_rethrow } from "next/navigation";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
export async function loginAction(
searchParams: SearchParams,
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
// ... validation code
try {
await login({
input: validated.data,
searchParams,
});
return {};
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: { email: validated.data.email },
};
}
}
Why it is wrong: A successful login() call redirects the user. The
redirect throws before return {} can run. Return the login() call to satisfy
the function type.
Correct:
import { unstable_rethrow } from "next/navigation";
import type { SearchParams } from "@/lib/auth/search-params";
import { login } from "@/lib/auth/server";
export async function loginAction(
searchParams: SearchParams,
_prevState: LoginState | null,
formData: FormData,
): Promise<LoginState> {
// ... validation code
try {
return await login({
input: validated.data,
searchParams,
});
} catch (error) {
unstable_rethrow(error);
return {
formErrors: ["Invalid email or password"],
values: { email: validated.data.email },
};
}
}
The return await login(...) statement makes the code path explicit. It also
satisfies the function type.
Not preserving safe 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);
}
Why it is wrong: The user must enter the email address again after a validation error.
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 : "",
},
};
}
Preserve only non-sensitive values. Do not return the 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 is needed because FormData supplies the form values.
/>
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";
// Use the shared structure for all Zod errors.
export type LoginState = ActionState<LoginInput, Pick<LoginInput, "email">>;