These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Zod?
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.
Why Integrate Zod with Strapi
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:
- Single source of truth for types and validation: Define your schema once and automatically generate TypeScript types with
z.infer. No more maintaining separate interface definitions that drift from your actual validation logic. - DRY principle across client and server: The same Zod schemas work across React components, Next.js server actions, and Strapi v5 custom controllers. One validation library for your entire stack. Note that Strapi v5 uses a flattened response structure where attributes are directly accessible at the first level inside the
dataobject, requiring schemas that account for thedocumentId(string) identifier instead of numericid. - Integration with Strapi v5 architecture: Zod's
parseandsafeParsemethods can be applied within Strapi's custom services and controllers to validate request data before processing. - Precise error handling: Zod's
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. - Minimal boilerplate for complex validation: Nested objects, arrays, unions, and custom refinements require significantly less code than alternatives. A discriminated union for Dynamic Zones takes a few lines.
How to Integrate Zod with Strapi
Prerequisites
Before starting, ensure you have:
- Node.js 18 or later installed.
- Strapi v5 project running (create one with
npx create-strapi@latest my-project). - TypeScript configured in your frontend project.
- Basic familiarity with Strapi's Content-Type Builder and REST API.
Step 1: Install Zod
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.
Step 2: Understand Strapi v5 Response Structure
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.
Step 3: Create Base Zod Schemas for Strapi Content Types
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.
Step 4: Create Response Wrapper Schemas
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);Step 5: Implement Type-Safe API Fetching
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;
}
}Step 6: Handle Nested Relations
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)
);Step 7: Validate Media Fields
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(),
});Step 8: Handle Dynamic Zones with Discriminated Unions
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.
Step 9: Validate Form Submissions
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>
);
}Step 10: Add Validation to Strapi Controllers
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.
Project Example: Type-Safe Blog with Strapi and Next.js
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.
Set Up the Strapi Backend
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:
- title (Text, required): A string-based text field used for the primary heading
- slug (UID, based on title): A unique identifier field automatically generated from the title
- content (Rich Text): A rich text field supporting formatted text and embedded media
- excerpt (Text, optional): An optional summary or preview text
- cover (Media, single image): A single media file field for featured images
- author (Relation, many-to-one with User): A relation field connecting to user entities
- publishedAt (DateTime): A date and time field tracking publication timestamps
Create a few sample articles with content.
Set Up the Next.js Frontend
Create a Next.js project with TypeScript:
npx create-next-app@latest blog-frontend --typescript --tailwind --app
cd blog-frontend
npm install zodCreate the Schema Layer
Set 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>;Build the API Client
// 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;
}Create the Blog Pages
// 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>
);
}Test the Integration
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.
Strapi Open Office Hours
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.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.