Guides

Building a Structured Output Pipeline with Gemini Flash and Zod

Structured output is the technique that turns unreliable LLM prose into validated, type-safe JSON. This guide walks through building a production-ready pipeline using Gemini Flash (Google's cost-efficient model with native JSON output) and Zod for runtime validation in Node.js. By the end, you'll have a reusable pattern for any content generation use case.

45–60 minutes6 steps
1

Set up your project and install dependencies

Create a new Node.js project and install the Google Generative AI SDK and Zod. We'll use the official @google/generative-ai package which has native structured output support.

mkdir structured-output-demo && cd structured-output-demo
npm init -y
npm install @google/generative-ai zod
npm install -D typescript @types/node tsx
npx tsc --init --strict

⚠ Common Pitfalls

  • Use the @google/generative-ai package (not @google/genai which is a different SDK)
  • TypeScript strict mode is recommended — Zod inferred types work best with strict null checks
2

Define your output schema with Zod

Before writing any generation code, define exactly what you want the LLM to produce. A Zod schema serves two purposes: it generates a JSON Schema for the API (constraining the model) and validates the output at runtime. Here's a schema for a resource article section.

src/schemas/article-section.ts
import { z } from 'zod';

export const ArticleSectionSchema = z.object({
  heading: z.string().describe('Section heading, concise and keyword-rich'),
  items: z.array(
    z.object({
      title: z.string().describe('Item title, under 80 characters'),
      description: z.string().describe('2-3 sentence description of the item'),
      difficulty: z.enum(['beginner', 'intermediate', 'advanced'])
        .describe('Technical difficulty level for the reader'),
    })
  ).min(5).max(10).describe('List of actionable items in this section'),
});

export type ArticleSection = z.infer<typeof ArticleSectionSchema>;

⚠ Common Pitfalls

  • Use .describe() on fields — these descriptions become part of the JSON Schema sent to the model and improve output quality significantly
  • Keep schemas flat where possible — deeply nested schemas increase token usage and hallucination risk
3

Convert your Zod schema to JSON Schema for the API

Gemini's responseSchema parameter accepts JSON Schema format. You can use zod-to-json-schema to convert your Zod schema automatically, avoiding duplication.

src/utils/schema-to-json.ts
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { ZodType } from 'zod';

export function toJsonSchema(schema: ZodType) {
  const jsonSchema = zodToJsonSchema(schema, { target: 'openApi3' });
  // Remove unsupported fields that Gemini rejects
  const { $schema, ...cleanSchema } = jsonSchema as Record<string, unknown>;
  return cleanSchema;
}

⚠ Common Pitfalls

  • Install zod-to-json-schema: npm install zod-to-json-schema
  • The target: 'openApi3' option produces cleaner output than the default JSON Schema draft
  • Gemini rejects some JSON Schema keywords — strip $schema and $defs if they cause errors
4

Initialize the Gemini client and configure structured output

The key configuration is setting responseMimeType to 'application/json' and providing the responseSchema. This constrains the model to produce output matching your schema rather than freeform text.

src/generator.ts
import { GoogleGenerativeAI } from '@google/generative-ai';
import { ArticleSectionSchema } from './schemas/article-section.js';
import { toJsonSchema } from './utils/schema-to-json.js';

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);

const model = genAI.getGenerativeModel({
  model: 'gemini-1.5-flash',
  generationConfig: {
    responseMimeType: 'application/json',
    responseSchema: toJsonSchema(ArticleSectionSchema) as object,
    temperature: 0.7,
  },
});

⚠ Common Pitfalls

  • Never hardcode your API key — use process.env.GEMINI_API_KEY
  • gemini-1.5-flash is more cost-effective than gemini-pro for structured output tasks
  • Lower temperature (0.3-0.7) produces more consistent structured output
5

Write the generation function with Zod validation

The generation function calls the model and immediately validates the response with your Zod schema. If validation fails, you can retry with a corrective prompt or log the failure. This is your validation gate.

src/generator.ts
import { ArticleSectionSchema, type ArticleSection } from './schemas/article-section.js';

export async function generateSection(
  niche: string,
  heading: string
): Promise<ArticleSection> {
  const prompt = `
    Generate a resource article section for the niche: "${niche}".
    Section heading: "${heading}"
    
    Produce exactly 7 actionable items. Each item needs a title, a 2-3 sentence description,
    and a difficulty level. Focus on practical, specific advice — not generic tips.
  `.trim();

  const result = await model.generateContent(prompt);
  const raw = result.response.text();
  
  // Parse and validate — will throw if schema doesn't match
  const parsed = JSON.parse(raw);
  return ArticleSectionSchema.parse(parsed);
}

⚠ Common Pitfalls

  • Always JSON.parse() before Zod validation — the response is a string even with JSON mode enabled
  • Wrap in a try/catch in production and implement retry logic for validation failures
  • Log the raw response on validation failure — it helps debug schema mismatches
6

Add retry logic for production robustness

Even with structured output, models occasionally produce invalid JSON or schema-mismatched content (especially for complex schemas). A simple retry wrapper with exponential backoff handles transient failures.

src/utils/retry.ts
export async function withRetry<T>(
  fn: () => Promise<T>,
  maxAttempts = 3,
  delayMs = 1000
): Promise<T> {
  let lastError: unknown;
  
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      console.warn(`Attempt ${attempt}/${maxAttempts} failed:`, error);
      if (attempt < maxAttempts) {
        await new Promise(resolve => setTimeout(resolve, delayMs * attempt));
      }
    }
  }
  
  throw lastError;
}

⚠ Common Pitfalls

  • Don't retry indefinitely — cap at 3-5 attempts to avoid runaway API costs
  • Log all failures even when retry succeeds — failure patterns reveal schema or prompt issues
  • For batch generation, track failure rate; above 5% usually means a schema or prompt problem

What you built

You now have a robust structured output pipeline: a Zod schema that defines your data contract, a JSON Schema conversion for the API, a generation function with runtime validation, and retry logic for production reliability. This pattern scales to any content type — change the schema and prompt, and the pipeline adapts. The validation gate is the key insight: no invalid content reaches your application.