Conform is a form validation library designed for server-first frameworks — parseWithZod(formData, { schema }) validates on the server and returns a typed result. useForm({ onValidate, constraint }) creates a client-side form manager with the same schema. getInputProps(fields.email, { type: "email" }) generates accessible input attributes including name, id, aria-invalid, aria-describedby. getFieldsetProps(fields.address) handles nested object fields. form.id syncs with Remix’s <Form> and Next.js Server Actions. <Button name="intent" value="delete"> adds named submit buttons for multi-action forms. Conform’s progressive enhancement model works without JavaScript using native form submission. Server validation errors are returned as submission replies: submission.reply({ formErrors: ["Invalid"] }). Claude Code generates Conform schemas, Remix action handlers, Next.js Server Action forms, nested fieldsets, and file upload patterns.
CLAUDE.md for Conform
## Conform Stack
- Version: @conform-to/react >= 1.1, @conform-to/zod >= 1.1
- Server: parseWithZod(await request.formData(), { schema }) → submission
- Client: const [form, fields] = useForm({ id, lastResult, onValidate: ({ formData }) => parseWithZod(formData, { schema }) })
- Input: <input {...getInputProps(fields.email, { type: "email" })} />
- Error: fields.email.errors — array of error messages
- Nested: getFieldsetProps(fields.address) + useInputControl(fields.address.fields.city)
- Intent: <button {...form.submit} name="intent" value="delete">Delete</button>
- Reply: submission.reply() — pass back to client as lastResult
Basic Form with Zod Validation
// app/routes/register.tsx — Remix route with Conform + Zod
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"
import { Form, useActionData } from "@remix-run/react"
import { parseWithZod } from "@conform-to/zod"
import { useForm, getInputProps, getTextareaProps } from "@conform-to/react"
import { z } from "zod"
const RegisterSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[0-9]/, "Must contain a number"),
confirmPassword: z.string(),
name: z.string().min(2, "Name must be at least 2 characters").max(100),
bio: z.string().max(500).optional(),
agreeToTerms: z.boolean({ required_error: "You must agree to terms" }).refine(v => v, "You must agree to terms"),
}).refine(
data => data.password === data.confirmPassword,
{ message: "Passwords do not match", path: ["confirmPassword"] },
)
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const submission = parseWithZod(formData, { schema: RegisterSchema })
// Return validation errors to client
if (submission.status !== "success") {
return json(submission.reply())
}
const { email, password, name, bio } = submission.value
// Check if email already taken
const existing = await db.user.findUnique({ where: { email } })
if (existing) {
return json(submission.reply({
fieldErrors: { email: ["This email is already registered"] },
}))
}
await db.user.create({
data: { email, password: await hashPassword(password), name, bio },
})
return redirect("/dashboard")
}
export default function RegisterPage() {
const lastResult = useActionData<typeof action>()
const [form, fields] = useForm({
id: "register-form",
lastResult, // Sync server errors to client
onValidate({ formData }) {
// Client-side validation with same schema
return parseWithZod(formData, { schema: RegisterSchema })
},
shouldValidate: "onBlur",
shouldRevalidate: "onInput",
})
return (
<div className="max-w-md mx-auto py-12">
<h1 className="text-2xl font-bold mb-6">Create account</h1>
<Form method="post" id={form.id} noValidate onSubmit={form.onSubmit}>
{/* Form-level errors */}
{form.errors && (
<div className="text-sm text-red-500 mb-4">{form.errors.join(", ")}</div>
)}
<div className="space-y-4">
<Field
label="Full name"
error={fields.name.errors}
input={<input {...getInputProps(fields.name, { type: "text" })} className="input" />}
/>
<Field
label="Email address"
error={fields.email.errors}
input={<input {...getInputProps(fields.email, { type: "email" })} className="input" />}
/>
<Field
label="Password"
error={fields.password.errors}
input={<input {...getInputProps(fields.password, { type: "password" })} className="input" />}
/>
<Field
label="Confirm password"
error={fields.confirmPassword.errors}
input={<input {...getInputProps(fields.confirmPassword, { type: "password" })} className="input" />}
/>
<Field
label="Bio (optional)"
error={fields.bio.errors}
input={<textarea {...getTextareaProps(fields.bio)} rows={3} className="input" />}
/>
<div className="flex items-start gap-2">
<input
{...getInputProps(fields.agreeToTerms, { type: "checkbox" })}
className="mt-1"
/>
<label htmlFor={fields.agreeToTerms.id} className="text-sm">
I agree to the <a href="/terms" className="underline">Terms of Service</a>
</label>
{fields.agreeToTerms.errors && (
<p id={fields.agreeToTerms.errorId} className="text-xs text-red-500">
{fields.agreeToTerms.errors[0]}
</p>
)}
</div>
</div>
<button type="submit" className="btn-primary w-full mt-6">
Create account
</button>
</Form>
</div>
)
}
function Field({
label,
error,
input,
}: {
label: string
error?: string[]
input: React.ReactNode
}) {
return (
<div className="space-y-1">
<label className="text-sm font-medium">{label}</label>
{input}
{error && <p className="text-xs text-red-500">{error[0]}</p>}
</div>
)
}
Multi-Action Form with Intent
// app/routes/posts.$id.tsx — intent-based multi-action form
import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"
import { Form, useLoaderData, useActionData } from "@remix-run/react"
import { parseWithZod } from "@conform-to/zod"
import { useForm, getInputProps, getTextareaProps } from "@conform-to/react"
import { z } from "zod"
const UpdatePostSchema = z.object({
title: z.string().min(1, "Title is required"),
content: z.string().min(10, "Content must be at least 10 characters"),
published: z.boolean().optional(),
})
const DeletePostSchema = z.object({
confirm: z.literal("delete", { errorMap: () => ({ message: 'Type "delete" to confirm' }) }),
})
export async function action({ request, params }: ActionFunctionArgs) {
const formData = await request.formData()
const intent = formData.get("intent")
if (intent === "update") {
const submission = parseWithZod(formData, { schema: UpdatePostSchema })
if (submission.status !== "success") return json(submission.reply())
await db.post.update({ where: { id: params.id }, data: submission.value })
return json(submission.reply())
}
if (intent === "delete") {
const submission = parseWithZod(formData, { schema: DeletePostSchema })
if (submission.status !== "success") return json(submission.reply())
await db.post.delete({ where: { id: params.id } })
return redirect("/posts")
}
return json({ error: "Unknown intent" })
}
export default function EditPost() {
const { post } = useLoaderData<typeof loader>()
const lastResult = useActionData<typeof action>()
const [form, fields] = useForm({
id: "edit-post",
lastResult,
defaultValue: { title: post.title, content: post.content, published: post.published },
onValidate({ formData }) {
const intent = formData.get("intent")
return parseWithZod(formData, {
schema: intent === "delete" ? DeletePostSchema : UpdatePostSchema,
})
},
})
return (
<Form method="post" id={form.id} noValidate onSubmit={form.onSubmit}>
<div className="space-y-4">
<div>
<input
{...getInputProps(fields.title, { type: "text" })}
className="input w-full text-2xl font-bold border-none p-0"
placeholder="Post title"
/>
{fields.title.errors && (
<p className="text-xs text-red-500 mt-1">{fields.title.errors[0]}</p>
)}
</div>
<textarea
{...getTextareaProps(fields.content)}
rows={15}
className="input w-full"
placeholder="Write your post..."
/>
<div className="flex items-center gap-2">
<input {...getInputProps(fields.published, { type: "checkbox" })} />
<label htmlFor={fields.published.id} className="text-sm">Published</label>
</div>
</div>
<div className="flex gap-3 mt-6">
{/* Named intent buttons — server reads formData.get("intent") */}
<button
type="submit"
name="intent"
value="update"
className="btn-primary"
>
Save changes
</button>
<button
type="submit"
name="intent"
value="delete"
className="btn-danger"
onClick={e => !confirm('Type "delete" to confirm') && e.preventDefault()}
>
Delete post
</button>
</div>
</Form>
)
}
Next.js Server Actions
// app/contact/page.tsx — Conform with Next.js Server Actions
"use client"
import { useForm, getInputProps, getTextareaProps } from "@conform-to/react"
import { parseWithZod } from "@conform-to/zod"
import { z } from "zod"
import { useFormState } from "react-dom"
import { submitContactForm } from "./actions"
const ContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
subject: z.string().min(5),
message: z.string().min(20).max(1000),
})
export default function ContactPage() {
const [lastResult, action] = useFormState(submitContactForm, undefined)
const [form, fields] = useForm({
id: "contact",
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: ContactSchema })
},
shouldValidate: "onBlur",
})
if (form.status === "success") {
return <p className="text-green-600">Message sent! We'll be in touch.</p>
}
return (
<form id={form.id} action={action} noValidate onSubmit={form.onSubmit}>
<div className="space-y-4">
<div>
<input {...getInputProps(fields.name, { type: "text" })} placeholder="Your name" className="input w-full" />
{fields.name.errors && <p className="text-xs text-red-500">{fields.name.errors[0]}</p>}
</div>
<div>
<input {...getInputProps(fields.email, { type: "email" })} placeholder="Email" className="input w-full" />
{fields.email.errors && <p className="text-xs text-red-500">{fields.email.errors[0]}</p>}
</div>
<div>
<textarea {...getTextareaProps(fields.message)} rows={5} placeholder="How can we help?" className="input w-full" />
{fields.message.errors && <p className="text-xs text-red-500">{fields.message.errors[0]}</p>}
</div>
</div>
<button type="submit" className="btn-primary mt-4">Send message</button>
</form>
)
}
// app/contact/actions.ts
import { parseWithZod } from "@conform-to/zod"
import { z } from "zod"
const ContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
subject: z.string().min(5),
message: z.string().min(20).max(1000),
})
export async function submitContactForm(_prev: unknown, formData: FormData) {
const submission = parseWithZod(formData, { schema: ContactSchema })
if (submission.status !== "success") return submission.reply()
await sendContactEmail(submission.value)
return submission.reply()
}
For the React Hook Form alternative when a flexible, hook-based form library with useForm, register, Controller, useFieldArray, a large community, and deep integration with UI libraries like Shadcn are preferred — React Hook Form separates validation from form management and supports both client-side-only and server-side patterns, see the React Hook Form guide. For the Zod-only alternative when server-side validation in a simple API route without a form library is sufficient — using safeParse directly in action handlers without a client form state manager is appropriate for simpler forms without complex UX requirements, see the Zod guide. The Claude Skills 360 bundle includes Conform skill sets covering Remix actions, nested forms, and Server Actions. Start with the free tier to try type-safe form generation.