I was building an AI content generation system with Gemini when I followed Google's official documentation on structured output, only to discover the recommended library wasn't compatible with Zod v4. After debugging incomplete JSON schemas for hours, I found out Zod v4 introduced native JSON schema conversion that makes the third-party library obsolete. Here's the exact implementation that actually works.
The Problem: Google's Docs Are Pointing You to Incompatible Code
The problem is that zod-to-json-schema v3.25.1 (the latest version) was built for Zod v3. When you use it with Zod v4, the schema conversion silently fails. Instead of returning your full schema with properties, type, and required fields, it returns an incomplete object:
This incomplete schema tells Gemini to ignore your structured output constraints entirely. The model then returns whatever field names it wants (like seo_title instead of title), completely defeating the purpose of schema enforcement.
The root cause: Zod v4 introduces native JSON schema conversion, making the external library redundant and breaking compatibility.
The Solution: Use Zod v4's Native z.toJSONSchema()
Zod v4 ships with a built-in z.toJSONSchema() method that works properly out of the box. No external dependencies needed.
Step 1: Upgrade to Zod v4
First, ensure you're on Zod v4 (currently v4.2.1 or later):
bash
pnpm add zod@^4
Or if you're using npm:
bash
npm install zod@^4
Step 2: Remove the Incompatible Library
If you have zod-to-json-schema in your dependencies, remove it:
bash
pnpm remove zod-to-json-schema
You no longer need it. Zod v4 has everything built-in.
Step 3: Update Your Gemini Integration
Here's the corrected implementation for your Gemini SDK integration:
typescript
// File: src/lib/gemini.tsimport { GenerateContentConfig, GenerateContentResponse, GoogleGenAI } from'@google/genai';
import { z } from'zod';
constGEMINI_API_KEY = process.env.GEMINI_API_KEY;
if (!GEMINI_API_KEY) {
console.warn('GEMINI_API_KEY is not defined in environment variables');
}
exportconst genAI = newGoogleGenAI({ apiKey: GEMINI_API_KEY || '' });
exportconstDEFAULT_MODEL = 'gemini-2.5-flash';
functionextractTextFromResponse(result: GenerateContentResponse): string {
const candidate = result.candidates?.[0];
if (!candidate) {
console.error('[Gemini Error] No candidates returned');
return'';
}
const content = candidate.content;
if (!content || !content.parts || content.parts.length === 0) {
console.error('[Gemini Error] No content parts returned');
return'';
}
const textPart = content.parts.find(p =>'text'in p);
const text = textPart ? textPart.text : '';
if (!text) {
console.error('[Gemini Error] No text found in content parts');
return'';
}
return text;
}
exportasyncfunction generateAIContent<T extends z.ZodTypeAny | undefined = undefined>(
input: string | Array<string | { inlineData: { mimeType: string; data: string } }>,
modelName: string = DEFAULT_MODEL,
schema?: T
): Promise<T extends z.ZodTypeAny ? z.infer<T> : string> {
try {
const contents = typeof input === 'string' ? input : input;
constconfig: GenerateContentConfig = {};
if (schema) {
// Use Zod v4's native z.toJSONSchema() methodconst jsonSchema = z.toJSONSchema(schema asany);
console.log('[Gemini] Config Schema:', JSON.stringify(jsonSchema, null, 2));
(config asany).responseJsonSchema = jsonSchema;
config.responseMimeType = 'application/json';
}
const result = await genAI.models.generateContent({
model: modelName,
contents,
config
}) asGenerateContentResponse;
letresponseStr: string | undefined;
if (result.text) {
responseStr = result.text;
}
if (!responseStr) {
responseStr = extractTextFromResponse(result);
}
if (!responseStr) {
thrownewError('No response from AI');
}
if (schema) {
// Clean up markdown code blocks if presentconst cleanJson = responseStr.replace(/```json\n?|\n?```/g, '').trim();
try {
const parsed = JSON.parse(cleanJson);
return schema.parse(parsed) as T extends z.ZodTypeAny ? z.infer<T> : string;
} catch (e) {
console.error('[Gemini] Failed to parse or validate JSON response:', cleanJson);
thrownewError(`Invalid AI response: ${e instanceofError ? e.message : String(e)}`);
}
}
console.log('[Gemini] Response:', responseStr);
return responseStr asunknownas T extends z.ZodTypeAny ? z.infer<T> : string;
} catch (error) {
console.error('Error generating AI content:', error);
throw error;
}
}
The critical change is on line 48: instead of importing and using zodToJsonSchema, we call z.toJSONSchema() directly on the Zod schema. This is a native Zod v4 method that generates complete, valid JSON schemas.
Step 4: Use It in Your AI Jobs
Now when you define a Zod schema in your AI job handlers, the structured output will work correctly:
typescript
// File: src/payload/jobs/ai/seo.tsimporttype { TaskHandler } from'payload';
import { z } from'zod';
import { generateAIContent } from'@/lib/gemini';
exportconstaiGenerateSeoHandler: TaskHandler<'ai-generate-seo'> = async ({ input, req }) => {
const { docId, collection, tenantId } = input;
// ... document fetching and verification ...const { title, contentText } = awaitgetDocContent(req, collection asCollectionName, docId);
const prompt = `Generate a concise SEO title (50-60 chars max) and meta description (100-150 chars max) for this article.
Title: ${title}
Content: ${contentText.substring(0, 5000)}
Requirements:
- SEO title: 50-60 characters (include spaces)
- Meta description: 100-150 characters (include spaces)
- Be concise and keyword-focused
- Include main topic from article`;
// Define your schema with clear field namesconst seoSchema = z.object({
title: z.string().describe("The SEO title (50-60 characters)"),
description: z.string().describe("The meta description (100-150 characters)"),
});
// Pass the schema to generateAIContentconst seo = awaitgenerateAIContent(prompt, undefined, seoSchema);
// seo is now properly typed and validatedawait req.payload.update({
collection: collection asCollectionName,
id: docId,
draft: true,
data: {
meta: {
title: seo.title,
description: seo.description,
}
},
context: {
disableRevalidation: true,
},
});
return { output: { message: 'SEO regenerated successfully' } };
};
When you pass the schema to generateAIContent, the function now converts it using z.toJSONSchema(), which produces the complete schema:
json
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"title":{"description":"The SEO title (50-60 characters)","type":"string"},"description":{"description":"The meta description (100-150 characters)","type":"string"}},"required":["title","description"],"additionalProperties":false}
Gemini now receives the full schema and properly enforces it. The model returns title and description fields exactly as specified, not some variation like seo_title.
Why This Matters
The difference between these two approaches is the difference between structured output that works and structured output that silently fails. Using Google's recommended library with Zod v4 leaves you debugging schema validation errors that don't actually exist—the problem is upstream in the schema conversion itself.
Avoid the compatibility nightmare that caught me and many other developers
Closing Note
Google's documentation should be updated to recommend z.toJSONSchema() instead of the third-party library, or at minimum add a compatibility note. If you encounter this issue, report it to Google's issue tracker so they update the official examples.
Let me know in the comments if you hit any issues implementing this, and subscribe for more practical development guides.