form-validation

Form Validation with Zod + Conform

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "form-validation" with this command: npx skills add toilahuongg/shopify-agents-kit/toilahuongg-shopify-agents-kit-form-validation

Form Validation with Zod + Conform

This skill covers robust form validation for Shopify Remix apps using Zod (schema validation) and Conform (form library designed for Remix).

Why Zod + Conform?

  • Type-safe: Zod schemas generate TypeScript types automatically

  • Server-first: Validation runs on server, with optional client-side

  • Progressive enhancement: Works without JavaScript

  • Remix-native: Conform is built specifically for Remix's form handling

  • Polaris-compatible: Easy integration with Shopify Polaris form components

Installation

npm install zod @conform-to/react @conform-to/zod

  1. Basic Schema Definition

Simple Schema

// app/schemas/product.schema.ts import { z } from 'zod';

export const productSchema = z.object({ title: z .string({ required_error: 'Title is required' }) .min(1, 'Title cannot be empty') .max(255, 'Title must be 255 characters or less'),

description: z .string() .max(5000, 'Description must be 5000 characters or less') .optional(),

price: z .number({ required_error: 'Price is required' }) .positive('Price must be positive') .multipleOf(0.01, 'Price can have at most 2 decimal places'),

quantity: z .number() .int('Quantity must be a whole number') .min(0, 'Quantity cannot be negative') .default(0),

status: z.enum(['active', 'draft', 'archived'], { required_error: 'Please select a status', }),

tags: z .array(z.string()) .max(10, 'Maximum 10 tags allowed') .default([]), });

// Infer TypeScript type from schema export type ProductFormData = z.infer<typeof productSchema>;

Schema with Refinements

// app/schemas/discount.schema.ts import { z } from 'zod';

export const discountSchema = z.object({ code: z .string() .min(3, 'Code must be at least 3 characters') .max(20, 'Code must be 20 characters or less') .regex(/^[A-Z0-9]+$/, 'Code must be uppercase letters and numbers only') .transform(val => val.toUpperCase()),

type: z.enum(['percentage', 'fixed_amount']),

value: z.number().positive('Value must be positive'),

minPurchase: z.number().min(0).optional(),

startsAt: z.coerce.date(),

endsAt: z.coerce.date().optional(),

usageLimit: z.number().int().positive().optional(),

}).refine( (data) => { if (data.type === 'percentage' && data.value > 100) { return false; } return true; }, { message: 'Percentage discount cannot exceed 100%', path: ['value'], } ).refine( (data) => { if (data.endsAt && data.startsAt > data.endsAt) { return false; } return true; }, { message: 'End date must be after start date', path: ['endsAt'], } );

  1. Remix Action Integration

Basic Action with Conform

// app/routes/products.new.tsx import { json, redirect, type ActionFunctionArgs } from '@remix-run/node'; import { useActionData } from '@remix-run/react'; import { parseWithZod } from '@conform-to/zod'; import { useForm } from '@conform-to/react'; import { productSchema } from '~/schemas/product.schema';

export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData();

const submission = parseWithZod(formData, { schema: productSchema });

// Return errors if validation failed if (submission.status !== 'success') { return json(submission.reply(), { status: 400 }); }

// submission.value is fully typed as ProductFormData const product = await createProduct(submission.value);

return redirect(/products/${product.id}); }

export default function NewProductPage() { const lastResult = useActionData<typeof action>();

const [form, fields] = useForm({ lastResult, onValidate({ formData }) { return parseWithZod(formData, { schema: productSchema }); }, shouldValidate: 'onBlur', shouldRevalidate: 'onInput', });

return ( <Form method="post" id={form.id} onSubmit={form.onSubmit}> {/* Form fields here */} </Form> ); }

Action with Async Validation

// app/routes/discounts.new.tsx import { parseWithZod } from '@conform-to/zod'; import { discountSchema } from '~/schemas/discount.schema';

export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData();

const submission = await parseWithZod(formData, { schema: discountSchema.superRefine(async (data, ctx) => { // Check if discount code already exists const existingDiscount = await db.discount.findUnique({ where: { code: data.code }, });

  if (existingDiscount) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'This discount code already exists',
      path: ['code'],
    });
  }
}),
async: true,

});

if (submission.status !== 'success') { return json(submission.reply(), { status: 400 }); }

await createDiscount(submission.value); return redirect('/discounts'); }

  1. Polaris Form Components Integration

TextField with Validation

// app/components/forms/ValidatedTextField.tsx import { TextField, type TextFieldProps } from '@shopify/polaris'; import { type FieldMetadata, getInputProps } from '@conform-to/react';

interface ValidatedTextFieldProps extends Omit<TextFieldProps, 'onChange' | 'value' | 'error'> { field: FieldMetadata<string>; }

export function ValidatedTextField({ field, ...props }: ValidatedTextFieldProps) { const inputProps = getInputProps(field, { type: 'text' });

return ( <TextField {...props} name={field.name} value={field.value ?? ''} onChange={(value) => { // Trigger Conform's change handler const input = document.querySelector([name="${field.name}"]) as HTMLInputElement; if (input) { input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); } }} error={field.errors?.[0]} autoComplete={inputProps.autoComplete} /> ); }

Select with Validation

// app/components/forms/ValidatedSelect.tsx import { Select, type SelectProps } from '@shopify/polaris'; import { type FieldMetadata } from '@conform-to/react';

interface ValidatedSelectProps extends Omit<SelectProps, 'onChange' | 'value' | 'error'> { field: FieldMetadata<string>; }

export function ValidatedSelect({ field, ...props }: ValidatedSelectProps) { return ( <Select {...props} name={field.name} value={field.value ?? ''} onChange={(value) => { const select = document.querySelector([name="${field.name}"]) as HTMLSelectElement; if (select) { select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); } }} error={field.errors?.[0]} /> ); }

Complete Polaris Form Example

// app/routes/products.$id.edit.tsx import { Page, Layout, Card, FormLayout, TextField, Select, Button, Banner, } from '@shopify/polaris'; import { Form, useActionData, useNavigation } from '@remix-run/react'; import { useForm, getFormProps, getInputProps } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; import { productSchema } from '~/schemas/product.schema';

export default function EditProductPage() { const lastResult = useActionData<typeof action>(); const navigation = useNavigation(); const isSubmitting = navigation.state === 'submitting';

const [form, fields] = useForm({ lastResult, onValidate({ formData }) { return parseWithZod(formData, { schema: productSchema }); }, shouldValidate: 'onBlur', shouldRevalidate: 'onInput', });

return ( <Page title="Edit Product" primaryAction={{ content: 'Save', loading: isSubmitting, submit: true, form: form.id, }} > {form.errors && ( <Banner status="critical"> <p>Please fix the errors below</p> </Banner> )}

  &#x3C;Form method="post" {...getFormProps(form)}>
    &#x3C;Layout>
      &#x3C;Layout.Section>
        &#x3C;Card>
          &#x3C;FormLayout>
            &#x3C;TextField
              label="Title"
              {...getInputProps(fields.title, { type: 'text' })}
              value={fields.title.value ?? ''}
              onChange={(value) => {
                const input = document.querySelector(
                  `[name="${fields.title.name}"]`
                ) as HTMLInputElement;
                if (input) {
                  input.value = value;
                  input.dispatchEvent(new Event('input', { bubbles: true }));
                }
              }}
              error={fields.title.errors?.[0]}
              autoComplete="off"
            />

            &#x3C;TextField
              label="Description"
              multiline={4}
              name={fields.description.name}
              value={fields.description.value ?? ''}
              onChange={(value) => {
                const input = document.querySelector(
                  `[name="${fields.description.name}"]`
                ) as HTMLTextAreaElement;
                if (input) {
                  input.value = value;
                  input.dispatchEvent(new Event('input', { bubbles: true }));
                }
              }}
              error={fields.description.errors?.[0]}
              autoComplete="off"
            />

            &#x3C;TextField
              label="Price"
              type="number"
              prefix="$"
              name={fields.price.name}
              value={fields.price.value ?? ''}
              onChange={(value) => {
                const input = document.querySelector(
                  `[name="${fields.price.name}"]`
                ) as HTMLInputElement;
                if (input) {
                  input.value = value;
                  input.dispatchEvent(new Event('input', { bubbles: true }));
                }
              }}
              error={fields.price.errors?.[0]}
              autoComplete="off"
            />

            &#x3C;Select
              label="Status"
              name={fields.status.name}
              value={fields.status.value ?? ''}
              onChange={(value) => {
                const select = document.querySelector(
                  `[name="${fields.status.name}"]`
                ) as HTMLSelectElement;
                if (select) {
                  select.value = value;
                  select.dispatchEvent(new Event('change', { bubbles: true }));
                }
              }}
              options={[
                { label: 'Active', value: 'active' },
                { label: 'Draft', value: 'draft' },
                { label: 'Archived', value: 'archived' },
              ]}
              error={fields.status.errors?.[0]}
            />
          &#x3C;/FormLayout>
        &#x3C;/Card>
      &#x3C;/Layout.Section>
    &#x3C;/Layout>
  &#x3C;/Form>
&#x3C;/Page>

); }

  1. Complex Form Patterns

Nested Objects

// app/schemas/shipping.schema.ts import { z } from 'zod';

const addressSchema = z.object({ street: z.string().min(1, 'Street is required'), city: z.string().min(1, 'City is required'), state: z.string().min(1, 'State is required'), zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'), country: z.string().min(1, 'Country is required'), });

export const shippingSchema = z.object({ name: z.string().min(1, 'Name is required'), phone: z.string().regex(/^+?[\d\s-()]+$/, 'Invalid phone number'), shippingAddress: addressSchema, billingAddress: addressSchema.optional(), sameAsBilling: z.boolean().default(false), });

// Usage with Conform nested fields const [form, fields] = useForm({ schema: shippingSchema }); const shippingFields = fields.shippingAddress.getFieldset();

// Access nested fields <TextField label="Street" name={shippingFields.street.name} error={shippingFields.street.errors?.[0]} />

Dynamic Arrays (Field List)

// app/schemas/variants.schema.ts import { z } from 'zod';

export const variantSchema = z.object({ sku: z.string().min(1, 'SKU is required'), price: z.number().positive(), inventory: z.number().int().min(0), options: z.record(z.string()), // { "Size": "Large", "Color": "Red" } });

export const productWithVariantsSchema = z.object({ title: z.string().min(1), variants: z.array(variantSchema).min(1, 'At least one variant is required'), });

// app/routes/products.new.tsx import { useFieldList, insert, remove } from '@conform-to/react';

export default function NewProductWithVariants() { const [form, fields] = useForm({ schema: productWithVariantsSchema, });

const variants = useFieldList(form.ref, fields.variants);

return ( <Form method="post" {...getFormProps(form)}> <TextField label="Title" name={fields.title.name} />

  {variants.map((variant, index) => {
    const variantFields = variant.getFieldset();
    return (
      &#x3C;Card key={variant.key}>
        &#x3C;FormLayout>
          &#x3C;TextField
            label="SKU"
            name={variantFields.sku.name}
            error={variantFields.sku.errors?.[0]}
          />
          &#x3C;TextField
            label="Price"
            type="number"
            name={variantFields.price.name}
            error={variantFields.price.errors?.[0]}
          />
          &#x3C;Button
            onClick={() => remove(form.ref, {
              name: fields.variants.name,
              index,
            })}
            destructive
          >
            Remove
          &#x3C;/Button>
        &#x3C;/FormLayout>
      &#x3C;/Card>
    );
  })}

  &#x3C;Button
    onClick={() => insert(form.ref, {
      name: fields.variants.name,
      defaultValue: { sku: '', price: 0, inventory: 0 },
    })}
  >
    Add Variant
  &#x3C;/Button>
&#x3C;/Form>

); }

File Upload Validation

// app/schemas/upload.schema.ts import { z } from 'zod';

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

export const uploadSchema = z.object({ image: z .instanceof(File) .refine( (file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB' ) .refine( (file) => ACCEPTED_IMAGE_TYPES.includes(file.type), 'Only .jpg, .png and .webp formats are supported' ), });

// For multiple files export const multiUploadSchema = z.object({ images: z .array(z.instanceof(File)) .min(1, 'At least one image is required') .max(5, 'Maximum 5 images allowed') .refine( (files) => files.every(file => file.size <= MAX_FILE_SIZE), 'Each file must be less than 5MB' ), });

  1. Common Shopify Schemas

Customer Schema

// app/schemas/customer.schema.ts import { z } from 'zod';

export const customerSchema = z.object({ firstName: z.string().min(1, 'First name is required'), lastName: z.string().min(1, 'Last name is required'),

email: z .string() .email('Invalid email address') .toLowerCase(),

phone: z .string() .regex(/^+?[\d\s-()]+$/, 'Invalid phone number') .optional() .or(z.literal('')),

acceptsMarketing: z.boolean().default(false),

tags: z .string() .transform(val => val.split(',').map(t => t.trim()).filter(Boolean)) .pipe(z.array(z.string())) .default(''),

note: z.string().max(5000).optional(), });

Order Note Schema

// app/schemas/order-note.schema.ts import { z } from 'zod';

export const orderNoteSchema = z.object({ orderId: z.string().startsWith('gid://shopify/Order/'), note: z.string().min(1, 'Note is required').max(5000), notifyCustomer: z.boolean().default(false), });

Metafield Schema

// app/schemas/metafield.schema.ts import { z } from 'zod';

const metafieldTypes = [ 'single_line_text_field', 'multi_line_text_field', 'number_integer', 'number_decimal', 'boolean', 'date', 'json', 'url', ] as const;

export const metafieldSchema = z.object({ namespace: z .string() .min(2, 'Namespace must be at least 2 characters') .max(20) .regex(/^[a-z_]+$/, 'Only lowercase letters and underscores'),

key: z .string() .min(2, 'Key must be at least 2 characters') .max(30) .regex(/^[a-z_]+$/, 'Only lowercase letters and underscores'),

type: z.enum(metafieldTypes),

value: z.string().min(1, 'Value is required'), }).refine( (data) => { if (data.type === 'number_integer') { return /^-?\d+$/.test(data.value); } if (data.type === 'number_decimal') { return /^-?\d+(.\d+)?$/.test(data.value); } if (data.type === 'boolean') { return ['true', 'false'].includes(data.value); } if (data.type === 'url') { try { new URL(data.value); return true; } catch { return false; } } if (data.type === 'json') { try { JSON.parse(data.value); return true; } catch { return false; } } return true; }, { message: 'Value does not match the selected type', path: ['value'], } );

  1. Error Handling Patterns

Custom Error Messages

// app/lib/validation-messages.ts export const validationMessages = { required: (field: string) => ${field} is required, minLength: (field: string, min: number) => ${field} must be at least ${min} characters, maxLength: (field: string, max: number) => ${field} must be ${max} characters or less, email: 'Please enter a valid email address', positive: (field: string) => ${field} must be a positive number, url: 'Please enter a valid URL', };

Form-Level Errors

// app/routes/checkout.tsx export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData();

const submission = parseWithZod(formData, { schema: checkoutSchema });

if (submission.status !== 'success') { return json(submission.reply(), { status: 400 }); }

try { await processCheckout(submission.value); return redirect('/thank-you'); } catch (error) { // Return form-level error return json( submission.reply({ formErrors: ['Payment processing failed. Please try again.'], }), { status: 500 } ); } }

// In component const [form, fields] = useForm({ lastResult });

{form.errors?.map((error, i) => ( <Banner key={i} status="critical">{error}</Banner> ))}

Best Practices Summary

  • Define schemas in separate files - Easier to test and reuse

  • Use z.infer<typeof schema>

  • Get TypeScript types for free

  • Validate on server first - Client validation is just UX

  • Use coerce for type conversion - z.coerce.number() handles string inputs

  • Keep refinements simple - Complex logic in action, not schema

  • Test schemas separately - Unit test validation logic

  • Use meaningful error messages - Users need to understand what's wrong

  • Progressive enhancement - Forms should work without JS

Anti-Patterns to Avoid

  • DON'T validate only on client - Always validate server-side

  • DON'T duplicate validation logic - Single source of truth in schema

  • DON'T catch errors silently - Show users what went wrong

  • DON'T over-validate - Trust the schema, don't re-check in action

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Automation

shopify-polaris-icons

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-polaris-viz

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-api

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-extensions

No summary provided by upstream source.

Repository SourceNeeds Review