TanStack Form is compatible with React out of the box, supporting SSR and being framework-agnostic. However, specific configurations are necessary according to your chosen framework.
Today we support the following meta-frameworks:
TanStack Start Integration
TanStack Start provides first-class integration with TanStack Form, including server-side validation.
Prerequisites
Setup
First, create a formOptions to share the form’s shape across client and server:
// app/routes/index.tsx
import { formOptions } from '@tanstack/react-form-start'
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
Server Validation
Create a Start Server Function to handle form submission:
// app/routes/index.tsx
import {
createServerValidate,
ServerValidateError,
} from '@tanstack/react-form-start'
import { createServerFn } from '@tanstack/start'
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export const handleForm = createServerFn({
method: 'POST',
})
.inputValidator((data: unknown) => {
if (!(data instanceof FormData)) {
throw new Error('Invalid form data')
}
return data
})
.handler(async (ctx) => {
try {
const validatedData = await serverValidate(ctx.data)
console.log('validatedData', validatedData)
// Persist to database
// await db.insert(validatedData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.response
}
console.error(e)
setResponseStatus(500)
return 'There was an internal error'
}
return 'Form has validated successfully'
})
Server Data Loader
Create a server function to retrieve form data:
// app/routes/index.tsx
import { getFormData } from '@tanstack/react-form-start'
export const getFormDataFromServer = createServerFn({ method: 'GET' }).handler(
async () => {
return getFormData()
},
)
Client Component
Use the server data in your client component:
// app/routes/index.tsx
import {
createFileRoute,
mergeForm,
useForm,
useStore,
useTransform,
} from '@tanstack/react-form-start'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => ({
state: await getFormDataFromServer(),
}),
})
function Home() {
const { state } = Route.useLoaderData()
const form = useForm({
...formOpts,
transform: useTransform((baseForm) => mergeForm(baseForm, state), [state]),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<form action={handleForm.url} method="post" encType="multipart/form-data">
{formErrors.map((error) => (
<p key={error as string} style={{ color: 'red' }}>
{error}
</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => (
<div>
<label htmlFor={field.name}>Age</label>
<input
id={field.name}
name={field.name}
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string} style={{ color: 'red' }}>
{error}
</p>
))}
</div>
)}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</form>
)
}
TanStack Start’s integration allows both client-side and server-side validation, giving you the best of both worlds.
Next.js App Router Integration
Prerequisites
- Start a new Next.js project following the Next.js Documentation
- Select “yes” for “Would you like to use App Router?”
- Install
@tanstack/react-form
- Optionally install a form validator
Create a shared configuration file:
// shared-code.ts
import { formOptions } from '@tanstack/react-form-nextjs'
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
Notice the import path is @tanstack/react-form-nextjs for server-side code, not @tanstack/react-form. This is a Next.js-specific requirement.
Server Action
Create a React Server Action:
// action.ts
'use server'
import {
ServerValidateError,
createServerValidate,
} from '@tanstack/react-form-nextjs'
import { formOpts } from './shared-code'
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export default async function someAction(prev: unknown, formData: FormData) {
try {
const validatedData = await serverValidate(formData)
console.log('validatedData', validatedData)
// Persist to database
// await db.insert(validatedData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.formState
}
throw e
}
// Form validated successfully!
}
Client Component
// client-component.tsx
'use client'
import { useActionState } from 'react'
import {
initialFormState,
mergeForm,
useForm,
useStore,
useTransform,
} from '@tanstack/react-form-nextjs'
import someAction from './action'
import { formOpts } from './shared-code'
export const ClientComp = () => {
const [state, action] = useActionState(someAction, initialFormState)
const form = useForm({
...formOpts,
transform: useTransform((baseForm) => mergeForm(baseForm, state!), [state]),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<form action={action as never} onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as string} style={{ color: 'red' }}>
{error}
</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => (
<div>
<label htmlFor={field.name}>Age</label>
<input
id={field.name}
name={field.name} // Must explicitly set name for POST request
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string} style={{ color: 'red' }}>
{error}
</p>
))}
</div>
)}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</form>
)
}
Use React’s useActionState hook and TanStack Form’s useTransform to merge state returned from the server action with the form state.
Remix Integration
Prerequisites
// routes/_index/route.tsx
import {
ServerValidateError,
createServerValidate,
formOptions,
} from '@tanstack/react-form-remix'
import type { ActionFunctionArgs } from '@remix-run/node'
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
try {
const validatedData = await serverValidate(formData)
console.log('validatedData', validatedData)
// Persist to database
// await db.insert(validatedData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.formState
}
throw e
}
// Form validated successfully!
}
Component
// routes/_index/route.tsx
import {
Form,
mergeForm,
useActionData,
useForm,
useStore,
useTransform,
} from '@tanstack/react-form'
import { initialFormState } from '@tanstack/react-form-remix'
export default function Index() {
const actionData = useActionData<typeof action>()
const form = useForm({
...formOpts,
transform: useTransform(
(baseForm) => mergeForm(baseForm, actionData ?? initialFormState),
[actionData],
),
})
const formErrors = useStore(form.store, (formState) => formState.errors)
return (
<Form method="post" onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as string} style={{ color: 'red' }}>
{error}
</p>
))}
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => (
<div>
<label htmlFor={field.name}>Age</label>
<input
id={field.name}
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string} style={{ color: 'red' }}>
{error}
</p>
))}
</div>
)}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</Form>
)
}
Use Remix’s useActionData hook and TanStack Form’s useTransform to merge state returned from the server action with the form state.
Benefits of SSR Integration
- Progressive Enhancement: Forms work without JavaScript
- Server-Side Validation: Validate on the server for security
- Type Safety: Share types between client and server
- Better UX: Faster initial page loads with server rendering
- SEO Friendly: Search engines can see your form content
Best Practices
- Always validate on the server, even if you validate on the client
- Use the correct import paths for your framework
- Handle errors gracefully with try/catch blocks
- Provide loading states during form submission
- Reset forms after successful submission when appropriate
Never trust client-side validation alone. Always validate on the server to prevent malicious submissions.