ANTIGRAVITY LABJP
Articles/Integrations
Integrations/2026-04-12Advanced

Antigravity × Gemini API Multimodal Complete Implementation Guide: Building Production-Ready AI Apps with Text, Images, and Audio

A comprehensive guide to building production-grade multimodal AI apps using Antigravity and the Gemini API. Covers text, image, audio, and document processing with real code, cost optimization strategies, and robust error handling patterns.

gemini16multimodal7ai2antigravity429api13visionaudio2production71

Setup and context: Why Multimodal AI Changes Everything for Indie Developers

In 2026, one of the biggest shifts in AI app development is the move toward multimodal systems. We've gone from processing text alone to building systems that understand images, audio, video, and PDF documents in an integrated way — and this shift creates enormous business opportunities for solo developers.

By combining Antigravity IDE with the Gemini API, you can now build multimodal AI apps that previously required entire engineering teams, and ship them to production as a solo developer in a fraction of the time.

In this guide, you'll learn through working code and production-tested design patterns:

  • How to integrate Gemini's multimodal capabilities (Vision, Audio, Document)
  • An efficient implementation workflow using Antigravity's AI agent features
  • Error handling and cost optimization strategies for production environments
  • Full implementations of practical use cases: receipt scanning, audio transcription, and document summarization

This guide assumes you're familiar with Antigravity's core features and have a working knowledge of JavaScript/TypeScript. If you want to ship AI features professionally in your next product, you're in the right place.


Understanding the Gemini Multimodal Feature Landscape

Before writing any code, it helps to understand the available models and when to use each one.

Gemini 2.5 Pro Delivers the highest accuracy for multimodal understanding. Best for complex image analysis, long-form document summarization, and nuanced audio transcription. Higher cost and latency mean it should be reserved for tasks where quality is the top priority.

Gemini 2.5 Flash Excellent balance of speed and cost. Ideal for real-time or near-real-time scenarios and high-volume batch processing. In most production environments, this is your default workhorse.

Gemini 2.0 Flash Lite The most cost-efficient option. Great for lightweight tasks like simple image classification or short text transformations.

Key multimodal use cases to keep in mind:

  • Image → Text: OCR, receipt scanning, product description generation, accessibility alt text
  • Audio → Text + Analysis: Meeting transcription, sentiment analysis, summarization
  • PDF/Document → Structured Data: Invoice parsing, contract review, research paper summaries
  • Video → Insights: Highlight extraction from product demos, educational content summarization
  • Combined inputs: "Analyze this image and this audio note, then generate a combined report"

Antigravity Environment Setup and API Configuration

Project Structure

Start by creating a new project in Antigravity and installing the required dependencies.

# Run in Antigravity's terminal
npm create next-app@latest multimodal-ai-app -- --typescript --tailwind --app
cd multimodal-ai-app
npm install @google/generative-ai @google/genai
npm install zod react-dropzone

Environment Variables

Create .env.local in the project root. To prevent Antigravity's AI agent from accidentally hardcoding keys, explicitly instruct it in your project rules.

# .env.local
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
NEXT_PUBLIC_MAX_FILE_SIZE=10485760  # 10MB

Add the following to your .antigravity/rules.md project rules file:

# Security Rules
- Never write API keys or secrets directly in code. Always use process.env
- Never commit files containing real credentials
- Never reference GEMINI_API_KEY on the client side — server-side only

Initializing the Gemini Client

Create src/lib/gemini.ts:

import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai";
 
if (!process.env.GEMINI_API_KEY) {
  throw new Error("GEMINI_API_KEY is not set in environment variables");
}
 
// Lazily initialize and cache model instances
const clients = new Map<string, ReturnType<GoogleGenerativeAI["getGenerativeModel"]>>();
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
 
// Production-grade safety settings
const safetySettings = [
  {
    category: HarmCategory.HARM_CATEGORY_HARASSMENT,
    threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
  },
  {
    category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
    threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
  },
];
 
export function getGeminiModel(modelName: "gemini-2.5-pro" | "gemini-2.5-flash" | "gemini-2.0-flash-lite") {
  if (!clients.has(modelName)) {
    clients.set(
      modelName,
      genAI.getGenerativeModel({ model: modelName, safetySettings })
    );
  }
  return clients.get(modelName)!;
}
 
// Convert a file buffer into the format Gemini expects
export function fileToGenerativePart(data: ArrayBuffer, mimeType: string) {
  return {
    inlineData: {
      data: Buffer.from(data).toString("base64"),
      mimeType,
    },
  };
}

Building a Receipt Scanning API (Image to Text Pattern)

Let's implement a concrete, practical example: an API that extracts structured data from a receipt image.

Receipt Analysis API Route

// src/app/api/analyze-receipt/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getGeminiModel, fileToGenerativePart } from "@/lib/gemini";
 
const ReceiptSchema = z.object({
  storeName: z.string(),
  date: z.string(),
  totalAmount: z.number(),
  currency: z.string().default("USD"),
  items: z.array(
    z.object({
      name: z.string(),
      quantity: z.number().optional(),
      unitPrice: z.number().optional(),
      totalPrice: z.number(),
    })
  ),
  taxAmount: z.number().optional(),
  paymentMethod: z.string().optional(),
});
 
export type ReceiptData = z.infer<typeof ReceiptSchema>;
 
const EXTRACTION_PROMPT = `
This image contains a receipt. Extract the following information and return it as valid JSON.
 
{
  "storeName": "store or business name",
  "date": "date in YYYY-MM-DD format",
  "totalAmount": total amount as a number only,
  "currency": "currency code (e.g. USD, JPY, EUR)",
  "items": [
    {
      "name": "item name",
      "quantity": quantity as a number (omit if unclear),
      "unitPrice": unit price as a number (omit if unclear),
      "totalPrice": line total as a number
    }
  ],
  "taxAmount": tax amount as a number (omit if not shown),
  "paymentMethod": "payment method (omit if not shown)"
}
 
Return numbers as digits only — no currency symbols or commas.
If the date is illegible, use "unknown".
`;
 
export async function POST(req: NextRequest) {
  try {
    const MAX_SIZE = 10 * 1024 * 1024;
 
    const formData = await req.formData();
    const file = formData.get("file") as File | null;
 
    if (!file) {
      return NextResponse.json({ error: "No file provided" }, { status: 400 });
    }
 
    if (file.size > MAX_SIZE) {
      return NextResponse.json(
        { error: "File size exceeds 10MB limit" },
        { status: 413 }
      );
    }
 
    const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/heic"];
    if (!allowedTypes.includes(file.type)) {
      return NextResponse.json(
        { error: "Only JPEG, PNG, WebP, and HEIC are supported" },
        { status: 415 }
      );
    }
 
    const arrayBuffer = await file.arrayBuffer();
    const imagePart = fileToGenerativePart(arrayBuffer, file.type);
 
    const model = getGeminiModel("gemini-2.5-flash");
    const result = await model.generateContent([EXTRACTION_PROMPT, imagePart]);
    const responseText = result.response.text();
 
    const jsonMatch = responseText.match(/\{[\s\S]*\}/);
    if (!jsonMatch) {
      throw new Error("Failed to extract JSON from response");
    }
 
    const parsed = JSON.parse(jsonMatch[0]);
    const validated = ReceiptSchema.parse(parsed);
 
    return NextResponse.json({ success: true, data: validated });
  } catch (error) {
    console.error("Receipt analysis error:", error);
 
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Invalid data format", details: error.errors },
        { status: 422 }
      );
    }
 
    return NextResponse.json(
      { error: "Analysis failed. Please try again." },
      { status: 500 }
    );
  }
}

Debugging Efficiently with Antigravity

Use Antigravity's inline chat (Cmd+I) with prompts like this to improve your code:

Review this API route's error handling and add proper retry logic
for Gemini API rate limit errors (429) and network failures,
using exponential backoff with jitter.

Document and PDF Analysis Pipeline

PDF and document processing is one of the highest-value capabilities for business-facing applications.

Document Analyzer Service

// src/lib/document-analyzer.ts
import { getGeminiModel, fileToGenerativePart } from "./gemini";
 
export interface DocumentAnalysisOptions {
  extractType: "summary" | "key-points" | "qa" | "structured";
  language?: "ja" | "en";
}
 
export interface DocumentAnalysisResult {
  title?: string;
  summary?: string;
  keyPoints?: string[];
  structuredData?: Record<string, unknown>;
  processingTimeMs: number;
}
 
const ANALYSIS_PROMPTS: Record<DocumentAnalysisOptions["extractType"], string> = {
  summary: `
Read this document and return the following in JSON:
1. title or main subject (one line)
2. summary (under 150 words)
3. key points (3 to 5 bullet points)
 
JSON format:
{"title": "...", "summary": "...", "keyPoints": ["...", "..."]}
`,
  "key-points": `
Extract 5 to 10 of the most important pieces of information from this document.
Return as JSON: {"keyPoints": ["...", "..."]}
`,
  qa: `
Generate 5 Q&A pairs based on the content of this document.
Return as JSON: {"qa": [{"q": "...", "a": "..."}, ...]}
`,
  structured: `
Extract all structured data from this document (tables, lists, numbers, dates, etc.).
Return as clean JSON.
`,
};
 
export async function analyzeDocument(
  fileData: ArrayBuffer,
  mimeType: string,
  options: DocumentAnalysisOptions
): Promise<DocumentAnalysisResult> {
  const startTime = Date.now();
 
  const modelName =
    mimeType === "application/pdf" ? "gemini-2.5-pro" : "gemini-2.5-flash";
 
  const model = getGeminiModel(modelName);
  const filePart = fileToGenerativePart(fileData, mimeType);
  const prompt = ANALYSIS_PROMPTS[options.extractType];
 
  try {
    const result = await model.generateContent([prompt, filePart]);
    const text = result.response.text();
 
    const jsonMatch = text.match(/\{[\s\S]*\}/);
    const data = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
 
    return {
      title: data.title,
      summary: data.summary,
      keyPoints: data.keyPoints,
      structuredData: options.extractType === "structured" ? data : undefined,
      processingTimeMs: Date.now() - startTime,
    };
  } catch (error) {
    throw new Error(
      `Document analysis failed: ${error instanceof Error ? error.message : "Unknown error"}`
    );
  }
}

Supported Formats and Limits

Formats Gemini handles natively:

  • Images: JPEG, PNG, WebP, HEIC/HEIF (up to 20MB)
  • Video: MP4, MOV, AVI, MKV (up to 2GB via File API)
  • Audio: WAV, MP3, AAC, OGG, FLAC (up to 20MB)
  • Documents: PDF (up to 1,000 pages, 100MB)

Audio Transcription API

Audio-to-Text API Route

// src/app/api/transcribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getGeminiModel, fileToGenerativePart } from "@/lib/gemini";
 
interface TranscriptionResult {
  text: string;
  language?: string;
  confidence?: "high" | "medium" | "low";
}
 
export async function POST(req: NextRequest) {
  try {
    const formData = await req.formData();
    const audioFile = formData.get("audio") as File | null;
    const lang = (formData.get("lang") as string) || "auto";
 
    if (!audioFile) {
      return NextResponse.json({ error: "Audio file is required" }, { status: 400 });
    }
 
    const allowedTypes = [
      "audio/wav", "audio/mpeg", "audio/mp4",
      "audio/aac", "audio/ogg", "audio/flac",
    ];
 
    if (!allowedTypes.includes(audioFile.type)) {
      return NextResponse.json({ error: "Unsupported audio format" }, { status: 415 });
    }
 
    const arrayBuffer = await audioFile.arrayBuffer();
    const audioPart = fileToGenerativePart(arrayBuffer, audioFile.type);
 
    const langInstruction =
      lang === "auto"
        ? "Automatically detect the language and transcribe accordingly."
        : `The language is ${lang}.`;
 
    const prompt = `
${langInstruction}
Transcribe this audio file and return the result as JSON:
 
{
  "text": "full transcription text",
  "language": "detected language code (e.g. en, ja)",
  "confidence": "high/medium/low"
}
 
Add appropriate punctuation. If speakers change, separate with a newline.
Mark inaudible sections as [inaudible].
`;
 
    const model = getGeminiModel("gemini-2.5-flash");
    const result = await model.generateContent([prompt, audioPart]);
    const text = result.response.text();
 
    const jsonMatch = text.match(/\{[\s\S]*\}/);
    const data: TranscriptionResult = jsonMatch
      ? JSON.parse(jsonMatch[0])
      : { text, confidence: "low" };
 
    return NextResponse.json({ success: true, data });
  } catch (error) {
    console.error("Transcription error:", error);
    return NextResponse.json({ error: "Transcription failed" }, { status: 500 });
  }
}

Building a Multimodal Integration Pipeline

The real power of multimodal AI emerges when you combine multiple modalities together. Here's a production-ready meeting minutes generator that processes audio recordings alongside slide images to automatically produce structured meeting notes.

Meeting Minutes Pipeline

// src/lib/meeting-pipeline.ts
import { GoogleGenerativeAI } from "@google/generative-ai";
import { fileToGenerativePart } from "./gemini";
 
interface MeetingInput {
  audioData: ArrayBuffer;
  audioMimeType: string;
  slideImages?: Array<{ data: ArrayBuffer; mimeType: string }>;
  meetingTitle: string;
  attendees?: string[];
}
 
interface MeetingMinutes {
  title: string;
  date: string;
  attendees: string[];
  agenda: string[];
  discussions: Array<{
    topic: string;
    content: string;
    decisions: string[];
    actionItems: string[];
  }>;
  nextSteps: string[];
  generatedAt: string;
}
 
export async function generateMeetingMinutes(input: MeetingInput): Promise<MeetingMinutes> {
  const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
  const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" });
 
  const parts: any[] = [];
 
  parts.push(fileToGenerativePart(input.audioData, input.audioMimeType));
 
  if (input.slideImages?.length) {
    for (const slide of input.slideImages) {
      parts.push(fileToGenerativePart(slide.data, slide.mimeType));
    }
  }
 
  const attendeesStr = input.attendees?.join(", ") || "Unknown";
 
  parts.push(`
Meeting title: ${input.meetingTitle}
Attendees: ${attendeesStr}
Today's date: ${new Date().toISOString().split("T")[0]}
 
Analyze the audio recording${input.slideImages?.length ? " and slide images" : ""} above
and produce detailed meeting minutes in the following JSON format:
 
{
  "title": "meeting title",
  "date": "YYYY-MM-DD",
  "attendees": ["name1", "name2"],
  "agenda": ["item1", "item2"],
  "discussions": [
    {
      "topic": "discussion topic",
      "content": "summary under 100 words",
      "decisions": ["decision1", "decision2"],
      "actionItems": ["Action: Owner to Due date"]
    }
  ],
  "nextSteps": ["step1", "step2"],
  "generatedAt": "ISO 8601 timestamp"
}
`);
 
  const result = await model.generateContent(parts);
  const text = result.response.text();
 
  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (!jsonMatch) throw new Error("Failed to generate meeting minutes");
 
  return JSON.parse(jsonMatch[0]) as MeetingMinutes;
}

Streaming Responses for Large Files

For large files or long documents, streaming significantly improves user experience by eliminating the blank loading screen.

// src/app/api/analyze-stream/route.ts
import { NextRequest } from "next/server";
import { GoogleGenerativeAI } from "@google/generative-ai";
import { fileToGenerativePart } from "@/lib/gemini";
 
export async function POST(req: NextRequest) {
  const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
  const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
 
  const formData = await req.formData();
  const file = formData.get("file") as File;
  const prompt = (formData.get("prompt") as string) || "Analyze this file.";
 
  const arrayBuffer = await file.arrayBuffer();
  const filePart = fileToGenerativePart(arrayBuffer, file.type);
 
  const stream = new ReadableStream({
    async start(controller) {
      try {
        const result = await model.generateContentStream([prompt, filePart]);
 
        for await (const chunk of result.stream) {
          const text = chunk.text();
          if (text) {
            controller.enqueue(
              new TextEncoder().encode(`data: ${JSON.stringify({ text })}\n\n`)
            );
          }
        }
 
        controller.enqueue(
          new TextEncoder().encode(`data: ${JSON.stringify({ done: true })}\n\n`)
        );
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
  });
 
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

Cost Optimization and Performance Tuning for Production

Managing API costs is critical when running multimodal AI in production.

Model Routing Strategy

Routing requests to the right model based on task complexity can cut your API bill by 50 to 70%.

// src/lib/model-router.ts
interface RoutingDecision {
  model: "gemini-2.0-flash-lite" | "gemini-2.5-flash" | "gemini-2.5-pro";
  reasoning: string;
}
 
export function routeToModel(
  task: string,
  fileType: string,
  fileSizeMB: number
): RoutingDecision {
  if (fileType === "application/pdf" && fileSizeMB > 5) {
    return { model: "gemini-2.5-pro", reasoning: "Large PDF requires high-accuracy model" };
  }
 
  if (
    fileType.startsWith("image/") &&
    fileSizeMB < 1 &&
    (task.includes("classify") || task.includes("detect") || task.includes("yes/no"))
  ) {
    return { model: "gemini-2.0-flash-lite", reasoning: "Simple classification — minimize cost" };
  }
 
  return { model: "gemini-2.5-flash", reasoning: "Standard multimodal task" };
}

Response Caching

Cache identical file analyses to avoid redundant API calls.

// src/lib/analysis-cache.ts
import crypto from "crypto";
 
const cache = new Map<string, { result: unknown; expiresAt: number }>();
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
 
export function getCacheKey(fileData: ArrayBuffer, prompt: string): string {
  return crypto
    .createHash("sha256")
    .update(Buffer.from(fileData))
    .update(prompt)
    .digest("hex")
    .substring(0, 32);
}
 
export function getCached<T>(key: string): T | null {
  const entry = cache.get(key);
  if (!entry) return null;
  if (Date.now() > entry.expiresAt) { cache.delete(key); return null; }
  return entry.result as T;
}
 
export function setCached<T>(key: string, result: T): void {
  cache.set(key, { result, expiresAt: Date.now() + CACHE_TTL_MS });
}

Retry Logic with Exponential Backoff

// src/lib/retry.ts
interface RetryOptions {
  maxRetries?: number;
  initialDelayMs?: number;
  maxDelayMs?: number;
}
 
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
  const { maxRetries = 3, initialDelayMs = 1000, maxDelayMs = 10000 } = options;
  let lastError: Error;
 
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
 
      if (error instanceof Error) {
        if (error.message.includes("INVALID_ARGUMENT") || error.message.includes("PERMISSION_DENIED")) {
          throw error;
        }
      }
 
      if (attempt < maxRetries - 1) {
        const delay = Math.min(
          initialDelayMs * Math.pow(2, attempt) + Math.random() * 1000,
          maxDelayMs
        );
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }
  }
 
  throw lastError!;
}

Building a React Frontend for File Upload

Now that the API routes are in place, let's build the client-side component that ties everything together. This React component handles drag-and-drop file uploads, shows real-time progress, and displays the AI analysis results.

Multimodal Upload Component

// src/components/MultimodalUploader.tsx
"use client";
 
import { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
 
type AnalysisMode = "receipt" | "document" | "audio";
 
interface AnalysisResult {
  success: boolean;
  data?: unknown;
  error?: string;
}
 
interface Props {
  mode: AnalysisMode;
}
 
const MODE_CONFIG: Record<AnalysisMode, {
  accept: Record<string, string[]>;
  label: string;
  endpoint: string;
  fileKey: string;
}> = {
  receipt: {
    accept: { "image/*": [".jpg", ".jpeg", ".png", ".webp", ".heic"] },
    label: "Drop a receipt image here",
    endpoint: "/api/analyze-receipt",
    fileKey: "file",
  },
  document: {
    accept: {
      "application/pdf": [".pdf"],
      "image/*": [".jpg", ".jpeg", ".png"],
    },
    label: "Drop a PDF or document image here",
    endpoint: "/api/analyze-document",
    fileKey: "file",
  },
  audio: {
    accept: {
      "audio/*": [".mp3", ".wav", ".aac", ".ogg", ".flac"],
    },
    label: "Drop an audio file here",
    endpoint: "/api/transcribe",
    fileKey: "audio",
  },
};
 
export function MultimodalUploader({ mode }: Props) {
  const [isProcessing, setIsProcessing] = useState(false);
  const [result, setResult] = useState<AnalysisResult | null>(null);
  const [fileName, setFileName] = useState<string | null>(null);
 
  const config = MODE_CONFIG[mode];
 
  const onDrop = useCallback(
    async (acceptedFiles: File[]) => {
      const file = acceptedFiles[0];
      if (\!file) return;
 
      setFileName(file.name);
      setIsProcessing(true);
      setResult(null);
 
      try {
        const formData = new FormData();
        formData.append(config.fileKey, file);
 
        const response = await fetch(config.endpoint, {
          method: "POST",
          body: formData,
        });
 
        const data = await response.json();
 
        if (\!response.ok) {
          setResult({ success: false, error: data.error || "Request failed" });
        } else {
          setResult({ success: true, data: data.data });
        }
      } catch (err) {
        setResult({
          success: false,
          error: err instanceof Error ? err.message : "Network error",
        });
      } finally {
        setIsProcessing(false);
      }
    },
    [config]
  );
 
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: config.accept,
    maxFiles: 1,
    maxSize: 10 * 1024 * 1024, // 10MB
    disabled: isProcessing,
  });
 
  return (
    <div className="w-full max-w-2xl mx-auto space-y-4">
      {/* Drop zone */}
      <div
        {...getRootProps()}
        className={[
          "border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors",
          isDragActive ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-gray-400",
          isProcessing ? "opacity-50 cursor-not-allowed" : "",
        ].join(" ")}
      >
        <input {...getInputProps()} />
        {isProcessing ? (
          <p className="text-gray-600 animate-pulse">Analyzing with Gemini AI...</p>
        ) : isDragActive ? (
          <p className="text-blue-600 font-medium">Drop to analyze</p>
        ) : (
          <div>
            <p className="text-gray-700 font-medium">{config.label}</p>
            <p className="text-sm text-gray-400 mt-1">or click to browse — max 10MB</p>
          </div>
        )}
      </div>
 
      {/* Results display */}
      {result && (
        <div
          className={[
            "rounded-xl p-5 border",
            result.success
              ? "bg-green-50 border-green-200"
              : "bg-red-50 border-red-200",
          ].join(" ")}
        >
          {result.success ? (
            <div>
              <p className="text-sm font-medium text-green-800 mb-3">
                Analysis complete — {fileName}
              </p>
              <pre className="text-xs text-gray-700 overflow-auto max-h-64 bg-white rounded p-3">
                {JSON.stringify(result.data, null, 2)}
              </pre>
            </div>
          ) : (
            <p className="text-sm text-red-700">{result.error}</p>
          )}
        </div>
      )}
    </div>
  );
}

Integrating the Component into a Page

// src/app/tools/receipt-scanner/page.tsx
import { MultimodalUploader } from "@/components/MultimodalUploader";
 
export const metadata = {
  title: "AI Receipt Scanner",
  description: "Extract structured data from receipt images using Gemini AI",
};
 
export default function ReceiptScannerPage() {
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-3xl mx-auto">
        <h1 className="text-2xl font-bold text-gray-900 mb-2">
          AI Receipt Scanner
        </h1>
        <p className="text-gray-500 mb-8">
          Upload a receipt image and get structured data extracted instantly using Gemini AI.
        </p>
        <MultimodalUploader mode="receipt" />
      </div>
    </main>
  );
}

Displaying Results in a Structured Format

For a polished user experience, you'll want to render the extracted receipt data in a readable layout rather than raw JSON.

// src/components/ReceiptDisplay.tsx
"use client";
 
import type { ReceiptData } from "@/app/api/analyze-receipt/route";
 
export function ReceiptDisplay({ data }: { data: ReceiptData }) {
  return (
    <div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
      {/* Header */}
      <div className="px-5 py-4 border-b border-gray-100">
        <h3 className="font-semibold text-gray-900">{data.storeName}</h3>
        <p className="text-sm text-gray-500">{data.date}</p>
      </div>
 
      {/* Items */}
      <ul className="divide-y divide-gray-50">
        {data.items.map((item, i) => (
          <li key={i} className="px-5 py-3 flex justify-between text-sm">
            <span className="text-gray-700">{item.name}</span>
            <span className="text-gray-900 font-medium tabular-nums">
              {data.currency} {item.totalPrice.toLocaleString()}
            </span>
          </li>
        ))}
      </ul>
 
      {/* Totals */}
      <div className="px-5 py-4 bg-gray-50 space-y-1">
        {data.taxAmount \!== undefined && (
          <div className="flex justify-between text-sm text-gray-500">
            <span>Tax</span>
            <span>{data.currency} {data.taxAmount.toLocaleString()}</span>
          </div>
        )}
        <div className="flex justify-between font-semibold text-gray-900">
          <span>Total</span>
          <span>{data.currency} {data.totalAmount.toLocaleString()}</span>
        </div>
        {data.paymentMethod && (
          <p className="text-xs text-gray-400 pt-1">{data.paymentMethod}</p>
        )}
      </div>
    </div>
  );
}

With Antigravity's AI agent, you can generate this entire component setup by describing what you need in the chat: "Create a React component with drag-and-drop file upload that calls /api/analyze-receipt and displays the result as a formatted receipt." The agent will scaffold the full implementation, which you can then refine with targeted follow-up prompts.

Summary

This guide covered a complete implementation approach for multimodal AI apps using Antigravity and the Gemini API. The key takeaways are these: model selection is the single biggest lever for cost control, routing tasks to Lite or Flash instead of Pro can cut your bill by up to 70%; Zod-based schema validation gives you type-safe, predictable AI responses that are safe to ship; streaming responses remove the anxiety of long loading screens when processing large files; and every production multimodal app needs both caching and retry logic — caching reduces unnecessary spend, while retry logic provides the reliability users expect.

Multimodal AI is one of the fastest-evolving areas in 2026 and beyond. Combined with Antigravity's AI-assisted development environment, solo developers can now ship production-quality multimodal apps in days rather than months. Start with one use case — receipt scanning, audio transcription, or document summarization — and build your intuition from there.

For deeper Gemini ecosystem coverage, see our guides on Gemini API Custom AI Tools and the Gemini API Advanced Integration Masterclass.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Integrations2026-05-05
How I Cut Gemini API Costs by 75% Using Context Caching in Antigravity
A complete implementation guide for Gemini API context caching in Antigravity — with working TypeScript and Python code, real cost calculation examples, and three production-ready patterns to dramatically reduce your API spend.
Integrations2026-04-22
Three Safeguards Every Antigravity Python API Deployment Needs Before Production
Retries, timeouts, and circuit breakers — the three production safeguards you need around your Antigravity Python API calls, with working code for each.
Integrations2026-04-17
Google Antigravity Python SDK Production Masterguide: Multimodal, Agents, and RAG Pipelines from Design to Deployment
The complete guide to using the Google Antigravity Python SDK in production. Covers multimodal input, tool calling, RAG pipelines, streaming, cost optimization, and Cloud Run deployment with working code examples.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →