Skip to content

Forms & Validation

FORGE uses React Hook Form for form state management and Zod for schema-based validation. This combination provides type-safe forms where the TypeScript types are inferred directly from the validation schema -- eliminating any drift between validation rules and type definitions.

Pattern Overview

Every form in the generated applications follows a four-step pattern:

┌─────────────────────┐
│ 1. Define Zod       │    Schema defines shape, types,
│    Schema            │    and validation rules
├─────────────────────┤
│ 2. Infer TypeScript  │    z.infer<typeof schema>
│    Type              │    generates the form data type
├─────────────────────┤
│ 3. Create Form with  │    useForm + zodResolver
│    useForm           │    connects schema to form
├─────────────────────┤
│ 4. Render with       │    shadcn/ui form components
│    Form Components   │    handle display and errors
└─────────────────────┘

Step 1: Define the Zod Schema

All validation schemas are centralized in lib/validations.ts. Here is an example for creating a user:

typescript
// lib/validations.ts
import { z } from "zod";

export const userCreateSchema = z
  .object({
    name: z
      .string()
      .min(1, "Name is required")
      .min(2, "Name must be at least 2 characters"),
    email: z
      .string()
      .min(1, "Email is required")
      .email("Please enter a valid email address"),
    password: z
      .string()
      .min(1, "Password is required")
      .min(8, "Password must be at least 8 characters")
      .regex(
        /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
        "Password must contain at least one uppercase letter, one lowercase letter, and one number"
      ),
    password_confirmation: z
      .string()
      .min(1, "Please confirm password"),
    role_ids: z
      .array(z.string())
      .min(1, "At least one role is required"),
    is_active: z.boolean().default(true),
  })
  .refine((data) => data.password === data.password_confirmation, {
    message: "Passwords do not match",
    path: ["password_confirmation"],
  });

export type UserCreateFormData = z.infer<typeof userCreateSchema>;

TIP

The .refine() method adds cross-field validation. In this example, it ensures password_confirmation matches password. The path option controls which field displays the error message.

Common Schema Patterns

typescript
// Optional field with default
bio: z.string().optional().default("")

// Enum field
status: z.enum(["draft", "published", "archived"])

// Nullable field
parent_id: z.string().uuid().nullable()

// URL field (allow empty string)
url: z.string().url("Invalid URL").or(z.literal(""))

// Number with constraints (coerce from string input)
sort_order: z.coerce.number().int().min(0).default(0)

// Conditional validation
const passwordSchema = z.object({
  current_password: z.string().min(1),
  new_password: z.string().min(8),
  new_password_confirmation: z.string().min(1),
}).refine((data) => data.new_password === data.new_password_confirmation, {
  message: "Passwords do not match",
  path: ["new_password_confirmation"],
});

Step 2: Infer the TypeScript Type

The z.infer utility extracts a TypeScript type from the schema. This type is used as the generic parameter for useForm, ensuring type safety throughout the form:

typescript
// Inferred type from the schema above:
type UserCreateFormData = {
  name: string;
  email: string;
  password: string;
  password_confirmation: string;
  role_ids: string[];
  is_active: boolean;
};

Step 3: Connect Schema to Form

Use useForm from React Hook Form with zodResolver to wire the schema into form validation:

typescript
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { userCreateSchema, type UserCreateFormData } from "@/lib/validations";

const form = useForm<UserCreateFormData>({
  resolver: zodResolver(userCreateSchema),
  defaultValues: {
    name: "",
    email: "",
    password: "",
    password_confirmation: "",
    role_ids: [],
    is_active: true,
  },
});

Step 4: Build the Form UI

Text Input

tsx
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

<div className="space-y-2">
  <Label htmlFor="name">Name</Label>
  <Input
    id="name"
    placeholder="Full name"
    {...form.register("name")}
  />
  {form.formState.errors.name && (
    <p className="text-sm text-destructive">
      {form.formState.errors.name.message}
    </p>
  )}
</div>

Select Dropdown

tsx
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Controller } from "react-hook-form";

<Controller
  control={form.control}
  name="status"
  render={({ field }) => (
    <div className="space-y-2">
      <Label>Status</Label>
      <Select value={field.value} onValueChange={field.onChange}>
        <SelectTrigger>
          <SelectValue placeholder="Select status" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="draft">Draft</SelectItem>
          <SelectItem value="published">Published</SelectItem>
          <SelectItem value="archived">Archived</SelectItem>
        </SelectContent>
      </Select>
      {form.formState.errors.status && (
        <p className="text-sm text-destructive">
          {form.formState.errors.status.message}
        </p>
      )}
    </div>
  )}
/>

Textarea

tsx
import { Textarea } from "@/components/ui/textarea";

<div className="space-y-2">
  <Label htmlFor="description">Description</Label>
  <Textarea
    id="description"
    rows={4}
    placeholder="Enter description"
    {...form.register("description")}
  />
  {form.formState.errors.description && (
    <p className="text-sm text-destructive">
      {form.formState.errors.description.message}
    </p>
  )}
</div>

Switch (Boolean Toggle)

tsx
import { Switch } from "@/components/ui/switch";

<div className="flex items-center space-x-2">
  <Switch
    id="is_active"
    checked={form.watch("is_active")}
    onCheckedChange={(checked) => form.setValue("is_active", checked)}
  />
  <Label htmlFor="is_active">Active</Label>
</div>

Checkbox Group

tsx
import { Checkbox } from "@/components/ui/checkbox";
import { Controller } from "react-hook-form";

<Controller
  control={form.control}
  name="role_ids"
  render={({ field }) => (
    <div className="space-y-2">
      <Label>Roles</Label>
      {roles.map((role) => (
        <div key={role.id} className="flex items-center space-x-2">
          <Checkbox
            checked={field.value.includes(role.id)}
            onCheckedChange={(checked) => {
              const updated = checked
                ? [...field.value, role.id]
                : field.value.filter((id) => id !== role.id);
              field.onChange(updated);
            }}
          />
          <Label>{role.display_name}</Label>
        </div>
      ))}
      {form.formState.errors.role_ids && (
        <p className="text-sm text-destructive">
          {form.formState.errors.role_ids.message}
        </p>
      )}
    </div>
  )}
/>

File Upload in Forms

Files are uploaded to the media API endpoint and the returned URL is stored in the form:

tsx
const [preview, setPreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);

const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (!file) return;

  // Show preview immediately
  setPreview(URL.createObjectURL(file));

  // Upload to API
  setIsUploading(true);
  try {
    const response = await api.upload<{
      data: { url: string; id: string };
    }>("/media", file, "file");

    form.setValue("featured_image_url", response.data.url);
    toast.success("Image uploaded");
  } catch (error) {
    toast.error(getErrorMessage(error));
    setPreview(null);
  } finally {
    setIsUploading(false);
  }
};

// In JSX
<div className="space-y-2">
  <Label>Featured Image</Label>
  {preview && (
    <img src={preview} alt="Preview" className="h-40 w-full rounded-md object-cover" />
  )}
  <Input type="file" accept="image/*" onChange={handleFileChange} disabled={isUploading} />
  {isUploading && <p className="text-sm text-muted-foreground">Uploading...</p>}
</div>

Multi-Language Form Fields

Content and other translatable resources use a tabbed interface for multi-language input. Each language tab contains the same set of fields, and the form manages a translations object keyed by language code:

tsx
import { TranslationsInput } from "@/components/ui/translations-input";

// The translations object shape:
// {
//   "en": { display_name: "Admin", description: "Full access" },
//   "ar": { display_name: "مشرف", description: "وصول كامل" }
// }

<TranslationsInput
  value={formData.translations || {}}
  onChange={(translations) =>
    setFormData({ ...formData, translations })
  }
  fields={["display_name", "description"]}
/>

For content pages with rich text fields, use ContentTranslationsInput which provides per-language tabs for title, summary, details (rich text editor), button text, and SEO fields:

tsx
import { ContentTranslationsInput } from "@/components/ui/content-translations-input";

<ContentTranslationsInput
  value={formData.translations || {}}
  onChange={(translations) =>
    setFormData({ ...formData, translations })
  }
/>

WARNING

Translation fields are stored as JSONB in the database. When submitting a form with translations, ensure you include all languages -- not just the ones the user edited. Missing language keys will clear existing translations for that language.

Error Display

Field-Level Errors

Every field displays its error message directly below the input:

tsx
{errors.fieldName && (
  <p className="text-sm text-destructive">
    {errors.fieldName.message}
  </p>
)}

Server-Side Validation Errors

For API errors (returned as errors in the response), set them on specific form fields:

typescript
try {
  await api.post("/admin/users", data);
} catch (error) {
  if (isApiError(error) && error.errors) {
    // Set server validation errors on form fields
    Object.entries(error.errors).forEach(([field, messages]) => {
      form.setError(field as keyof UserCreateFormData, {
        message: messages[0],
      });
    });
  } else {
    toast.error(getErrorMessage(error));
  }
}

The getErrorMessage utility extracts a readable message from the API error response:

typescript
export function getErrorMessage(error: unknown): string {
  if (error instanceof AxiosError && error.response?.data?.message) {
    return error.response.data.message;
  }
  if (error instanceof Error) {
    return error.message;
  }
  return "An unexpected error occurred";
}

Submit Handling with Loading States

Every form tracks submission state to disable the submit button and show feedback:

tsx
const [isSubmitting, setIsSubmitting] = React.useState(false);

const onSubmit = async (data: FormData) => {
  setIsSubmitting(true);
  try {
    await api.post("/admin/users", data);
    toast.success("User created successfully");
    router.push("/users");
  } catch (error) {
    toast.error(getErrorMessage(error));
  } finally {
    setIsSubmitting(false);
  }
};

<Button type="submit" disabled={isSubmitting}>
  {isSubmitting ? (
    <>
      <Loader2 className="me-2 h-4 w-4 animate-spin" />
      Saving...
    </>
  ) : (
    "Save"
  )}
</Button>

Form Patterns

Create Form

tsx
export default function CreateUserPage() {
  const { t } = useTranslation();
  const router = useRouter();
  const [isSubmitting, setIsSubmitting] = useState(false);

  const form = useForm<UserCreateFormData>({
    resolver: zodResolver(userCreateSchema),
    defaultValues: {
      name: "", email: "", password: "",
      password_confirmation: "", role_ids: [], is_active: true,
    },
  });

  const onSubmit = async (data: UserCreateFormData) => {
    setIsSubmitting(true);
    try {
      await api.post("/admin/users", data);
      toast.success(t("admin.users.User_created_successfully"));
      router.push("/users");
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t("admin.users.Add_User")}</CardTitle>
      </CardHeader>
      <CardContent>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          {/* Fields here */}
          <Button type="submit" disabled={isSubmitting}>
            {t("common.Create")}
          </Button>
        </form>
      </CardContent>
    </Card>
  );
}

Edit Form

Edit forms pre-populate defaultValues from fetched data using form.reset():

tsx
export default function EditUserPage({ params }: { params: { id: string } }) {
  const { t } = useTranslation();
  const router = useRouter();
  const [isLoading, setIsLoading] = useState(true);
  const [isSubmitting, setIsSubmitting] = useState(false);

  const form = useForm<UserUpdateFormData>({
    resolver: zodResolver(userUpdateSchema),
  });

  // Fetch existing data and populate form
  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await api.get<{ data: User }>(
          `/admin/users/${params.id}`
        );
        const user = response.data;
        form.reset({
          name: user.name,
          email: user.email,
          is_active: user.is_active,
          role_ids: user.roles.map((r) => r.id),
        });
      } catch (error) {
        toast.error(getErrorMessage(error));
      } finally {
        setIsLoading(false);
      }
    };
    fetchUser();
  }, [params.id, form]);

  const onSubmit = async (data: UserUpdateFormData) => {
    setIsSubmitting(true);
    try {
      await api.put(`/admin/users/${params.id}`, data);
      toast.success(t("admin.users.User_updated_successfully"));
      router.push("/users");
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsSubmitting(false);
    }
  };

  if (isLoading) return <Skeleton className="h-96 w-full" />;

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
      {/* Same fields as create, minus password */}
    </form>
  );
}

Settings Form

Settings forms use a grouped layout where each group is a separate card:

tsx
export default function SettingsPage() {
  const [settings, setSettings] = useState<SettingsGroup[]>([]);

  const handleSave = async (group: string, values: Record<string, string>) => {
    try {
      await api.put(`/admin/settings/${group}`, { settings: values });
      toast.success("Settings saved");
    } catch (error) {
      toast.error(getErrorMessage(error));
    }
  };

  return (
    <div className="space-y-8">
      {settings.map((group) => (
        <Card key={group.key}>
          <CardHeader>
            <CardTitle>{group.label}</CardTitle>
          </CardHeader>
          <CardContent>
            <SettingsForm
              settings={group.settings}
              onSave={(values) => handleSave(group.key, values)}
            />
          </CardContent>
        </Card>
      ))}
    </div>
  );
}

Available Schemas

FORGE generates validation schemas for every form in the application:

Authentication Schemas

SchemaFieldsUsed In
loginSchemaemail, passwordLogin page
registerSchemaname, email, password, password_confirmationRegistration page
forgotPasswordSchemaemailForgot password page
resetPasswordSchemaemail, token, password, password_confirmationReset password page
otpSendSchemamobileOTP login (send code)
otpVerifySchemamobile, code (6 digits)OTP login (verify code)

Profile Schemas

SchemaFieldsUsed In
profileUpdateSchemanameEdit profile page
passwordChangeSchemacurrent_password, password, password_confirmationChange password page
emailUpdateSchemaemail, passwordUpdate email
mobileUpdateSchemamobileUpdate mobile number

Admin Schemas

SchemaFieldsUsed In
userCreateSchemaname, email, password, password_confirmation, role_ids, is_activeCreate user
userUpdateSchemaname, email, role_ids, is_activeEdit user
roleSchemaname, display_name, description, permission_idsCreate/edit role
apiKeySchemaname, permissions, expires_atCreate API key
settingsSchemasettings (key-value record)Application settings

What's Next

Released under the MIT License.