This guide walks you through integrating Zod schema validation with Strapi v5, covering runtime data validation, automatic TypeScript type inference, and practical implementation patterns for building type-safe content-driven applications

Zod is a TypeScript-first schema validation library that provides runtime data validation with automatic type inference. Unlike validation libraries that bolt TypeScript support onto JavaScript APIs, Zod was designed from the ground up for TypeScript projects.
The core value proposition: define a schema once, get both validation logic and TypeScript types from that single definition. This unified approach provides compile-time type safety with automatic type inference through z.infer<typeof schema>, eliminates manual type maintenance by serving as a single source of truth, and enables runtime validation through both throw-based .parse() and graceful .safeParse() methods. ‘
This delivers integrated type safety across frontend forms, API responses, and server-side validation in full-stack TypeScript applications.
import { z } from 'zod';
const ArticleSchema = z.object({
documentId: z.string(),
title: z.string().min(1).max(200),
content: z.string(),
publishedAt: z.string().datetime().nullable(),
});
// TypeScript type automatically inferred
type Article = z.infer<typeof ArticleSchema>;That Article type stays synchronized with your validation rules. Change the schema, and TypeScript immediately catches any code that doesn't match.
Zod provides two validation methods: .parse() throws a ZodError when validation fails (useful for fail-fast scenarios), while .safeParse() returns a result object for graceful error handling without try-catch blocks.
Compared to alternatives like Yup or Joi, Zod offers the smallest bundle size (~8KB), native TypeScript support and a modern API that eliminates boilerplate.
Strapi v5 provides a powerful headless CMS with comprehensive content modeling, but the data flowing from its API needs runtime validation before your frontend can safely consume it. Here's why Zod makes sense for this:
z.infer. No more maintaining separate interface definitions that drift from your actual validation logic.data object, requiring schemas that account for the documentId (string) identifier instead of numeric id.parse and safeParse methods can be applied within Strapi's custom services and controllers to validate request data before processing.safeParse() method returns structured error objects with path arrays (error.issues) that identify exact validation failures at field level. Additional formatting utilities (flattenError(), prettifyError(), treeifyError()) enable flexible error presentation for forms and API responses, making it straightforward to surface validation failures with specific field locations and messages.Before starting, ensure you have:
npx create-strapi@latest my-project).Add Zod to your frontend project:
npm install zodIf you're using React Hook Form for form validation in addition to Zod, also install the resolver package to integrate them:
npm install @hookform/resolversThis resolver enables using your Zod schemas directly with React Hook Form's validation system, as demonstrated in the official Strapi integration patterns.
Strapi v5 introduces a flattened response format that differs from v4. Attributes now sit directly inside the data object rather than nested under an attributes key. The unique identifier changed from numeric id to string-based documentId.
A typical v5 response looks like this:
{
"data": {
"id": 1,
"documentId": "abc123xyz",
"title": "Getting Started with Strapi v5 and TypeScript",
"content": "Strapi v5 is a headless CMS built on Node.js that provides comprehensive TypeScript support through experimental auto-generation capabilities.",
"publishedAt": "2024-01-15T10:30:00.000Z",
"createdAt": "2024-01-14T08:00:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
},
"meta": {}
}This flattened structure in Strapi v5 requires different Zod schemas than the nested patterns found in Strapi v4 tutorials, where attributes were wrapped in an attributes object.
Start by mapping your Strapi v5 content types to Zod schemas. In Strapi v5, the response structure is flattened with attributes at the first level and uses documentId (string) instead of numeric id as the unique identifier. These are breaking changes from v4 that require updated schema definitions. Create a dedicated directory for your schemas:
src/
├── lib/
│ └── schemas/
│ ├── article.ts
│ ├── author.ts
│ ├── media.ts
│ └── index.tsDefine a schema matching your Article content type:
// src/lib/schemas/article.ts
import { z } from 'zod';
export const ArticleSchema = z.object({
id: z.number(),
documentId: z.string(),
title: z.string(),
slug: z.string(),
content: z.string(),
excerpt: z.string().optional(),
publishedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type Article = z.infer<typeof ArticleSchema>;Notice publishedAt uses .nullable() because unpublished content in Strapi v5 returns null as the value, while excerpt uses .optional() because the field might not be included in the flattened response structure at all.
Strapi wraps data in a consistent structure. Create reusable schema factories:
// src/lib/schemas/strapi.ts
import { z } from 'zod';
// Pagination metadata from Strapi REST API
const PaginationSchema = z.object({
page: z.number(),
pageSize: z.number(),
pageCount: z.number(),
total: z.number(),
});
// Factory for single entry responses
export function createSingleResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
data: dataSchema,
meta: z.object({}).optional(),
});
}
// Factory for collection responses with pagination
export function createCollectionResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
data: z.array(dataSchema),
meta: z.object({
pagination: PaginationSchema,
}),
});
}
export type Pagination = z.infer<typeof PaginationSchema>;Use these factories with your content type schemas:
import { ArticleSchema } from './article';
import { createSingleResponseSchema, createCollectionResponseSchema } from './strapi';
export const ArticleResponseSchema = createSingleResponseSchema(ArticleSchema);
export const ArticlesResponseSchema = createCollectionResponseSchema(ArticleSchema);Create an API client that validates responses at the boundary:
// src/lib/strapi/api.ts
import { z } from 'zod';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export async function fetchStrapi<T>(
endpoint: string,
schema: z.ZodType<T>,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${STRAPI_URL}/api${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`Strapi API error: ${response.status} ${response.statusText}`);
}
const json = await response.json();
return schema.parse(json);
}For production code, use safeParse for controlled error handling:
export async function fetchStrapiSafe<T>(
endpoint: string,
schema: z.ZodType<T>
): Promise<{ success: true; data: T } | { success: false; error: z.ZodError }> {
try {
const response = await fetch(`${STRAPI_URL}/api${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
const result = schema.safeParse(json);
if (!result.success) {
console.error('Validation errors:', result.error.flatten());
return { success: false, error: result.error };
}
return { success: true, data: result.data };
} catch (error) {
if (error instanceof Error) {
console.error('Fetch error:', error.message);
}
throw error;
}
}When using Strapi's populate parameter, relations return nested objects in the response. In Strapi v5, attributes are directly accessible at the first level inside the data object. Define separate schemas for related content:
// src/lib/schemas/author.ts
import { z } from 'zod';
export const AuthorSchema = z.object({
id: z.number(),
documentId: z.string(),
name: z.string(),
email: z.string().email(),
bio: z.string().optional(),
});
export type Author = z.infer<typeof AuthorSchema>;Extend your Article schema to include the populated relation:
// src/lib/schemas/article.ts
import { z } from 'zod';
import { AuthorSchema } from './author';
export const ArticleWithAuthorSchema = z.object({
documentId: z.string(),
title: z.string(),
slug: z.string(),
content: z.string(),
publishedAt: z.string().datetime().nullable(),
author: AuthorSchema.nullable(), // Relation can be null if not set
});
export type ArticleWithAuthor = z.infer<typeof ArticleWithAuthorSchema>;Fetching with Population:
According to Strapi's REST API populate documentation, fetching related content requires using the populate query parameter to include nested relations in the API response:
const article = await fetchStrapi(
`/articles/${documentId}?populate=author`,
createSingleResponseSchema(ArticleWithAuthorSchema)
);Strapi's Media Library returns structured metadata for uploaded files:
// src/lib/schemas/media.ts
import { z } from 'zod';
export const MediaSchema = z.object({
id: z.number(),
documentId: z.string(),
name: z.string(),
url: z.string().url(),
alternativeText: z.string().nullable(),
caption: z.string().nullable(),
width: z.number().optional(),
height: z.number().optional(),
mime: z.string(),
size: z.number(),
formats: z.record(z.any()).optional(),
});
export type Media = z.infer<typeof MediaSchema>;For content types with image fields:
export const ArticleWithCoverSchema = z.object({
documentId: z.string(),
title: z.string(),
content: z.string(),
cover: MediaSchema.nullable(),
});Dynamic Zones allow content editors to compose pages from multiple component types. Zod's discriminated unions handle this pattern elegantly using the __component field as a discriminator, enabling type-safe validation of mixed component arrays where each component type has its own distinct schema structure.
// src/lib/schemas/blocks.ts
import { z } from 'zod';
import { MediaSchema } from './media';
const RichTextBlock = z.object({
__component: z.literal('blocks.rich-text'),
id: z.number(),
content: z.string(),
});
const ImageBlock = z.object({
__component: z.literal('blocks.image'),
id: z.number(),
media: MediaSchema,
caption: z.string().optional(),
});
const QuoteBlock = z.object({
__component: z.literal('blocks.quote'),
id: z.number(),
text: z.string(),
author: z.string(),
});
// Discriminated union based on __component field
export const ContentBlockSchema = z.discriminatedUnion('__component', [
RichTextBlock,
ImageBlock,
QuoteBlock,
]);
export type ContentBlock = z.infer<typeof ContentBlockSchema>;Use in a page content type:
export const PageSchema = z.object({
documentId: z.string(),
title: z.string(),
slug: z.string(),
blocks: z.array(ContentBlockSchema),
});Discriminated unions give you proper type narrowing. TypeScript knows which properties exist based on the __component value. This pattern is particularly powerful with Strapi's dynamic zones, providing both better performance and more precise error messages by allowing TypeScript to automatically narrow the union type based on the discriminator value.
For forms that submit data to Strapi, create input schemas (distinct from response schemas):
// src/lib/schemas/contact.ts
import { z } from 'zod';
export const ContactFormSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export type ContactFormData = z.infer<typeof ContactFormSchema>;Integrate with React Hook Form:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { ContactFormSchema, ContactFormData } from '@/lib/schemas/contact';
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(ContactFormSchema),
});
const onSubmit = async (data: ContactFormData) => {
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
if (!response.ok) throw new Error('Submission failed');
alert('Form submitted successfully!');
} catch (error) {
console.error('Submission error:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register('message')} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit" disabled={isSubmitting}>
Send Message
</button>
</form>
);
}You can use Zod schemas within Strapi custom controllers to validate incoming request data using Zod's parse or safeParse methods before further processing:
// src/api/contact/controllers/contact.ts
import { factories } from '@strapi/strapi';
import { z } from 'zod';
const ContactSubmissionSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Must be a valid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export default factories.createCoreController('api::contact.contact', ({ strapi }) => ({
async create(ctx) {
const result = ContactSubmissionSchema.safeParse(ctx.request.body.data);
if (!result.success) {
return ctx.badRequest('Validation failed', {
errors: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
});
}
const entry = await strapi.service('api::contact.contact').create({
data: result.data,
});
return entry;
},
}));This pattern ensures validation rules stay consistent between frontend and backend.
Let's build a blog that demonstrates end-to-end Zod validation with Strapi v5 and Next.js. This example shows the complete flow from Strapi content types to validated, type-safe React components.
Create a new Strapi project if you haven't already:
npx create-strapi@latest blog-backend --quickstartIn the Admin Panel, create an Article content type with these fields:
Create a few sample articles with content.
Create a Next.js project with TypeScript:
npx create-next-app@latest blog-frontend --typescript --tailwind --app
cd blog-frontend
npm install zodSet up your schemas directory:
// src/lib/schemas/media.ts
import { z } from 'zod';
export const MediaSchema = z.object({
id: z.number(),
documentId: z.string(),
name: z.string(),
url: z.string().url(),
alternativeText: z.string().nullable(),
mime: z.string(),
size: z.number(),
width: z.number().optional(),
height: z.number().optional(),
});
export type Media = z.infer<typeof MediaSchema>;// src/lib/schemas/author.ts
import { z } from 'zod';
export const AuthorSchema = z.object({
documentId: z.string(),
username: z.string(),
email: z.string().email(),
});
export type Author = z.infer<typeof AuthorSchema>;// src/lib/schemas/article.ts
import { z } from 'zod';
import { MediaSchema } from './media';
import { AuthorSchema } from './author';
export const ArticleSchema = z.object({
id: z.number(),
documentId: z.string(),
title: z.string(),
slug: z.string(),
content: z.string(),
excerpt: z.string().nullable(),
publishedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
cover: MediaSchema.nullable(),
author: AuthorSchema.nullable(),
});
export type Article = z.infer<typeof ArticleSchema>;
// List view without relations for performance
export const ArticleListItemSchema = z.object({
id: z.number(),
documentId: z.string(),
title: z.string(),
slug: z.string(),
excerpt: z.string().nullable(),
publishedAt: z.string().datetime().nullable(),
});
export type ArticleListItem = z.infer<typeof ArticleListItemSchema>;// src/lib/schemas/strapi.ts
import { z } from 'zod';
const PaginationSchema = z.object({
page: z.number(),
pageSize: z.number(),
pageCount: z.number(),
total: z.number(),
});
export function createSingleResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
data: dataSchema,
meta: z.object({}).optional(),
});
}
export function createCollectionResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
data: z.array(dataSchema),
meta: z.object({
pagination: PaginationSchema,
}),
});
}
export type Pagination = z.infer<typeof PaginationSchema>;// src/lib/strapi/client.ts
import { z } from 'zod';
import {
ArticleSchema,
ArticleListItemSchema,
type Article,
type ArticleListItem,
} from '../schemas/article';
import {
createSingleResponseSchema,
createCollectionResponseSchema,
type Pagination,
} from '../schemas/strapi';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
async function fetchFromStrapi<T>(
endpoint: string,
schema: z.ZodType<T>
): Promise<T> {
const url = `${STRAPI_URL}/api${endpoint}`;
const response = await fetch(url, {
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error(`Failed to fetch ${endpoint}: ${response.statusText}`);
}
const json = await response.json();
const result = schema.safeParse(json);
if (!result.success) {
console.error('Validation failed for', endpoint);
console.error(result.error.issues);
throw new Error(`Invalid data structure from Strapi: ${endpoint}`);
}
return result.data;
}
export async function getArticles(
page = 1,
pageSize = 10
): Promise<{ articles: ArticleListItem[]; pagination: Pagination }> {
const schema = createCollectionResponseSchema(ArticleListItemSchema);
const response = await fetchFromStrapi(
`/articles?pagination[page]=${page}&pagination[pageSize]=${pageSize}&sort=publishedAt:desc`,
schema
);
return {
articles: response.data,
pagination: response.meta.pagination,
};
}
export async function getArticleBySlug(slug: string): Promise<Article | null> {
const schema = createCollectionResponseSchema(ArticleSchema);
const response = await fetchFromStrapi(
`/articles?filters[slug][$eq]=${slug}&populate=cover,author`,
schema
);
return response.data[0] || null;
}// src/app/blog/page.tsx
import Link from 'next/link';
import { getArticles } from '@/lib/strapi/client';
export default async function BlogPage() {
const { articles, pagination } = await getArticles();
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Blog</h1>
<div className="space-y-6">
{articles.map((article) => (
<article key={article.documentId} className="border-b pb-6">
<Link href={`/blog/${article.slug}`}>
<h2 className="text-xl font-semibold hover:text-blue-600">
{article.title}
</h2>
</Link>
{article.excerpt && (
<p className="text-gray-600 mt-2">{article.excerpt}</p>
)}
{article.publishedAt && (
<time className="text-sm text-gray-500 mt-2 block">
{new Date(article.publishedAt).toLocaleDateString()}
</time>
)}
</article>
))}
</div>
<div className="mt-8 text-sm text-gray-500">
Showing page {pagination.page} of {pagination.pageCount}
({pagination.total} total articles)
</div>
</main>
);
}// src/app/blog/[slug]/page.tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getArticleBySlug } from '@/lib/strapi/client';
interface Props {
params: { slug: string };
}
export default async function ArticlePage({ params }: Props) {
const article = await getArticleBySlug(params.slug);
if (!article) {
notFound();
}
const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
return (
<main className="max-w-3xl mx-auto px-4 py-8">
{article.cover && (
<Image
src={`${strapiUrl}${article.cover.url}`}
alt={article.cover.alternativeText || article.title}
width={800}
height={400}
className="w-full h-64 object-cover rounded-lg mb-8"
/>
)}
<h1 className="text-4xl font-bold mb-4">{article.title}</h1>
<div className="flex items-center gap-4 text-gray-600 mb-8">
{article.author && <span>By {article.author.username}</span>}
{article.publishedAt && (
<time>{new Date(article.publishedAt).toLocaleDateString()}</time>
)}
</div>
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: article.content }}
/>
</main>
);
}Start both servers:
# Terminal 1: Strapi backend
cd blog-backend
npm run develop
# Terminal 2: Next.js frontend
cd blog-frontend
npm run devWhen building with Strapi v5 and Zod, your blog articles API endpoint returns fully typed responses. If the Strapi response structure changes unexpectedly, Zod catches it immediately with a descriptive error instead of letting undefined values cascade through your components.
By validating responses with Zod schemas that match Strapi v5's flattened structure (with documentId string identifiers), you ensure type safety throughout your application and prevent runtime errors from data structure mismatches.
This pattern scales to larger applications. You add schemas for new content types, compose them for relations, and the type safety propagates throughout your codebase.
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Zod documentation.
Combine automated tooling with testing. Use the ts-to-zod package to generate Zod schemas from TypeScript interfaces, then run integration tests against your Strapi API in CI/CD. When content types change, tests catch schema mismatches before deployment. For GraphQL, use GraphQL Code Generator with Zod plugins to automate schema generation directly from your Strapi GraphQL schema.
Zod's interpreter-based architecture adds approximately 100-500 microseconds per validation. For typical frontend use cases fetching individual entries or small collections, this overhead is negligible. Bulk operations validating hundreds of entries can accumulate latency. In performance-critical paths, validate at API boundaries only and consider memoization for repeated validations.
Use parse for development or server-side scenarios where immediate failure on invalid data is acceptable. Use safeParse for production code needing graceful error handling, as it returns a result object instead of throwing exceptions. For API routes, safeParse enables returning proper 400 responses with field-level error details.
Use Zod's discriminated unions with the __component field as the discriminator. Define each component schema with __component: z.literal('component.name'), then combine them using z.discriminatedUnion('__component', [schema1, schema2]). TypeScript automatically narrows types based on the discriminator, providing precise type inference for each component.
Yes, schemas can be shared effectively through a monorepo structure or shared npm package. The same Zod schemas used on the client can be reused on the server, adhering to the DRY principle. Create a shared types or schemas package containing your Zod definitions, then import them in both your Next.js frontend and Strapi custom controllers. This ensures validation consistency across your entire stack.