Skip to main content

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.

Prerequisites

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:

app/api/auth/login/route.ts
import { createLoginHandler } from "@krakentech/blueprint-auth/server";
import { authConfig } from "@/lib/auth/config";

export const POST = createLoginHandler(authConfig);

Then create the client form:

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

lib/auth/search-params.ts
import type { AppRouterLoginParams } from "@krakentech/blueprint-auth/server";

export type SearchParams = AppRouterLoginParams.Redirect["searchParams"];
app/login/actions.ts
"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,
);
}
}

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.

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

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

The form now keeps the email address after a failed login. The user must enter the password again.

Do not return secrets

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.

Why useActionState?

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.

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

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<TFields, TValues = Partial<TFields>> = {
fieldErrors?: Partial<Record<keyof TFields, string[]>>;
formErrors?: string[];
values?: TValues;
};

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.

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

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/LoginForm.tsx
"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.

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.

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.

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​

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