●ROBOTICS — The ER 1.6 preview that shut down on August 31 does have a successor. Gemini Robotics ER 2 is in public preview, in both standard and streaming variants●VIDEO — ER 2 judges success and failure from live video rather than still snapshots, which is what lets it catch spills, slips, and misalignments while a task is still running●DEADLINE — Next up is September 30, when gemini-omni-flash-preview is retired. The target is gemini-omni-1.1-flash, GA since August 27, and there are now under four weeks left●APIKEY — Every remaining standard API key, restricted ones included, stops working during September. The replacement is an auth key bound to a Google Cloud service account●PRICE — Gemini 3.7 Flash keeps its introductory $0.75/$3.75 per 1M through December 31, then moves to $1.50/$7.50 on January 1, 2027. Any estimate crossing the year needs both figures●AUDIO — Gemini 3.5 Transcribe handles language detection across 85+ languages, speaker diarization, word-level timestamps, and custom vocabulary biasing of up to 1,000 terms●ROBOTICS — The ER 1.6 preview that shut down on August 31 does have a successor. Gemini Robotics ER 2 is in public preview, in both standard and streaming variants●VIDEO — ER 2 judges success and failure from live video rather than still snapshots, which is what lets it catch spills, slips, and misalignments while a task is still running●DEADLINE — Next up is September 30, when gemini-omni-flash-preview is retired. The target is gemini-omni-1.1-flash, GA since August 27, and there are now under four weeks left●APIKEY — Every remaining standard API key, restricted ones included, stops working during September. The replacement is an auth key bound to a Google Cloud service account●PRICE — Gemini 3.7 Flash keeps its introductory $0.75/$3.75 per 1M through December 31, then moves to $1.50/$7.50 on January 1, 2027. Any estimate crossing the year needs both figures●AUDIO — Gemini 3.5 Transcribe handles language detection across 85+ languages, speaker diarization, word-level timestamps, and custom vocabulary biasing of up to 1,000 terms
Building Serverless AI Apps with Firebase Genkit and Gemini — An Implementation Notebook
A hands-on implementation notebook for building serverless AI apps with Firebase Genkit and Gemini. Flows, agents, and RAG on the current Genkit 1.x API, plus per-flow usage logging that turns spend into an itemized bill, the gaps between the Dev UI and production, and cold-start tuning.
Shipping one Flow, and why the framework's thinness matters
When I first picked up Firebase Genkit, I was wary. Running Dolice Labs as an indie developer, every new abstraction layer is something to learn, and a tool that doesn't earn its keep becomes a liability.
That wariness lifted when I ran a tiny Flow — one that just returns a greeting — in the local Dev UI, and then deployed the exact same code to Cloud Functions. Input and output schemas, local testing, tracing, deployment: all of it sits inside one consistent way of writing. Because the framework is thin, you get to focus on the Gemini call itself.
This is an implementation notebook that walks from that first Flow all the way to RAG and agents, using code I actually wrote and verified. It isn't a line-by-line translation of the docs; it centers on the places an indie developer tends to get stuck. Note that this is a fast-moving area — the examples here assume the 1.x genkit() constructor and zod schemas.
Install and initialize — consolidate in one place
Install Genkit and the Google AI plugin. TypeScript is the natural choice.
Consolidate initialization into a single file and import ai from it everywhere else, so swapping models is a one-line change.
// src/genkit.tsimport { genkit } from "genkit";import { googleAI } from "@genkit-ai/googleai";// Swap the model ID for whichever you want (a current Flash / Pro model)export const ai = genkit({ plugins: [googleAI({ apiKey: process.env.GOOGLE_API_KEY })], model: googleAI.model("gemini-2.5-flash"),});
Never hardcode apiKey; always pass it from the environment. Use .env locally, and the Secret Manager path shown below in production.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Copy-runnable Flow, Tool, and RAG code written against the current Genkit 1.x API (the genkit constructor and zod schemas)
✦A usage-logging wrapper and aggregation script that report cost per 1,000 calls, plus how to cut the input-token tail
✦How to isolate the four things that pass in the Dev UI but break in production, and when a minimum instance is worth paying for
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
A Genkit Flow bundles "input schema, processing, output schema" into one function. Writing the schema in zod lets the Dev UI generate an input form for you, and keeps execution traces typed.
// src/flows/greeting.tsimport { z } from "genkit";import { ai } from "../genkit";export const greetingFlow = ai.defineFlow( { name: "greeting", inputSchema: z.object({ name: z.string(), language: z.enum(["en", "ja"]), }), outputSchema: z.string(), }, async ({ name, language }) => { const prompt = language === "en" ? `Write a short, friendly greeting for ${name}.` : `${name}さんへの、短くて親切な挨拶を日本語で書いてください。`; const { text } = await ai.generate({ prompt }); return text; },);
Verify locally from the Dev UI.
# Start the Dev UI (try flows at http://localhost:4000)npx genkit start -- npx tsx --watch src/index.ts
The important detail here is reading text via destructuring from the return of ai.generate() — not a method call like the response.text() you may see in older samples. In Genkit 1.x the return value is a property, and that difference is the first thing that trips up a copy-and-paste.
Multi-step Flow — fold document analysis into one function
Folding several preprocessing steps and a generation call into a single Flow means the caller only has to pass input. Here we take a document and return a summary, sentiment, or keywords.
// src/flows/documentAnalysis.tsimport { z } from "genkit";import { ai } from "../genkit";export const documentAnalysisFlow = ai.defineFlow( { name: "documentAnalysis", inputSchema: z.object({ documentText: z.string(), analysisType: z.enum(["summary", "sentiment", "keywords"]), }), outputSchema: z.object({ analysisType: z.string(), result: z.string(), }), }, async ({ documentText, analysisType }) => { // Step 1: clamp the input to a safe length const cleaned = documentText.trim().slice(0, 5000); // Step 2: prepare a prompt per analysis type const prompts: Record<string, string> = { summary: `Summarize this document in three sentences:\n${cleaned}`, sentiment: `Rate the sentiment as negative / neutral / positive:\n${cleaned}`, keywords: `List the five most important keywords:\n${cleaned}`, }; // Step 3: generate with Gemini and return a structured result const { text } = await ai.generate({ prompt: prompts[analysisType] }); return { analysisType, result: text }; },);
Clamping the input at 5,000 characters guards against an unexpectedly long body spiking your token cost. Set the limit to match your documents, but keeping it on the outside removes one class of accidents.
Tool Calling and agents — hand tool choice to the model
In Genkit you define a tool with ai.defineTool and simply pass it to generate; the model decides when to call it. You don't build a special class to get an agent.
// src/tools/weather.tsimport { z } from "genkit";import { ai } from "../genkit";export const getWeather = ai.defineTool( { name: "getWeather", description: "Return the current weather for a given city", inputSchema: z.object({ city: z.string() }), outputSchema: z.object({ city: z.string(), temperature: z.number(), condition: z.string(), }), }, async ({ city }) => { // In production, call a real weather API here return { city, temperature: 25, condition: "Sunny" }; },);
// src/flows/assistant.tsimport { z } from "genkit";import { ai } from "../genkit";import { getWeather } from "../tools/weather";export const assistantFlow = ai.defineFlow( { name: "assistant", inputSchema: z.object({ question: z.string() }), outputSchema: z.string(), }, async ({ question }) => { const { text } = await ai.generate({ prompt: question, tools: [getWeather], system: "You are a capable assistant. Use tools only when needed, and answer concisely.", }); return text; },);
The more tools you add, the more room the model has to pick the wrong one at the wrong time. A tool's description is the model's only basis for that decision; vague wording drives up misfires. I've written up how to measure and fix this in instrumenting agent tool misselection.
RAG — connect to Firestore Vector Search
In RAG you turn the query into an embedding, retrieve the nearest documents, and answer grounded only in that context. Genkit handles embeddings through ai.embed.
// src/flows/rag.tsimport { z } from "genkit";import { ai } from "../genkit";import { googleAI } from "@genkit-ai/googleai";export const ragFlow = ai.defineFlow( { name: "documentRAG", inputSchema: z.object({ query: z.string(), topK: z.number().default(3) }), outputSchema: z.string(), }, async ({ query, topK }) => { // 1. Turn the query into an embedding vector const [embedding] = await ai.embed({ embedder: googleAI.embedder("text-embedding-004"), content: query, }); // 2. Retrieve nearest documents via Firestore Vector Search const docs = await searchSimilar(embedding.embedding, topK); // 3. Build a prompt grounded only in the retrieved context const context = docs.map((d) => `- ${d.content}`).join("\n"); const { text } = await ai.generate({ prompt: `Answer concisely, grounded only in the reference material below. If there is no basis, say so.\n\nReference:\n${context}\n\nQuestion: ${query}`, }); return text; },);// The Firestore nearest-neighbor search lives in a separate articleasync function searchSimilar( _embedding: number[], _topK: number,): Promise<Array<{ content: string }>> { return [];}
Deploy to Cloud Functions — wrap it thinly with onCallGenkit
A Flow deploys to Firebase Functions as-is. onCallGenkit keeps the wiring for auth, streaming, and App Check short.
// functions/src/index.tsimport { onCallGenkit } from "firebase-functions/https";import { defineSecret } from "firebase-functions/params";import { greetingFlow } from "./flows/greeting";import { assistantFlow } from "./flows/assistant";// Inject the API key from Secret Managerconst apiKey = defineSecret("GOOGLE_API_KEY");export const greeting = onCallGenkit({ secrets: [apiKey] }, greetingFlow);export const assistant = onCallGenkit({ secrets: [apiKey] }, assistantFlow);
The deployment steps:
# 1. Install the Firebase CLInpm install -g firebase-tools# 2. Register the API key in Secret Manager (keep it out of code)firebase functions:secrets:set GOOGLE_API_KEY# 3. Deploy only Functionsfirebase deploy --only functions# 4. Verifyfirebase functions:list
Receiving the API key through defineSecret is the key point. Inlining an environment variable creates a leak path into your repo or logs. Keeping it in Secret Manager also lets you rotate keys without a code change. For larger production workloads, deploying to Vertex AI Agent Engine is another option.
Cost optimization — model routing and making spend visible
Serverless billing quietly balloons through a single flow if you let it. The first thing I add is routing that sends work to a model based on difficulty.
// src/flows/routed.tsimport { z } from "genkit";import { ai } from "../genkit";import { googleAI } from "@genkit-ai/googleai";const FLASH = googleAI.model("gemini-2.5-flash");const PRO = googleAI.model("gemini-2.5-pro");export const routedFlow = ai.defineFlow( { name: "routed", inputSchema: z.object({ prompt: z.string(), hard: z.boolean() }), outputSchema: z.string(), }, async ({ prompt, hard }) => { // Send only hard tasks to Pro; handle the rest on Flash const { text } = await ai.generate({ model: hard ? PRO : FLASH, prompt }); return text; },);
Alongside that, log call counts and duration per flow. Watching only the grand total hides which flow is driving the bill. On my own setup I was running a simple classification task on Pro for a while; dropping it to Flash roughly halved that flow's model cost. My broader take on staged cost control is in cost guardrails for indie developers.
Stop guessing at cost — record usage per flow and read the itemized bill
The trouble that showed up right after I added routing was that I couldn't tell whether it was working. Billing arrives per project, not per Flow. When the total drops, there's no way to separate "routing paid off" from "that was just a quiet week."
So I started writing out the usage that comes back from ai.generate(), one line per call, tagged with the flow name.
// src/usage.tsimport { appendFileSync } from "node:fs";import { ai } from "./genkit";type GenArgs = Parameters<typeof ai.generate>[0];// Wrap generate with a flow name and leave usage behind as one JSON lineexport async function generateWithUsage(flow: string, args: GenArgs) { const startedAt = Date.now(); const res = await ai.generate(args); const u = res.usage ?? {}; const record = { ts: new Date().toISOString(), flow, model: typeof args.model === "string" ? args.model : args.model?.name ?? "default", inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0, ms: Date.now() - startedAt, }; if (process.env.USAGE_LOG) { appendFileSync(process.env.USAGE_LOG, JSON.stringify(record) + "\n"); } else { // Files don't survive on Cloud Functions, so emit a structured log instead console.log(JSON.stringify({ severity: "INFO", genkitUsage: record })); } return res;}
This is where I tripped once: I kept appending to a file the way I did locally. A Cloud Functions instance takes its filesystem with it when it goes away, so those lines are not a log you can trust. In production I switched to emitting JSON through console.log and picking it up as a log-based metric in Cloud Logging. Locally I set USAGE_LOG, collect the jsonl, and aggregate it with the script below.
Running my four flows on Flash produced the numbers below, calculated with the prices in that script.
Flow
Avg input tokens
Avg output tokens
Per 1,000 calls
vs. greeting
greeting
120
90
$0.26
1.0x
assistant (with tools)
640
210
$0.72
2.7x
documentAnalysis
1,850
320
$1.36
5.2x
documentRAG
2,600
280
$1.48
5.7x
What the table finally showed me is that the tail of the input distribution matters more than the average. documentAnalysis averages 1,850 input tokens, but its p90 is roughly 4,900 — about 2.6x the mean. The top ten percent of calls were producing something like thirty percent of that flow's monthly cost.
The 5,000-character truncation should have prevented that, so why did the tail keep growing? Because the cap only applied to the document that was passed in. The boilerplate around the prompt, and the retrieved context injected on the RAG path, were never counted. I now apply the cap to the assembled prompt as a whole and drop from the end of the retrieved context when it overflows.
Keep the price table in code as a constant. Once it lives in a spreadsheet, it stops getting updated, and you end up making decisions on six-month-old rates.
Cold starts and operation — the unglamorous tuning that paid off
The first wall in serverless was cold starts. The less often a flow is called, the slower its first invocation, and to a user that reads as a heavy app. Here are my own measurements.
Flow
Warm average
Cold-start first call
First call after min instances
greeting
~0.9s
~4.2s
~1.1s
documentAnalysis
~1.6s
~5.0s
~1.7s
documentRAG
~2.3s
~6.1s
~2.4s
The fixes are plain. Set minInstances to 1 on high-traffic paths to keep them warm, and move heavy initialization off module load and outside the request path. There's nothing flashy here, but perceived speed really does come down to this kind of tuning. Since minimum instances mean continuous billing, the indie compromise is to warm only the paths that genuinely need it.
Whether to set a minimum instance comes down to three questions for me.
Is this path part of the user's first interaction? If not, four to six seconds on the first call does little real damage
How far apart are the calls? If the logs show gaps of fifteen minutes or more as the norm, warmth won't survive on its own — nothing improves until you pay for a minimum instance
Does the monthly cost of holding one instance warm balance against that flow's model spend?
Question three flipped a decision for me. The lightweight greeting path costs about $0.26 per 1,000 calls. At a few thousand calls a month, the always-on charge for a minimum instance came out larger than the model spend itself. I dropped the minimum instance there and put a single warm instance on documentRAG instead. Warm every path out of a general desire for speed and the model cost you trimmed simply comes back under a different name.
Four things that passed in the Dev UI and broke in production
The local Dev UI is generous. It reads .env, fills schema defaults into the form, and never makes you think about regions. All of that generosity came due at once after deployment. Here are the four I actually hit.
Symptom
Actual cause
First move
500 on the first call after deploy, auth-related error in the logs
Forgot to pass the secret into onCallGenkit. The Dev UI reads .env, so nothing warns you
Confirm the value exists in Secret Manager, then check the secrets array on every exported Flow
404, or something that looks like a CORS failure, when calling from the client
The function's region and the client SDK's default region don't match
Name the region on the client too (getFunctions(app, "asia-northeast1"))
zod rejects the input because a field is undefined
The Dev UI form was filling in .default(); the real client omits the field entirely
Don't lean on schema defaults — settle them at the top of the Flow body as well
deadline-exceeded, but only on the very first call
Heavy initialization running at module top level
Defer init off the request path; set a minimum instance if the path deserves warming
Three steps have been enough to isolate every one of these.
Pull the logs for that specific function and check whether it fails only on the first call or on every call. That split separates wiring problems from cold-start problems
Failing every time means wiring. Work through secrets, region, and schema in that order — those three explained every case I ran into
Failing only the first time means initialization. Count what runs at module top level and see whether moving it behind the request changes anything
The region mismatch cost me half a day. The error wears a CORS face, so you keep suspecting the frontend. Checking the region in the functions list is a far shorter road.
Wrapping up — start small, and grow with the spend breakdown in hand
Genkit's value is that the framework is thin, letting you focus on the Gemini call. Start by running a single greeting Flow locally and deploying it. Once that one goes through, Tool Calling and RAG are the same way of writing, extended.
As a next step, record from day one the material you need to judge whether each flow warrants minInstances — its per-flow latency and call frequency. It's far easier to grow with that in hand than to add it later.
I hope this gives you a foothold for your own build. Thank you for reading.
Gemini 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.