Claude Code for Conform: Type-Safe Forms for Remix and Next.js — Claude Skills 360 Blog
Blog / Frontend / Claude Code for Conform: Type-Safe Forms for Remix and Next.js
Frontend

Claude Code for Conform: Type-Safe Forms for Remix and Next.js

Published: April 11, 2027
Read time: 7 min read
By: Claude Skills 360

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.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free