Skills

form-validation

form-validation
npx @loomcraft/cli add skill form-validation
Frontmatter

Name

form-validation

Description

Zod dual validation patterns for client and server with react-hook-form and ShadCN Form. Use when building forms, implementing validation, or creating input schemas with i18n error messages.

Content
# Form Validation Patterns

## Critical Rules

- **One Zod schema, two validations** — share between client and server.
- **Schema files in `src/schemas/`** — never inline schemas in components.
- **Use ShadCN Form components** — `FormField`, `FormMessage` for consistent UX.
- **Always show inline errors** — next to the field, not in toasts.
- **Disable submit while pending** — prevent double submissions.
- **Default values required** — every field needs a `defaultValues` entry in useForm.

## Dual Validation Strategy

Every form must validate on **both client and server**:
- **Client**: instant feedback, UX quality.
- **Server**: security, data integrity — never trust client validation alone.

Use a single Zod schema shared between client and server.

## Shared Schema Definition

```ts
// src/schemas/user.schema.ts
import { z } from "zod";

export const createUserSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters").max(100),
  email: z.string().email("Invalid email address"),
  role: z.enum(["USER", "ADMIN"]).default("USER"),
  bio: z.string().max(500).optional(),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;
```

## Client-Side Validation with react-hook-form

```tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createUserSchema, type CreateUserInput } from "@/schemas/user.schema";
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

export function CreateUserForm() {
  const form = useForm<CreateUserInput>({
    resolver: zodResolver(createUserSchema),
    defaultValues: { name: "", email: "", role: "USER" },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl><Input {...field} /></FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* More fields... */}
        <Button type="submit" disabled={form.formState.isSubmitting}>
          Create
        </Button>
      </form>
    </Form>
  );
}
```

## Server-Side Validation

```ts
// src/actions/user.actions.ts
"use server";
import { createUserSchema } from "@/schemas/user.schema";

export async function createUser(input: unknown) {
  const parsed = createUserSchema.safeParse(input);
  if (!parsed.success) {
    return { success: false, error: parsed.error.errors[0].message };
  }
  // proceed with validated data...
}
```

## I18n Error Messages with Zod

For internationalized error messages, use a Zod error map:

```ts
// src/lib/zod-i18n.ts
import { z } from "zod";
import { getTranslations } from "next-intl/server";

export async function createI18nSchema() {
  const t = await getTranslations("validation");

  return {
    createUser: z.object({
      name: z.string().min(2, t("name.min", { min: 2 })),
      email: z.string().email(t("email.invalid")),
    }),
  };
}
```

Or use a custom error map:

```ts
// src/lib/zod-error-map.ts
import { type ZodErrorMap, ZodIssueCode } from "zod";

export function createZodErrorMap(t: (key: string, params?: Record<string, unknown>) => string): ZodErrorMap {
  return (issue, ctx) => {
    switch (issue.code) {
      case ZodIssueCode.too_small:
        return { message: t("too_small", { minimum: issue.minimum }) };
      case ZodIssueCode.too_big:
        return { message: t("too_big", { maximum: issue.maximum }) };
      case ZodIssueCode.invalid_string:
        if (issue.validation === "email") return { message: t("invalid_email") };
        break;
    }
    return { message: ctx.defaultError };
  };
}
```

## Multi-Card Form Layout

For complex forms, split into logical sections using Cards:

```tsx
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
  <Card>
    <CardHeader>
      <CardTitle>Personal Information</CardTitle>
    </CardHeader>
    <CardContent className="grid gap-4 md:grid-cols-2">
      {/* Name, Email fields */}
    </CardContent>
  </Card>

  <Card>
    <CardHeader>
      <CardTitle>Preferences</CardTitle>
    </CardHeader>
    <CardContent className="space-y-4">
      {/* Role, Bio fields */}
    </CardContent>
  </Card>

  <div className="flex justify-end">
    <Button type="submit">Create User</Button>
  </div>
</form>
```

## Do

- Define one Zod schema per form in `src/schemas/` and share it between client and server.
- Always provide `defaultValues` for every field in `useForm` -- react-hook-form requires it for controlled behavior.
- Use ShadCN `FormField` + `FormMessage` so validation errors render inline next to each field.
- Disable the submit button while `isSubmitting` is true to prevent double submissions.
- Use `zodResolver` from `@hookform/resolvers/zod` for client-side validation.
- Re-validate on the server with `schema.safeParse()` -- never trust client-only validation.
- Split complex forms into logical Card sections for readability.
- Use `.optional()` and `.default()` in Zod to express optional fields explicitly.

## Don't

- Don't inline Zod schemas inside components -- keep them in `src/schemas/{entity}.schema.ts`.
- Don't show validation errors only in toasts -- always show them inline next to the relevant field.
- Don't skip server-side validation because the client already validated -- the client can be bypassed.
- Don't forget `defaultValues` -- missing defaults cause react-hook-form to treat fields as uncontrolled.
- Don't use `onChange` mode for validation unless the form specifically needs real-time feedback -- `onSubmit` (default) is less noisy.
- Don't duplicate Zod schemas for client and server -- use a single shared schema file.
- Don't use native HTML validation attributes (`required`, `pattern`) alongside Zod -- let Zod be the single source of truth.
- Don't leave the submit button enabled during pending requests -- users will double-submit.

## Anti-Patterns

| Anti-Pattern | Problem | Fix |
|---|---|---|
| **Duplicated schemas** | Separate Zod schemas for client and server drift apart, causing inconsistent validation. | Define one schema in `src/schemas/` and import it in both places. |
| **Toast-only errors** | Validation errors shown only as toasts disappear quickly and don't indicate which field failed. | Use `FormMessage` to render errors inline next to each field. |
| **Missing defaultValues** | Omitting `defaultValues` in `useForm` causes uncontrolled-to-controlled warnings and broken resets. | Provide a `defaultValues` entry for every field, even if it's an empty string. |
| **Client-only validation** | Validating only on the client; a crafted request bypasses the form and submits invalid data. | Always call `schema.safeParse(input)` on the server inside the action. |
| **Inline schema definition** | Schema defined inside the component file, making it impossible to share with the server. | Move the schema to `src/schemas/{entity}.schema.ts` and import it. |
| **No pending state** | Submit button stays enabled during the request, causing duplicate submissions and race conditions. | Check `form.formState.isSubmitting` or `isPending` from `useTransition` and disable the button. |
Files

No additional files