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:
// 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
// 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:
// 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:
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
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
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
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)
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
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:
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:
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:
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:
{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:
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:
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:
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
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():
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:
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
| Schema | Fields | Used In |
|---|---|---|
loginSchema | email, password | Login page |
registerSchema | name, email, password, password_confirmation | Registration page |
forgotPasswordSchema | Forgot password page | |
resetPasswordSchema | email, token, password, password_confirmation | Reset password page |
otpSendSchema | mobile | OTP login (send code) |
otpVerifySchema | mobile, code (6 digits) | OTP login (verify code) |
Profile Schemas
| Schema | Fields | Used In |
|---|---|---|
profileUpdateSchema | name | Edit profile page |
passwordChangeSchema | current_password, password, password_confirmation | Change password page |
emailUpdateSchema | email, password | Update email |
mobileUpdateSchema | mobile | Update mobile number |
Admin Schemas
| Schema | Fields | Used In |
|---|---|---|
userCreateSchema | name, email, password, password_confirmation, role_ids, is_active | Create user |
userUpdateSchema | name, email, role_ids, is_active | Edit user |
roleSchema | name, display_name, description, permission_ids | Create/edit role |
apiKeySchema | name, permissions, expires_at | Create API key |
settingsSchema | settings (key-value record) | Application settings |
What's Next
- Data Fetching -- Loading form data and submitting with the API client
- Components -- Form-related component documentation
- Internationalization -- Translatable form fields