writing/tutorial/2026/07
TutorialJul 31, 2026·32 min read

Build an AI Image Generation App with Gemini Nano Banana Pro and Next.js

A production walkthrough of Google's Nano Banana Pro image model in Next.js: typed generation and editing services, reference-image composition, legible Arabic and multilingual text inside images, WebP storage, rate limiting, cost tiering, and SynthID provenance.

The demo that dies in production

Image generation demos are the easiest thing in the world to build. Ten lines, one API key, a prompt box, and a picture appears. The demo works. Everybody claps.

Then it goes live and the real questions arrive all at once. What happens when the model returns text instead of an image because the safety filter fired? Where do you put a 4 MB PNG that a user will look at once? Who pays when somebody discovers your unauthenticated endpoint and generates fourteen thousand images overnight? Why does the Arabic text in the poster come out as disconnected letterforms? And when a client asks whether an image was AI-generated, what do you actually tell them?

This tutorial builds the version that survives those questions. We will use Google's Nano Banana Pro — the image generation and editing model exposed through the Gemini API as gemini-3-pro-image-preview — inside a Next.js App Router project, and treat every one of the above as a design requirement rather than an afterthought.

What you'll build

A single-page image studio with a real backend behind it:

  • A typed generation service that handles aspect ratio, resolution, and the failure modes the SDK does not model for you
  • An API route that validates input, enforces per-user rate limits, and never trusts the client about which model to bill
  • Conversational editing: upload a reference image, ask for a change, get a new image that keeps the subject consistent
  • Multilingual text rendering, including the Arabic case that most image models get wrong
  • Storage that converts to WebP before it touches your bucket, because the raw model output is not what you want to serve
  • Cost tiering between the fast model and the pro model, plus a cache that stops you paying twice for the same prompt

By the end you will have roughly 400 lines of application code and a clear mental model of where the money goes.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ and a package manager (this guide uses pnpm)
  • A Gemini API key from Google AI Studio, in an environment variable
  • Comfort with the Next.js App Router — route handlers, server actions, and the server/client component split
  • Basic TypeScript — we lean on types to catch the response shapes that vary at runtime
  • Optional but recommended: an S3-compatible bucket and an Upstash Redis instance for the storage and rate-limiting steps

If the Gemini API itself is new to you, our Gemini API with TypeScript guide covers the text side of the same SDK and is a useful companion.

Step 1: Project setup

Create the project and install the SDK:

pnpm create next-app@latest image-studio --typescript --app --tailwind
cd image-studio
pnpm add @google/genai zod sharp
pnpm add -D @types/node

Note the package name. @google/genai is the current unified Google Gen AI SDK covering both the Gemini Developer API and Vertex AI. The older @google/generative-ai package is a different, deprecated library — following a tutorial written against it will send you down a dead end.

Put your key in .env.local:

GEMINI_API_KEY=your_key_here

Never expose this to the browser. There is no NEXT_PUBLIC_ version of this variable in a correct implementation — every model call happens on the server, behind an endpoint you control. A leaked image-model key is a metered credit card.

Step 2: The smallest call that works

Before building anything around it, verify the API surface with a throwaway script. Create scripts/smoke.ts:

import { GoogleGenAI } from '@google/genai'
import { writeFileSync } from 'node:fs'
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY })
 
const response = await ai.models.generateContent({
  model: 'gemini-3-pro-image-preview',
  contents: 'A cross-section diagram of a Tunisian olive press, technical illustration style, labelled parts, warm ochre palette',
  config: {
    responseModalities: ['IMAGE'],
    imageConfig: {
      aspectRatio: '16:9',
      imageSize: '2K',
    },
  },
})
 
for (const part of response.candidates?.[0]?.content?.parts ?? []) {
  if (part.inlineData?.data) {
    writeFileSync('out.png', Buffer.from(part.inlineData.data, 'base64'))
    console.log('saved out.png')
  }
}

Run it:

GEMINI_API_KEY=$GEMINI_API_KEY pnpm dlx tsx scripts/smoke.ts

Three details in that snippet matter more than they look.

responseModalities: ['IMAGE'] is what turns a text model call into an image call. Omit it and you will get a polite paragraph describing an olive press.

imageConfig carries aspectRatio (supported values include 1:1, 2:3, 3:2, 3:4, 4:3, 9:16, 16:9, and 21:9) and imageSize (1K, 2K, 4K, defaulting to 1K). Resolution is a billing decision, not a quality knob you should max out by reflex — 4K costs meaningfully more per image and most web surfaces never display it.

The response is a parts array, not an image. A single response can contain text parts, image parts, or both, and the model sometimes returns text only. Any code that reaches for parts[0].inlineData.data directly will throw in production the first time a prompt trips a filter.

Step 3: A generation service that models failure

Wrap the SDK in something your route handler can trust. Create lib/gemini-image.ts:

import { GoogleGenAI } from '@google/genai'
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! })
 
export const MODELS = {
  fast: 'gemini-2.5-flash-image',
  pro: 'gemini-3-pro-image-preview',
} as const
 
export type ModelTier = keyof typeof MODELS
 
export type ReferenceImage = {
  data: string      // base64, no data-URL prefix
  mimeType: string  // 'image/png' | 'image/jpeg' | 'image/webp'
}
 
export type GenerateOptions = {
  prompt: string
  tier?: ModelTier
  aspectRatio?: string
  imageSize?: '1K' | '2K' | '4K'
  references?: ReferenceImage[]
}
 
export type GenerateResult =
  | { ok: true; image: Buffer; mimeType: string; note?: string }
  | { ok: false; reason: 'no_image' | 'blocked' | 'error'; message: string }
 
export async function generateImage(
  options: GenerateOptions,
): Promise<GenerateResult> {
  const {
    prompt,
    tier = 'pro',
    aspectRatio = '1:1',
    imageSize = '2K',
    references = [],
  } = options
 
  const parts: Array<Record<string, unknown>> = [
    ...references.map((ref) => ({
      inlineData: { data: ref.data, mimeType: ref.mimeType },
    })),
    { text: prompt },
  ]
 
  try {
    const response = await ai.models.generateContent({
      model: MODELS[tier],
      contents: [{ role: 'user', parts }],
      config: {
        responseModalities: ['IMAGE'],
        imageConfig: { aspectRatio, imageSize },
      },
    })
 
    const candidate = response.candidates?.[0]
 
    if (candidate?.finishReason === 'SAFETY' || candidate?.finishReason === 'PROHIBITED_CONTENT') {
      return {
        ok: false,
        reason: 'blocked',
        message: 'The request was blocked by the safety filter.',
      }
    }
 
    let image: Buffer | null = null
    let mimeType = 'image/png'
    let note: string | undefined
 
    for (const part of candidate?.content?.parts ?? []) {
      if (part.inlineData?.data) {
        image = Buffer.from(part.inlineData.data, 'base64')
        mimeType = part.inlineData.mimeType ?? 'image/png'
      } else if (part.text) {
        note = part.text
      }
    }
 
    if (!image) {
      return {
        ok: false,
        reason: 'no_image',
        message: note ?? 'The model returned no image for this prompt.',
      }
    }
 
    return { ok: true, image, mimeType, note }
  } catch (error) {
    return {
      ok: false,
      reason: 'error',
      message: error instanceof Error ? error.message : 'Unknown error',
    }
  }
}

The return type is the point. A discriminated union forces the caller to handle "blocked" and "no image" as ordinary outcomes rather than exceptions, which is exactly what they are — a safety refusal is a normal response from a working API, not a bug.

Notice also that references come first in the parts array and the text prompt comes last. The model reads the images as context and the trailing text as the instruction; putting the instruction first produces noticeably weaker adherence in editing tasks.

Step 4: The API route

Now the endpoint. Create app/api/generate/route.ts:

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { generateImage } from '@/lib/gemini-image'
import { storeImage } from '@/lib/storage'
import { checkRateLimit } from '@/lib/rate-limit'
import { getSession } from '@/lib/auth'
 
export const runtime = 'nodejs'
export const maxDuration = 60
 
const BodySchema = z.object({
  prompt: z.string().min(3).max(2000),
  aspectRatio: z.enum(['1:1', '3:4', '4:3', '9:16', '16:9', '21:9']).default('1:1'),
  imageSize: z.enum(['1K', '2K']).default('2K'),
  references: z
    .array(
      z.object({
        data: z.string().max(10_000_000),
        mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp']),
      }),
    )
    .max(3)
    .default([]),
})
 
export async function POST(request: Request) {
  const session = await getSession()
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const limit = await checkRateLimit(session.userId)
  if (!limit.success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded', resetAt: limit.reset },
      { status: 429 },
    )
  }
 
  const parsed = BodySchema.safeParse(await request.json())
  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Invalid request', details: parsed.error.flatten() },
      { status: 400 },
    )
  }
 
  const result = await generateImage({
    ...parsed.data,
    tier: session.plan === 'pro' ? 'pro' : 'fast',
  })
 
  if (!result.ok) {
    const status = result.reason === 'blocked' ? 422 : 502
    return NextResponse.json({ error: result.message, reason: result.reason }, { status })
  }
 
  const url = await storeImage(result.image, session.userId)
  return NextResponse.json({ url, note: result.note })
}

Several decisions here are worth stating explicitly, because they are the ones people skip.

Authentication comes before validation. An unauthenticated image endpoint is not a security hole in the abstract sense — it is a direct, measurable bill. Rate limiting comes second, before you have spent a cent on model tokens.

The client does not choose the model. tier is derived from the session plan on the server. If the request body could set tier: 'pro', every free user would send that field within a day of somebody posting your endpoint on a forum.

imageSize excludes 4K at the schema level. You can add it for paying tiers, but the schema is the right place to make that decision rather than trusting a dropdown.

maxDuration = 60. Pro-tier generation at 2K regularly takes fifteen to forty seconds. The default serverless timeout will cut you off mid-generation, and you will have paid for the image you never received.

For a deeper treatment of the limiting layer, see our Upstash rate limiting guide; the sliding-window recipe there drops straight into checkRateLimit.

Step 5: Store WebP, not what the model gave you

The model returns PNG. PNG is the wrong format to serve — a 2K generated image lands somewhere around 3 to 6 MB, and the same picture as WebP at quality 82 is usually under 400 KB with no visible difference on a screen.

Create lib/storage.ts:

import sharp from 'sharp'
import { createHash, randomUUID } from 'node:crypto'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
 
const s3 = new S3Client({
  region: process.env.S3_REGION!,
  endpoint: process.env.S3_ENDPOINT,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY!,
    secretAccessKey: process.env.S3_SECRET_KEY!,
  },
})
 
export async function storeImage(image: Buffer, userId: string): Promise<string> {
  const webp = await sharp(image)
    .webp({ quality: 82, effort: 4 })
    .toBuffer()
 
  const key = `generations/${userId}/${randomUUID()}.webp`
 
  await s3.send(
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: key,
      Body: webp,
      ContentType: 'image/webp',
      CacheControl: 'public, max-age=31536000, immutable',
    }),
  )
 
  return `${process.env.CDN_BASE_URL}/${key}`
}
 
export function promptFingerprint(input: Record<string, unknown>): string {
  return createHash('sha256').update(JSON.stringify(input)).digest('hex').slice(0, 32)
}

Two things to flag. effort: 4 is a deliberate middle setting — effort: 6 shaves maybe 8% more off the file at roughly triple the CPU time, which is a bad trade inside a request that already took thirty seconds. And the immutable cache header is safe precisely because the key contains a UUID: the bytes at that URL will never change.

promptFingerprint is there for the next step.

Step 6: Don't pay twice for the same picture

Image generation is non-deterministic, but users are extremely repetitive. They tweak one word, dislike the result, and hit the original prompt again. Caching identical requests is the single highest-leverage cost control in this whole application.

import { Redis } from '@upstash/redis'
import { promptFingerprint } from './storage'
 
const redis = Redis.fromEnv()
 
export async function cachedGeneration(
  input: { prompt: string; aspectRatio: string; imageSize: string; tier: string },
  produce: () => Promise<string>,
): Promise<{ url: string; cached: boolean }> {
  const key = `img:${promptFingerprint(input)}`
 
  const hit = await redis.get<string>(key)
  if (hit) return { url: hit, cached: true }
 
  const url = await produce()
  await redis.set(key, url, { ex: 60 * 60 * 24 * 7 })
  return { url, cached: false }
}

Fingerprint the full parameter set, not just the prompt text — the same words at 16:9 and at 9:16 are two different images, and a cache that ignores that will hand users the wrong aspect ratio and be very hard to debug.

One caveat worth surfacing in your UI: if a user genuinely wants a different take on the same prompt, a cache makes the app feel broken. Add a "regenerate" control that appends a nonce to the fingerprint input so it deliberately misses.

Step 7: The client

The interface is a form, a loading state, and an image. Server actions with React 19's useActionState keep it short. Create app/studio/generate-form.tsx:

'use client'
 
import { useActionState } from 'react'
import { generateAction } from './actions'
 
const RATIOS = ['1:1', '4:3', '16:9', '9:16', '21:9'] as const
 
export function GenerateForm() {
  const [state, formAction, pending] = useActionState(generateAction, null)
 
  return (
    <div className="grid gap-6 lg:grid-cols-2">
      <form action={formAction} className="flex flex-col gap-4">
        <textarea
          name="prompt"
          rows={5}
          required
          minLength={3}
          maxLength={2000}
          placeholder="Describe the image you want..."
          className="w-full resize-y rounded-lg border p-3"
        />
 
        <select name="aspectRatio" defaultValue="1:1" className="rounded-lg border p-2">
          {RATIOS.map((ratio) => (
            <option key={ratio} value={ratio}>
              {ratio}
            </option>
          ))}
        </select>
 
        <button
          type="submit"
          disabled={pending}
          className="rounded-lg bg-black px-4 py-2 text-white disabled:opacity-50"
        >
          {pending ? 'Generating...' : 'Generate'}
        </button>
 
        {state?.error && (
          <p role="alert" className="text-sm text-red-600">
            {state.error}
          </p>
        )}
      </form>
 
      <div className="flex aspect-square items-center justify-center rounded-lg border bg-neutral-50">
        {pending && <span className="animate-pulse text-sm">Rendering...</span>}
        {state?.url && (
          <img src={state.url} alt="Generated result" className="h-full w-full object-contain" />
        )}
      </div>
    </div>
  )
}

The one non-obvious accessibility point: role="alert" on the error paragraph. Generation failures arrive twenty to forty seconds after the click, long after a screen reader user has moved focus elsewhere. Without a live region, the failure is silent.

Give the preview container a fixed aspect ratio too. Without it the page reflows violently when the image arrives, which on a slow connection reads as a broken layout.

Step 8: Conversational editing with reference images

This is where Nano Banana Pro earns its keep over a plain text-to-image model. It accepts multiple reference images in a single call and can compose them, maintaining subject consistency across edits.

The service already supports this through references. The interesting part is the prompt discipline:

const edited = await generateImage({
  prompt: [
    'Using the product photo as the subject, place it on a polished walnut surface.',
    'Keep the product shape, colour, and label text exactly as in the reference.',
    'Change only the background and lighting: soft window light from the left, shallow depth of field.',
  ].join(' '),
  references: [{ data: productBase64, mimeType: 'image/jpeg' }],
  aspectRatio: '4:3',
  imageSize: '2K',
})

The three-sentence structure — subject, what must not change, what should change — is dramatically more reliable than a single fluent sentence. Image models drift on unconstrained attributes; naming the invariants explicitly is what pins them down.

For multi-turn editing, feed the previous output back as the reference for the next request:

let current = initialResult.image
 
for (const instruction of ['make the lighting cooler', 'add a subtle reflection']) {
  const next = await generateImage({
    prompt: `Apply this change and keep everything else identical: ${instruction}`,
    references: [{ data: current.toString('base64'), mimeType: 'image/png' }],
  })
  if (!next.ok) break
  current = next.image
}

Be aware that quality degrades over long edit chains — each pass is a fresh generation conditioned on the last, so small artefacts compound. In practice, keep chains under about five steps and offer users a "start from the original" escape hatch.

Step 9: Arabic and multilingual text inside images

Most image models produce Arabic that looks like Arabic from across the room and falls apart up close: letters unjoined, diacritics floating, words reversed. Nano Banana Pro is markedly better at this than its predecessors, which makes it genuinely usable for MENA-market creative work — but only if you prompt for it deliberately.

What works:

await generateImage({
  prompt: [
    'A minimal social media banner, deep teal background, geometric Islamic pattern in the corners.',
    'Render this exact Arabic headline, large and centred, in a modern Naskh typeface:',
    '"نقطة للحلول الرقمية"',
    'The text must be fully connected and correctly shaped right-to-left.',
    'No other text anywhere in the image.',
  ].join('\n'),
  aspectRatio: '16:9',
  imageSize: '2K',
})

Four rules make the difference:

  1. Quote the exact string. Asking for "an Arabic headline about digital solutions" gives you plausible-looking nonsense. Give the model the literal characters.
  2. Name the script behaviour. Explicitly requesting connected, right-to-left shaping measurably reduces the disconnected-letterform failure.
  3. Name a typeface family. "Modern Naskh" or "geometric Kufi" anchors the letterforms; without it the model averages across styles and produces something that reads as neither.
  4. Forbid extra text. Image models love to invent supporting copy, and invented Arabic is where the gibberish shows up.

Always verify the rendered text with a human reader before publishing. This applies doubly to Arabic, where a wrongly shaped or mirrored word is invisible to a non-reader reviewing the file. If you are building Arabic-language pipelines more broadly, the normalisation pitfalls in our Arabic RAG pipeline tutorial apply to prompt text too.

Step 10: Provenance, safety, and what you tell clients

Every image produced by Google's image models carries SynthID, an invisible watermark embedded in the pixels. It survives moderate compression, cropping, and colour adjustment, and it is detectable by Google's verification tooling. Gemini can also be asked directly whether an uploaded image carries the watermark.

This matters commercially, not just ethically. If you deliver AI-generated assets to a client, the honest position is that the images are machine-generated and carry a detectable watermark. Discovering this later, from a third party, is a considerably worse conversation.

Two practical steps:

  • Persist the provenance alongside the asset. Store the model ID, prompt, timestamp, and user with every generation row. When somebody asks about an image in eight months, you want a database query, not an archaeology project.
  • Do not strip metadata as a habit. Your WebP conversion in sharp already drops EXIF by default. That is fine for privacy, but it means your database row is the only provenance record you control — treat it accordingly.

On safety: personGeneration in imageConfig controls whether people may be generated, and regional policy varies. Set it explicitly rather than relying on the default, and pair the model's filter with your own prompt-level checks. The layered approach in our AI agent guardrails guide transfers directly: model-side filters catch the obvious cases, application-side rules catch what is merely against your policy.

Testing your implementation

Verify the pieces independently rather than clicking through the UI and hoping.

The service layer. Assert the union, not the happy path:

import { describe, expect, it } from 'vitest'
import { generateImage } from '@/lib/gemini-image'
 
describe('generateImage', () => {
  it('returns an image buffer for a benign prompt', async () => {
    const result = await generateImage({ prompt: 'a red ceramic bowl', imageSize: '1K' })
    expect(result.ok).toBe(true)
    if (result.ok) expect(result.image.length).toBeGreaterThan(1000)
  }, 60_000)
 
  it('reports blocked prompts without throwing', async () => {
    const result = await generateImage({ prompt: PROMPT_KNOWN_TO_BE_BLOCKED })
    expect(result.ok).toBe(false)
  }, 60_000)
})

Keep these tagged as integration tests — they cost real money on every run, so they do not belong in the pull-request pipeline. Run them on a schedule instead.

The route. Mock generateImage and test the parts that are pure logic: does an unauthenticated request get 401 before any model call, does a free-plan session get tier: 'fast', does an oversized reference array get rejected. These are fast, free, and belong in CI. Our Vitest and Testing Library guide covers the mocking setup.

The budget. The check people forget: generate fifty images through the real endpoint, then compare your provider dashboard against what you expected to spend. If the two numbers disagree, find out why before launch rather than after.

Troubleshooting

"The model returned text instead of an image." Almost always a missing responseModalities: ['IMAGE'], or a prompt the model interpreted as a question. Prompts phrased as instructions ("A poster showing...") outperform questions ("Can you make...").

Blank or truncated images at 4K. Usually a timeout, not a model failure. Raise maxDuration, and check whether your host caps response time independently of your framework config.

Aspect ratio ignored. Verify aspectRatio sits inside imageConfig, not at the top level of config. Placed wrongly it is silently dropped, which is a genuinely irritating ten minutes to diagnose.

Reference image rejected. Strip the data:image/png;base64, prefix before sending. inlineData.data wants the raw base64 payload only, and the SDK will not do it for you.

Costs higher than expected. Check three things in order: your cache hit rate, whether imageSize defaults to something larger than you intended, and whether retry logic is silently regenerating on transient errors. The third one is the usual culprit — a naive retry wrapper turns one failed 4K generation into three billed ones.

Arabic text mangled. Re-read Step 9. In stubborn cases, generate the image without text and composite the typography with sharp or an SVG overlay — deterministic text rendering beats a model that is 90% reliable when the client is reading the words.

Next steps

  • Add streaming progress. Long generations feel broken without feedback. A server-sent-events channel that reports queued, generating, and storing states costs little and changes the perceived latency substantially.
  • Queue the work. Once you have more than a handful of concurrent users, move generation off the request path into a background job. Our Trigger.dev v4 tutorial covers the durable-execution pattern this needs.
  • Instrument it. Prompt, model, latency, cost, and outcome per generation, in one trace. Langfuse handles image-model calls the same way it handles text ones.
  • Compare providers. Routing through a gateway lets you swap models without touching application code; see our AI gateway guide.
  • Extend the studio. Batch generation from a CSV of prompts, brand-kit presets that prepend a house style, and a gallery with regeneration history are the three features users ask for first.

Conclusion

The gap between an image generation demo and an image generation product is not model quality — it is everything around the model call. A discriminated return type that treats a safety refusal as data. Server-side tier selection so the client cannot upgrade itself. WebP conversion before storage. A fingerprint cache that stops you paying twice for the same picture. Explicit prompt structure for text rendering, and honest provenance when the asset ships.

Nano Banana Pro is a genuinely strong model, particularly for reference-image composition and multilingual typography — the Arabic case alone opens work that was not practical eighteen months ago. But the model is the cheap part of this system to get right. The expensive part is the plumbing, and now you have it.