Skip to main content
strapi2front generates Zod validation schemas from your Strapi content types, providing runtime type safety and validation for forms, API inputs, and more.

What Gets Generated

For each content type, strapi2front creates Zod schemas that:

Validate Structure

Ensure data matches your Strapi schema structure

Enforce Constraints

Min/max length, number ranges, email format, etc.

Type Inference

Generate TypeScript types from schemas automatically

Custom Messages

Add custom validation error messages

Generated Schema Example

For an Article collection type:
article/schemas.ts
/**
 * Article Zod Schemas
 * Blog article with author and categories
 * Generated by strapi2front
 */

import { z } from 'zod';

/**
 * Schema for creating a new Article
 */
export const articleCreateSchema = z.object({
  title: z.string().min(1).max(255),
  slug: z.string(),
  content: z.array(
    z.discriminatedUnion('type', [
      z.object({
        type: z.literal('paragraph'),
        children: z.array(z.object({
          type: z.literal('text'),
          text: z.string(),
        })),
      }),
      z.object({
        type: z.literal('heading'),
        level: z.number().int().min(1).max(6),
        children: z.array(z.object({
          type: z.literal('text'),
          text: z.string(),
        })),
      }),
      z.object({
        type: z.literal('list'),
        format: z.enum(['ordered', 'unordered']),
        children: z.array(z.object({
          type: z.literal('list-item'),
          children: z.array(z.object({
            type: z.literal('text'),
            text: z.string(),
          })),
        })),
      }),
    ])
  ),
  excerpt: z.string().optional(),
  coverImage: z.number().int().positive().nullable(),
  status: z.enum(['draft', 'published', 'archived']),
  views: z.number().int(),
  readingTime: z.number().int().optional(),
  featured: z.boolean().default(false),
  author: z.string().nullable(),  // documentId in v5
  categories: z.array(z.string()).default([]),  // array of documentIds
});

/**
 * Schema for updating an Article
 * All fields are optional for partial updates
 */
export const articleUpdateSchema = z.object({
  title: z.string().min(1).max(255).optional(),
  slug: z.string().optional(),
  content: z.array(z.any()).optional(),
  excerpt: z.string().optional(),
  coverImage: z.number().int().positive().nullable().optional(),
  status: z.enum(['draft', 'published', 'archived']).optional(),
  views: z.number().int().optional(),
  readingTime: z.number().int().optional(),
  featured: z.boolean().optional(),
  author: z.string().nullable().optional(),
  categories: z.array(z.string()).optional(),
});

// Type inference helpers
export type ArticleCreateInput = z.infer<typeof articleCreateSchema>;
export type ArticleUpdateInput = z.infer<typeof articleUpdateSchema>;

Schema Types

Validates data for creating new entries. Required fields are enforced.
const result = articleCreateSchema.safeParse({
  title: 'New Article',
  slug: 'new-article',
  content: [],
  status: 'draft',
  views: 0,
  coverImage: null,
  author: null,
  categories: [],
});

if (result.success) {
  await articleService.create(result.data);
} else {
  console.error(result.error.errors);
}

Field Type Mappings

strapi2front maps Strapi field types to Zod schemas:
// Basic string
z.string()

// With min/max length from Strapi
z.string().min(3).max(100)

// Email
z.string().email()

// With regex pattern
z.string().regex(/^[a-z0-9-]+$/)
// Integer
z.number().int()

// Float/Decimal
z.number()

// With min/max from Strapi
z.number().int().min(0).max(100)

// Positive numbers
z.number().int().positive()
// Basic boolean
z.boolean()

// With default value
z.boolean().default(false)
// Date (YYYY-MM-DD)
z.string().date()

// Time (HH:MM:SS)
z.string().time()

// DateTime with timezone
z.string().datetime({ offset: true })
// Enum values from Strapi
z.enum(['draft', 'published', 'archived'])
// Single media (accepts file ID from upload API)
z.number().int().positive().nullable()

// Multiple media
z.array(z.number().int().positive()).default([])
Media fields accept file IDs (numbers), not file objects. Upload files first via the upload API, then use the returned IDs.
// To-one relation (Strapi v5: documentId string, v4: id number)
z.string().nullable()  // v5
z.number().int().positive().nullable()  // v4

// To-many relation
z.array(z.string()).default([])  // v5
z.array(z.number().int().positive()).default([])  // v4
// Single component
z.record(z.unknown()).nullable()

// Repeatable component
z.array(z.record(z.unknown()))
// Discriminated union of component types
z.array(
  z.discriminatedUnion('__component', [
    textBlockSchema.extend({ __component: z.literal('content.text-block') }),
    imageBlockSchema.extend({ __component: z.literal('content.image-block') }),
  ])
)

Using Schemas in Forms

React Hook Form Example

components/ArticleForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { articleCreateSchema, type ArticleCreateInput } from '@/strapi/collections/article/schemas';
import { articleService } from '@/strapi/collections/article/service';

export function ArticleForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<ArticleCreateInput>({
    resolver: zodResolver(articleCreateSchema),
  });
  
  const onSubmit = async (data: ArticleCreateInput) => {
    const article = await articleService.create(data);
    console.log('Created:', article);
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Title</label>
        <input {...register('title')} />
        {errors.title && <span>{errors.title.message}</span>}
      </div>
      
      <div>
        <label>Slug</label>
        <input {...register('slug')} />
        {errors.slug && <span>{errors.slug.message}</span>}
      </div>
      
      <div>
        <label>Status</label>
        <select {...register('status')}>
          <option value="draft">Draft</option>
          <option value="published">Published</option>
          <option value="archived">Archived</option>
        </select>
        {errors.status && <span>{errors.status.message}</span>}
      </div>
      
      <button type="submit">Create Article</button>
    </form>
  );
}

Astro Actions Example

actions/article.ts
import { defineAction } from 'astro:actions';
import { articleCreateSchema } from '@/strapi/collections/article/schemas';
import { articleService } from '@/strapi/collections/article/service';

export const createArticle = defineAction({
  accept: 'form',
  input: articleCreateSchema,
  handler: async (input) => {
    const article = await articleService.create(input);
    return { success: true, article };
  },
});

Manual Validation

import { articleCreateSchema } from '@/strapi/collections/article/schemas';

const formData = {
  title: 'My Article',
  slug: 'my-article',
  status: 'draft',
  // ...
};

// Use safeParse for error handling
const result = articleCreateSchema.safeParse(formData);

if (result.success) {
  // Data is valid and typed
  const validData = result.data;
  await articleService.create(validData);
} else {
  // Validation failed
  console.error(result.error.errors);
  // [
  //   { path: ['title'], message: 'String must contain at least 1 character(s)' },
  //   { path: ['views'], message: 'Required' }
  // ]
}

// Use parse to throw on error
try {
  const validData = articleCreateSchema.parse(formData);
  await articleService.create(validData);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error(error.errors);
  }
}

Custom Validation

Extend generated schemas with custom validation:
import { articleCreateSchema } from '@/strapi/collections/article/schemas';

// Add custom validation rules
const customArticleSchema = articleCreateSchema.extend({
  title: z.string()
    .min(1, 'Title is required')
    .max(255, 'Title too long')
    .refine(
      (title) => !title.toLowerCase().includes('spam'),
      'Title contains forbidden words'
    ),
  
  slug: z.string().regex(
    /^[a-z0-9-]+$/,
    'Slug must contain only lowercase letters, numbers, and hyphens'
  ),
}).refine(
  (data) => data.excerpt || data.content.length > 0,
  'Either excerpt or content must be provided'
);

// Use the custom schema
const result = customArticleSchema.safeParse(formData);

Advanced Relation Schemas (v5)

For Strapi v5, schemas support advanced relation operations:
// Simple format (array of documentIds)
z.array(z.string())

// Advanced format with connect/disconnect/set
z.union([
  // Shorthand: array of documentIds
  z.array(z.string()),
  
  // Longhand: object with operations
  z.object({
    /** Add relations while preserving existing ones */
    connect: z.array(
      z.union([
        z.string(),  // Just documentId
        z.object({
          documentId: z.string(),
          locale: z.string().optional(),
          status: z.enum(['draft', 'published']).optional(),
          position: z.object({
            before: z.string().optional(),
            after: z.string().optional(),
            start: z.boolean().optional(),
            end: z.boolean().optional(),
          }).optional(),
        }),
      ])
    ).optional(),
    
    /** Remove specific relations */
    disconnect: z.array(
      z.union([
        z.string(),
        z.object({
          documentId: z.string(),
          locale: z.string().optional(),
          status: z.enum(['draft', 'published']).optional(),
        }),
      ])
    ).optional(),
    
    /** Replace ALL relations */
    set: z.array(z.string()).optional(),
  }),
])
Usage example:
// Simple: just set the relations
const simpleData = {
  categories: ['cat-1', 'cat-2'],
};

// Advanced: add and remove specific items
const advancedData = {
  categories: {
    connect: [{ documentId: 'new-cat', position: { start: true } }],
    disconnect: [{ documentId: 'old-cat' }],
  },
};

// Both validate successfully
articleUpdateSchema.parse(simpleData);
articleUpdateSchema.parse(advancedData);

Error Handling

Display Validation Errors

import { z } from 'zod';
import { articleCreateSchema } from '@/strapi/collections/article/schemas';

function displayErrors(error: z.ZodError) {
  return error.errors.map(err => ({
    field: err.path.join('.'),
    message: err.message,
  }));
}

const result = articleCreateSchema.safeParse(invalidData);

if (!result.success) {
  const errors = displayErrors(result.error);
  // [
  //   { field: 'title', message: 'String must contain at least 1 character(s)' },
  //   { field: 'status', message: "Invalid enum value. Expected 'draft' | 'published' | 'archived'" }
  // ]
}

Custom Error Messages

const customSchema = articleCreateSchema.extend({
  title: z.string()
    .min(1, { message: 'Please enter a title' })
    .max(255, { message: 'Title cannot exceed 255 characters' }),
  
  status: z.enum(['draft', 'published', 'archived'], {
    errorMap: () => ({ message: 'Please select a valid status' }),
  }),
});

Real-World Example: Multi-Step Form

components/CreateArticleWizard.tsx
import { useState } from 'react';
import { articleCreateSchema } from '@/strapi/collections/article/schemas';
import type { ArticleCreateInput } from '@/strapi/collections/article/schemas';
import { articleService } from '@/strapi/collections/article/service';

// Split schema into steps
const step1Schema = articleCreateSchema.pick({
  title: true,
  slug: true,
  excerpt: true,
});

const step2Schema = articleCreateSchema.pick({
  content: true,
  coverImage: true,
});

const step3Schema = articleCreateSchema.pick({
  status: true,
  author: true,
  categories: true,
});

export function CreateArticleWizard() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState<Partial<ArticleCreateInput>>({});
  
  const validateStep = (stepNum: number, data: unknown) => {
    const schemas = [step1Schema, step2Schema, step3Schema];
    return schemas[stepNum - 1].safeParse(data);
  };
  
  const handleNext = (stepData: unknown) => {
    const result = validateStep(step, stepData);
    
    if (result.success) {
      setFormData({ ...formData, ...result.data });
      setStep(step + 1);
    } else {
      console.error(result.error.errors);
    }
  };
  
  const handleSubmit = async (finalStepData: unknown) => {
    const finalData = { ...formData, ...finalStepData };
    const result = articleCreateSchema.safeParse(finalData);
    
    if (result.success) {
      await articleService.create(result.data);
    } else {
      console.error(result.error.errors);
    }
  };
  
  return (
    <div>
      {step === 1 && <Step1Form onNext={handleNext} />}
      {step === 2 && <Step2Form onNext={handleNext} />}
      {step === 3 && <Step3Form onSubmit={handleSubmit} />}
    </div>
  );
}
Zod schemas are automatically regenerated when you run npx strapi2front sync. Keep them in sync with your Strapi schema.

Next Steps