⚡️ End-to-end type-safety from client to server. Inspired by react-hook-form and next-safe-action.
- ✅ Ridiculously easy to use
- ✅ 100% type-safe
- ✅ Input validation using Standard Schema
- ✅ Server error handling
- ✅ Automatic input binding
- ✅ Native file upload support
- React >=19
- A React framework or bundler with Server Functions support
- A Standard Schema compatible validator, such as zod, Valibot, or ArkType
npm install safe-formInstall a validator separately if your app does not already have one:
npm install zodUse the safe-form/server and safe-form/client entrypoints to keep server
and client boundaries explicit.
First, define your schema in a separate file, so you can use it both in the form and in the server action. This example uses zod, but safe-form accepts any Standard Schema compatible validator:
schema.ts
import { z } from 'zod'
export const exampleSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
message: z.string().min(10, 'Message must be at least 10 characters'),
attachment: z.instanceof(File).nullish()
})Now, create a server action:
action.ts
'use server'
import { createFormAction, FormActionError } from 'safe-form/server'
import { exampleSchema } from './schema'
export const exampleAction = createFormAction(exampleSchema, async (input) => {
if (input.attachment && input.attachment.size >= 1024 * 1024 * 10) {
throw new FormActionError('The maximum file size is 10MB.') // Custom errors! 💜
}
return `Hello, ${input.name}! Your message is: ${input.message}.`
})Finally, create a form as a client component:
form.tsx
'use client'
import { useForm } from 'safe-form/client'
import { exampleAction } from './action'
import { exampleSchema } from './schema'
export const HelloForm = () => {
const { connect, bindField, isPending, error, fieldErrors, response } =
useForm({
action: exampleAction,
schema: exampleSchema
})
return (
<form {...connect()}>
<label htmlFor='name'>Name</label>
<input {...bindField('name')} />
{fieldErrors.name && <pre>{fieldErrors.name.first}</pre>}
<br />
<label htmlFor='message'>Message</label>
<textarea {...bindField('message')} />
{fieldErrors.message && <pre>{fieldErrors.message.first}</pre>}
<br />
<label htmlFor='attachment'>Attachment (optional)</label>
<input type='file' {...bindField('attachment')} />
{fieldErrors.attachment && <pre>{fieldErrors.attachment.first}</pre>}
<br />
<button type='submit' disabled={isPending}>
Submit
</button>
<br />
{error && <pre>{error}</pre>}
{response && <div>{response}</div>}
</form>
)
}connect() wires the form both ways. With JavaScript enabled, values are
serialized with rich types: date inputs produce Date objects, checkboxes
produce booleans, and number inputs produce numbers. Without JavaScript, the
browser submits the native FormData as a fallback, so every value reaches
the server action as a plain string (e.g. "2022-01-01" for dates, "on" or
absent for checkboxes).
If you rely on the no-JS fallback, make your schema accept both representations. With zod, for example:
export const exampleSchema = z.object({
birthDate: z.coerce.date(),
subscribed: z.coerce.boolean().optional().default(false)
})- Date inputs follow the
valueAsDateconvention: values are interpreted as midnight UTC, both when reading from and writing to the input. - Validation issues without a path (e.g. object-level refinements) are exposed
separately as
rootError(also passed as the third argument toonError), sofieldErrorsstays keyed strictly by your schema's fields.rootErroronly refreshes on full validation (submit(),validate()orreset()) — field-level validation can't tell whether an object-level rule passes. - The displayed
error,fieldErrorsandrootErroralways come from a single validation run. Server-side errors are shown until the next client-side validation run (validate(), a schema-backedvalidateField()— including a validating blur/change — orreset()) supersedes them; a new server response takes precedence again. submit()resolves once validation and theonSubmitcallback finish; the server action itself runs in a transition. UseonSuccess/onError(or the returnedresponse/error) to react to the action result.