Powerful, type-safe forms for React.
Server-side validation utilities for Next.js, Remix, and TanStack Start frameworks. These integrations enable Progressive Enhancement patterns with server-side validation and client-side hydration.
Server-side validation for Next.js Server Actions.
/**
* Creates a server validation function for Next.js Server Actions
* Validates FormData on the server and throws ServerValidateError on failure
*
* @param defaultOpts - Configuration for server validation
* @returns Async function that validates FormData and returns parsed data
*/
function createServerValidate<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined,
>(
defaultOpts: CreateServerValidateOptions<TFormData, TOnServer>,
): (
formData: FormData,
info?: { resolve?: (fieldName: string) => string | File },
) => Promise<TFormData>;
interface CreateServerValidateOptions<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
> {
/** Default form values */
defaultValues?: TFormData;
/**
* Server-side validator function or Standard Schema
* Validates the parsed form data before processing
*/
onServerValidate?: TOnServer;
/**
* Custom parser for FormData
* @param formData - Raw FormData from form submission
* @returns Parsed form data object
*/
parse?: (formData: FormData) => TFormData;
}
/**
* Initial form state constant
* Use this as default state before server validation completes
*/
const initialFormState: ServerFormState<any, undefined>;
/**
* Server validation error class
* Thrown by createServerValidate when validation fails
*/
class ServerValidateError<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
> extends Error {
/** Form state with validation errors */
formState: ServerFormState<TFormData, TOnServer>;
constructor(formState: ServerFormState<TFormData, TOnServer>);
}
/**
* Server form state type
* Subset of full FormState used for server-side validation
*/
type ServerFormState<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
> = Pick<
FormState<TFormData, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, TOnServer>,
'values' | 'errors' | 'errorMap'
>;Usage Example:
// app/actions.ts (Next.js Server Action)
'use server';
import { createServerValidate } from '@tanstack/react-form/nextjs';
import { z } from 'zod';
const serverValidate = createServerValidate({
defaultValues: {
name: '',
email: '',
},
onServerValidate: z.object({
name: z.string().min(2),
email: z.string().email(),
}),
});
export async function submitForm(prev: any, formData: FormData) {
try {
const data = await serverValidate(formData);
// Process validated data
await saveToDatabase(data);
return { success: true };
} catch (error) {
if (error instanceof ServerValidateError) {
return error.formState;
}
throw error;
}
}
// app/form.tsx (Client Component)
'use client';
import { useForm } from '@tanstack/react-form';
import { useFormState } from 'react-dom';
import { submitForm } from './actions';
import { initialFormState } from '@tanstack/react-form/nextjs';
export function MyForm() {
const [serverFormState, formAction] = useFormState(submitForm, initialFormState);
const form = useForm({
defaultValues: {
name: '',
email: '',
},
// Merge server validation errors with client state
defaultState: serverFormState,
});
return (
<form action={formAction}>
<form.Field name="name">
{(field) => (
<input
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<input
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>
<button type="submit">Submit</button>
</form>
);
}Server-side validation for Remix Form Actions.
/**
* Creates a server validation function for Remix actions
* Validates FormData on the server and throws ServerValidateError on failure
*
* @param defaultOpts - Configuration for server validation
* @returns Async function that validates FormData and returns parsed data
*/
function createServerValidate<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined,
>(
defaultOpts: CreateServerValidateOptions<TFormData, TOnServer>,
): (
formData: FormData,
info?: { resolve?: (fieldName: string) => string | File },
) => Promise<TFormData>;
/**
* Initial form state constant
* Use this as default state in loader functions
*/
const initialFormState: ServerFormState<any, undefined>;
/**
* Server validation error class
* Thrown by createServerValidate when validation fails
*/
class ServerValidateError<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
> extends Error {
/** Form state with validation errors */
formState: ServerFormState<TFormData, TOnServer>;
constructor(formState: ServerFormState<TFormData, TOnServer>);
}
/** Server form state type (same as Next.js) */
type ServerFormState<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
> = Pick<
FormState<TFormData, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, TOnServer>,
'values' | 'errors' | 'errorMap'
>;Usage Example:
// app/routes/contact.tsx (Remix)
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { useActionData, Form } from '@remix-run/react';
import { useForm } from '@tanstack/react-form';
import { createServerValidate, initialFormState } from '@tanstack/react-form/remix';
import { z } from 'zod';
const serverValidate = createServerValidate({
defaultValues: {
name: '',
email: '',
message: '',
},
onServerValidate: z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
}),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
try {
const data = await serverValidate(formData);
// Process validated data
await sendEmail(data);
return json({ success: true });
} catch (error) {
if (error instanceof ServerValidateError) {
return json(error.formState);
}
throw error;
}
}
export default function ContactRoute() {
const actionData = useActionData<typeof action>();
const serverFormState = actionData?.success ? initialFormState : actionData;
const form = useForm({
defaultValues: {
name: '',
email: '',
message: '',
},
defaultState: serverFormState,
});
return (
<Form method="post">
<form.Field name="name">
{(field) => (
<div>
<input
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0]}
</div>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<div>
<input
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0]}
</div>
)}
</form.Field>
<form.Field name="message">
{(field) => (
<div>
<textarea
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0]}
</div>
)}
</form.Field>
<button type="submit">Submit</button>
</Form>
);
}Server-side validation for TanStack Start with cookie-based state persistence.
/**
* Creates a server validation function for TanStack Start
* Validates FormData and stores state in cookies for post-redirect access
*
* @param defaultOpts - Configuration for server validation
* @returns Async function that validates FormData and returns parsed data
*/
function createServerValidate<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined,
>(
defaultOpts: CreateServerValidateOptions<TFormData, TOnServer>,
): (
formData: FormData,
info?: { resolve?: (fieldName: string) => string | File },
) => Promise<TFormData>;
/**
* Retrieves form data from cookies set by server validation
* Use this in your component to access validation state after redirect
*
* @returns Promise resolving to server form state or initialFormState
*/
function getFormData(): Promise<
ServerFormState<any, undefined> | typeof initialFormState
>;
/**
* Initial form state constant
*/
const initialFormState: {
errorMap: { onServer: undefined };
errors: [];
};
/**
* Server validation error class for TanStack Start
* Includes Response object for redirect handling
*/
class ServerValidateError<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
> extends Error {
/** Form state with validation errors */
formState: ServerFormState<TFormData, TOnServer>;
/** Response object for redirects and headers */
response: Response;
constructor(formState: ServerFormState<TFormData, TOnServer>, response: Response);
}
/** Server form state type */
type ServerFormState<
TFormData,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
> = Pick<
FormState<TFormData, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, TOnServer>,
'values' | 'errors' | 'errorMap'
>;Usage Example:
// app/routes/contact.tsx (TanStack Start)
import { createServerFn } from '@tanstack/start';
import { useServerFn } from '@tanstack/start';
import { useForm } from '@tanstack/react-form';
import {
createServerValidate,
getFormData,
initialFormState,
ServerValidateError,
} from '@tanstack/react-form/start';
import { z } from 'zod';
const serverValidate = createServerValidate({
defaultValues: {
name: '',
email: '',
},
onServerValidate: z.object({
name: z.string().min(2),
email: z.string().email(),
}),
});
const submitFormAction = createServerFn({ method: 'POST' })
.validator((formData: FormData) => formData)
.handler(async ({ data }) => {
try {
const validated = await serverValidate(data);
// Process validated data
await saveToDatabase(validated);
return { success: true };
} catch (error) {
if (error instanceof ServerValidateError) {
// Throw the response with cookies
throw error.response;
}
throw error;
}
});
export default function ContactRoute() {
const serverFormState = getFormData();
const submitForm = useServerFn(submitFormAction);
const form = useForm({
defaultValues: {
name: '',
email: '',
},
defaultState: serverFormState,
onSubmit: async ({ value }) => {
const formData = new FormData();
formData.append('name', value.name);
formData.append('email', value.email);
await submitForm({ data: formData });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field name="name">
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0]}
</div>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0]}
</div>
)}
</form.Field>
<button type="submit">Submit</button>
</form>
);
}// Next.js
import {
createServerValidate,
initialFormState,
ServerValidateError,
type ServerFormState,
} from '@tanstack/react-form/nextjs';
// Remix
import {
createServerValidate,
initialFormState,
ServerValidateError,
type ServerFormState,
} from '@tanstack/react-form/remix';
// TanStack Start
import {
createServerValidate,
getFormData,
initialFormState,
ServerValidateError,
type ServerFormState,
} from '@tanstack/react-form/start';All framework integrations support Progressive Enhancement where forms work without JavaScript:
// The form submits to server even without JS
<form action={formAction} method="post">
{/* name attribute required for server-side parsing */}
<input name="email" />
<button type="submit">Submit</button>
</form>
// With JS, client-side validation enhances UX
<form
action={formAction}
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field name="email">
{(field) => (
<input
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>
</form>const serverValidate = createServerValidate({
defaultValues: {
tags: [],
metadata: {},
},
parse: (formData) => {
return {
tags: formData.getAll('tags'),
metadata: JSON.parse(formData.get('metadata') as string),
};
},
onServerValidate: (data) => {
if (data.tags.length === 0) {
return 'At least one tag required';
}
return undefined;
},
});const serverValidate = createServerValidate({
defaultValues: {
file: null as File | null,
},
parse: (formData) => ({
file: formData.get('file') as File,
}),
onServerValidate: async ({ value }) => {
if (!value.file) {
return { fields: { file: 'File is required' } };
}
if (value.file.size > 5 * 1024 * 1024) {
return { fields: { file: 'File must be less than 5MB' } };
}
return undefined;
},
});// shared/validation.ts
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
// server/action.ts
import { createServerValidate } from '@tanstack/react-form/nextjs';
import { contactSchema } from '../shared/validation';
export const serverValidate = createServerValidate({
defaultValues: {
name: '',
email: '',
message: '',
},
onServerValidate: contactSchema,
});
// client/form.tsx
import { useForm } from '@tanstack/react-form';
import { contactSchema } from '../shared/validation';
export function ContactForm() {
const form = useForm({
defaultValues: {
name: '',
email: '',
message: '',
},
validators: {
onChange: contactSchema,
},
});
return <form>{/* fields */}</form>;
}Install with Tessl CLI
npx tessl i tessl/npm-tanstack--react-form