Zod v4 & Gemini: Fix Structured Output with z.toJSONSchema

Stop using zod-to-json-schema—use Zod v4's native z.toJSONSchema to enforce Gemini structured output reliably.

·Updated on:·Matija Žiberna·
Zod v4 & Gemini: Fix Structured Output with z.toJSONSchema

📚 Get Practical Development Guides

Join developers getting comprehensive guides, code examples, optimization tips, and time-saving prompts to accelerate their development workflow.

No spam. Unsubscribe anytime.

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

Google's Gemini API documentation for structured output recommends using zod-to-json-schema, a third-party library:

import { zodToJsonSchema } from "zod-to-json-schema";

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:

{
  "$schema": "http://json-schema.org/draft-07/schema#"
}

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):

pnpm add zod@^4

Or if you're using npm:

npm install zod@^4

Step 2: Remove the Incompatible Library

If you have zod-to-json-schema in your dependencies, remove it:

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:

// File: src/lib/gemini.ts
import { GenerateContentConfig, GenerateContentResponse, GoogleGenAI } from '@google/genai';
import { z } from 'zod';

const GEMINI_API_KEY = process.env.GEMINI_API_KEY;

if (!GEMINI_API_KEY) {
    console.warn('GEMINI_API_KEY is not defined in environment variables');
}

export const genAI = new GoogleGenAI({ apiKey: GEMINI_API_KEY || '' });
export const DEFAULT_MODEL = 'gemini-2.5-flash';

function extractTextFromResponse(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;
}

export async function 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;

        const config: GenerateContentConfig = {};
        if (schema) {
            // Use Zod v4's native z.toJSONSchema() method
            const jsonSchema = z.toJSONSchema(schema as any);

            console.log('[Gemini] Config Schema:', JSON.stringify(jsonSchema, null, 2));
            (config as any).responseJsonSchema = jsonSchema;
            config.responseMimeType = 'application/json';
        }

        const result = await genAI.models.generateContent({
            model: modelName,
            contents,
            config
        }) as GenerateContentResponse;

        let responseStr: string | undefined;
        if (result.text) {
            responseStr = result.text;
        }

        if (!responseStr) {
            responseStr = extractTextFromResponse(result);
        }

        if (!responseStr) {
            throw new Error('No response from AI');
        }

        if (schema) {
            // Clean up markdown code blocks if present
            const 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);
                throw new Error(`Invalid AI response: ${e instanceof Error ? e.message : String(e)}`);
            }
        }

        console.log('[Gemini] Response:', responseStr);
        return responseStr as unknown as 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:

// File: src/payload/jobs/ai/seo.ts
import type { TaskHandler } from 'payload';
import { z } from 'zod';
import { generateAIContent } from '@/lib/gemini';

export const aiGenerateSeoHandler: TaskHandler<'ai-generate-seo'> = async ({ input, req }) => {
    const { docId, collection, tenantId } = input;

    // ... document fetching and verification ...

    const { title, contentText } = await getDocContent(req, collection as CollectionName, 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 names
    const 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 generateAIContent
    const seo = await generateAIContent(prompt, undefined, seoSchema);

    // seo is now properly typed and validated
    await req.payload.update({
        collection: collection as CollectionName,
        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:

{
  "$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.

By using Zod v4's native method, you:

  • Eliminate the external dependency
  • Get proper schema conversion out of the box
  • Have full type safety with TypeScript
  • Follow the actual Zod v4 design (as documented at zod.dev/json-schema)
  • 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.

Thanks, Matija


Sources

3

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.