This technical deep dive explains the migration of a photo editing application from Gemini 2.5 Flash Image model (a.k.a Nano Banana) to Gemini 3 Pro Image (a.k.a Nano Banana Pro). The upgrade focuses on a secure architecture using Firebase and unlocks advanced new capabilities of Gemini 3 Pro Image, for example, thinking process and the built-in Google Search Tool for grounding.
I migrated from Gemini 2.5 Flash Image to Gemini 3 Pro Image because the new model renders images better, is able to display real-time data, supports multiple languages, and renders Traditional Chinese characters correctly.
This guide demonstrates how the Firebase services simplify bootstrapping, handle Firebase app initialization, and improve the maintainability of the Angular application.
1. Prerequisites
- Angular 21
- TailwindCSS
- Node 24
- Gemini 3 Pro Image
- Firebase AI Logic
- Firebase Cloud Functions
- Firebase Remote Config
- Firebase Local Emulator Suite
npm i -g firebase-tools
Install firebase-tools globally using npm.
firebase logout
firebase login
Log out of Firebase and re-login to perform proper Firebase authentication.
firebase init
Execute firebase init and follow the screens to set up Firebase Cloud Function, Firebase Local Emulator Suite and Firebase Remote Config.
If you have an existing project or multiple projects, you can specify the project ID on the command line.
firebase init --project <PROJECT_ID>
After completing the step-by-step, the Firebase tools will generate function and remote config templates, and configuration files such as .firebaserc and firebase.json.
2. Load Model Configurations and Parameters from Firebase Remote Config
The Firebase Client Remote Config is used to store model metadata dynamically. When values update in the remote config, the application does not require redeployment. It receives new values after a fetch interval (in milliseconds) that can also be configured.
The Remote Config UI offers options to download default values and the current config file in JSON format. I save the JSON files in firebase-project/functions/remoteconfig.defaults.json and firebase-project/functions/remoteconfig.template.json respectively.
This is the content of the remoteconfig.defaults.json file:
{
"thinkingBudget": "512",
"geminiImageModelName": "gemini-3-pro-image-preview",
"includeThoughts": "true",
"vertexAILocation": "global"
}
thinkingBudget is represented as a numeric string in the JSON file, so Firebase Remote Config provides an asNumber() method to convert a string to a number. Similarly, it offers an asBoolean() method to convert the "true" string to a boolean true.
I made the switch because I do not want to hardcode these values in the source files. If I have to downgrade to Gemini 2.5 Flash Image due to a 429 rate limit error, I will make the changes in the Remote Config UI.
In app.bootstrap.ts, I import remoteconfig.defaults.json, assign the in-app default values to remoteConfig.defaultConfig, and invoke fetchAndActivate to activate the values.
import remoteConfigDefaults from '@/firebase-project/remoteconfig.defaults.json';
import {
fetchAndActivate,
getRemoteConfig,
getValue,
RemoteConfig
} from 'firebase/remote-config';
async function fetchRemoteConfig(firebaseApp: FirebaseApp) {
const remoteConfig = getRemoteConfig(firebaseApp);
remoteConfig.settings.minimumFetchIntervalMillis = 3600000;
remoteConfig.defaultConfig = remoteConfigDefaults;
await fetchAndActivate(remoteConfig);
return remoteConfig;
}
remoteConfigDefaults is a simple JSON object that is assigned to the remoteConfig.defaultConfig property.
3. Build Firebase Config and Public reCAPTCHA Site Key using Firebase Cloud Functions
Another big change is to create a Firebase Cloud function to construct and return the Firebase Config and reCAPTCHA Site Key. The cloud function replaces the need to bundle environment variables in the client build process. I can remove .env and .env.example, and port the logic of the cli.js script to the function.
The Firebase API Key and reCAPTCHA Site Key are public identifiers that are safe to expose in the client-side code. However, they are tied to my Firebase project and could potentially be misused for quota exhaustion or denial-of-service if not protected by platform-specific restrictions.
For enhanced security, I perform HTTP referrer restriction, Origin check, and CORS in the function. Referrer and CORS are the second layer of defense while App Check is the primary defense against abuse. The referrer restriction prevents unauthorized web clients from easily using my quota, but does not strictly secure the keys from a determined attacker.
The function getFirebaseConfig reads variables from the server environment, constructs the Firebase configuration object, and includes the reCAPTCHA site key for App Check.
APP_API_KEY=<Firebase API Key>
APP_MESSAGING_SENDER_ID=<Firebase Messaging Sender ID>
APP_ID=<Firebase App ID>
RECAPTCHA_ENTERPRISE_SITE_KEY=<Google reCaptcha Enterprise site key>
GOOGLE_FUNCTION_LOCATION="asia-east2"
WHITELIST="http://localhost:4200"
setGlobalOptions({maxInstances: 2, region: process.env.GOOGLE_FUNCTION_LOCATION});
All functions should be deployed to the asia-east2 region. I chose asia-east2 because this is where I live.
export function validate(value: string | undefined, fieldName: string, response?: express.Response) {
const err = `${fieldName} is missing.`;
if (!value) {
logger.error(err);
response.status(500).send(err);
}
return value;
}
When the value is undefined, a 500 error status is set and an error message is written to the response. Otherwise, the value is returned. When the first failure is detected, further validation is halted.
import logger from "firebase-functions/logger";
import express from "express";
import {validate} from "./validate";
function validateFirebaseConfigFields(env: NodeJS.ProcessEnv, response: express.Response) {
const apiKey = validate(env.APP_API_KEY, "API Key", response);
if (!apiKey) {
return undefined;
}
const appId = validate(env.APP_ID, "App Id", response);
if (!appId) {
return undefined;
}
const messagingSenderId = validate(env.APP_MESSAGING_SENDER_ID, "Messaging Sender ID", response);
if (!messagingSenderId) {
return undefined;
}
const recaptchaSiteKey = validate(env.RECAPTCHA_ENTERPRISE_SITE_KEY, "Recaptcha site key", response);
if (!recaptchaSiteKey) {
return undefined;
}
const strFirebaseConfig = validate(env.FIREBASE_CONFIG, "Firebase config", response);
if (!strFirebaseConfig) {
return undefined;
}
const firebaseConfig = JSON.parse(strFirebaseConfig);
const projectId = validate(firebaseConfig?.projectId, "Project ID", response);
if (!projectId) {
return undefined;
}
const storageBucket = validate(firebaseConfig?.storageBucket, "Storage Bucket", response);
if (!storageBucket) {
return undefined;
}
return {
apiKey,
appId,
recaptchaSiteKey,
projectId,
storageBucket,
messagingSenderId,
};
}
validateFirebaseConfigFields uses validate to validate the environment variables. When the end of the function is reached, Firebase API Key, app ID, reCAPTCHA site key, storage bucket, Firebase project id, and messaging sender id are returned.
process.env.FIREBASE_CONFIG is provided by Firebase to the cloud function. It provides the project ID and the storage bucket that are required to construct the Firebase Config. Therefore, it is not found in the .env file.
export const getFirebaseConfigFunction = (response: express.Response) => {
process.loadEnvFile();
const variables = validateFirebaseConfigFields(process.env, response);
if (!variables) {
return;
}
const {recaptchaSiteKey, ...rest} = variables;
const app = {
...rest,
authDomain: `${rest.projectId}.firebaseapp.com`,
};
const config = JSON.stringify({
app,
recaptchaSiteKey,
});
response.set("Cache-Control", "public, max-age=3600, s-maxage=3600");
response.send(config);
};
I am using Node 24, so it provides a built-in process.loadEnvFile() function to load the environment variables from the .env file. If you are using an older Node version (not 20.12+), please install dotenv to load the environment variables from the .env file.
npm i --save-exact dotenv
import dotenv from "dotenv";
dotenv.config();
The getFirebaseConfigFunction invokes validateFirebaseConfigFields to validate the environment variables, returns the reCAPTCHA site key (recaptchaSiteKey) and the Firebase app object (rest). These values do not change frequently; therefore, the response is cached for an hour.
Finally, the Firebase app object and the reCAPTCHA site key are written to the Express response object.
import {onRequest} from "firebase-functions/https";
process.loadEnvFile();
const cors = process.env.WHITELIST ? process.env.WHITELIST.split(",") : true;
const whitelist = process.env.WHITELIST?.split(",") || [];
export const getFirebaseConfig = onRequest( {cors},
(request, response) => {
if (request.method !== "GET") {
response.status(405).send("Method Not Allowed");
return;
}
try {
const referer = request.header("referer");
const origin = request.header("origin");
if (!referer) {
response.status(401).send("Unauthorized. Invalid referrer.");
return;
}
if (!whitelist.includes(referer)) {
response.status(401).send("Unauthorized. Invalid referrer.");
return;
}
if (!origin) {
response.status(401).send("Unauthorized. Invalid origin.");
return;
}
if (!whitelist.includes(origin)) {
response.status(401).send("Unauthorized. Invalid origin.");
return;
}
getFirebaseConfigFunction(response);
} catch (err) {
console.error(err);
response.status(401).send("Unauthorized");
}
}
);
The getFirebaseConfig is the endpoint that is exposed to the Angular application. When the URL is not a GET or the referer is not http://localhost:4200, an error is thrown. Otherwise, the endpoint returns the result.
4. Angular App Initialization
To ensure the application is fully configured before it starts, the bootstrapFirebase function is provided via Angular's provideAppInitializer function.
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideAppInitializer(async () => bootstrapFirebase()),
provideFirebase(),
]
};
The config.json file has an appUrl that is the base URL of the cloud function.
{
"appUrl": "http://127.0.0.1:5001/<project ID>/<region>"
}
I start the Firebase Local Emulator Suite and the Functions Emulator runs at http://127.0.0.1:4000/functions. Moreover, the function is initialized at http://127.0.0.1:5001/<project ID>/<region>/getFirebaseConfig.
import { lastValueFrom } from 'rxjs';
import config from '../../public/config.json';
async function loadFirebaseConfig() {
const httpService = inject(HttpClient);
const firebaseConfig$ = httpService.get<FirebaseConfigResponse>(`${config.appUrl}/getFirebaseConfig`);
const firebaseConfig = await lastValueFrom(firebaseConfig$);
return firebaseConfig;
}
The loadFirebaseConfig calls the getFirebaseConfig endpoint to retrieve the Firebase Config and the reCAPTCHA site key.
import { Injectable } from '@angular/core';
import { FirebaseApp } from 'firebase/app';
import { RemoteConfig } from 'firebase/remote-config';
@Injectable({
providedIn: 'root'
})
export class ConfigService {
remoteConfig: RemoteConfig | undefined = undefined;
firebaseApp: FirebaseApp | undefined = undefined;
loadConfig(firebaseApp: FirebaseApp, remoteConfig: RemoteConfig) {
this.firebaseApp = firebaseApp;
this.remoteConfig = remoteConfig;
}
}
ConfigService is a simple Angular service that keeps the Firebase App and RemoteConfig in memory.
export async function bootstrapFirebase() {
try {
const configService = inject(ConfigService);
const { app, recaptchaSiteKey } = await loadFirebaseConfig();
const firebaseApp = initializeApp(app);
const remoteConfig = await fetchRemoteConfig(firebaseApp);
const provider = new ReCaptchaEnterpriseProvider(recaptchaSiteKey);
initializeAppCheck(firebaseApp, {
provider,
isTokenAutoRefreshEnabled: true,
});
configService.loadConfig(firebaseApp, remoteConfig);
} catch (err) {
console.error('Remote Config fetch failed', err);
throw err;
}
}
bootstrapFirebase fetches the Firebase config, reCAPTCHA site key, initializes the Firebase App, and activates the Remote Config values. Moreover, it injects ConfigService and uses this service to store firebaseApp and remoteConfig. These objects are later reused in the provideFirebase factory function.
5. Providing Gemini 3 Pro (Angular Provider Logic)
export function provideFirebase() {
return makeEnvironmentProviders([
{
provide: GEMINI_IMAGE_MODEL,
useFactory: () => {
const configService = inject(ConfigService);
if (!configService.remoteConfig) {
throw new Error('Remote config does not exist.');
}
if (!configService.firebaseApp) {
throw new Error('Firebase App does not exist');
}
return getGenerativeAIModel(configService);
}
}
]);
}
GEMINI_IMAGE_MODEL is a custom injection token that injects the Gemini 3 Pro Image model. provideFirebase is a factory function that injects an instance of ConfigService and passes it to getGenerativeAIModel to return the image editing model.
getGenerativeAIModel retrieves dynamic values from Remote Config.
function getGenerativeAIModel(firebaseApp: FirebaseApp, remoteConfig: RemoteConfig) {
const modelName = getValue(remoteConfig, 'geminiImageModelName').asString();
const vertexAILocation = getValue(remoteConfig, 'vertexAILocation').asString();
const includeThoughts = getValue(remoteConfig, 'includeThoughts').asBoolean();
const thinkingBudget = getValue(remoteConfig, 'thinkingBudget').asNumber();
const modelParams: ModelParams = {
model: modelName,
generationConfig: {
thinkingConfig: {
includeThoughts,
thinkingBudget,
},
},
tools: [
{ googleSearch: {} }
],
};
const ai = getVertexAI(firebaseApp, { location: vertexAILocation });
return getGenerativeModel(ai, modelParams);
}
The modelParams variable defines the model, the thinking config and the Google Search tool for grounding. Next, I create a VertexAI backend for the Firebase AI Logic. Both the model parameters and the AI backend are passed to getGenerativeModel to construct and return the AI model.
6. Angular Service Logic
The FirebaseService handles the communication with the model.
@Injectable({
providedIn: 'root'
})
export class FirebaseService {
private readonly geminiModel = inject(GEMINI_IMAGE_MODEL);
async generateImage(prompt: string, imageFiles: File[]) {
try {
// Logic to resolve images and construct parts
const imageParts = await resolveImageParts(imageFiles);
return await getBase64Images(this.geminiModel, [prompt, ...imageParts]);
} catch (err) {
console.error("Error in generating the image.");
throw err;
}
}
}
The getBase64Images function generates a response, extracts the image, thought summary, token usage and grounding metadata.
export function getBase64EncodedString({mimeType, data}: GenerativeContentBlob) {
return `data:${mimeType};base64,${data}`;
}
async function getBase64Images(model: GenerativeModel, parts: Array<string | Part>): Promise<ImageTokenUsage> {
const result = await model.generateContent(parts);
const response = result.response;
const tokenUsage = getTokenUsage(response.usageMetadata);
const inlineDataParts = response.inlineDataParts();
const thoughtSummary = response.thoughtSummary() || '';
const citations = constructCitations(response.candidates?.[0]?.groundingMetadata);
if (inlineDataParts?.length) {
const images = inlineDataParts.map(({inlineData}, index) => {
const { data, mimeType } = inlineData;
return {
id: index,
mimeType,
data,
inlineData: getBase64EncodedString(inlineData)
};
});
if (images.length <= 0) {
throw new Error('Error in generating the image.');
}
return {
image: images[0],
tokenUsage,
thoughtSummary,
metadata: citations,
};
}
throw new Error('Error in generating the image.');
}
We can obtain input, output, thought and total tokens from response.usageMetadata. The grounding metadata helps construct the inline citations, web search queries and Google Search suggestions.
7. Post-Processing Token Usage
export function getTokenUsage(usageMetadata?: UsageMetadata): TokenUsage {
const totalTokenCount = usageMetadata?.totalTokenCount || 0;
const promptTokenCount = usageMetadata?.promptTokenCount || 0;
const outputTokenCount = usageMetadata?.candidatesTokenCount || 0;
const thoughtTokenCount = usageMetadata?.thoughtsTokenCount || 0;
return {
totalTokenCount,
promptTokenCount,
outputTokenCount,
thoughtTokenCount,
}
}
getTokenUsage is a function that returns the input, output, thought and total tokens, or their default values.
When the token count is undefined, it is defaulted to zero.
Thinking is enabled by default in Gemini 3; therefore, thoughtTokenCount is always non-zero. In older models such as Gemini 2.5 Flash Image, the thoughtTokenCount could be undefined and the function defaults it to zero.
8. Construct Citations from Grounding
export function constructCitations(groundingMetadata?: GroundingMetadata): Metadata {
const supports = groundingMetadata?.groundingSupports || [];
const citations: WebGroundingChunk[] = [];
for (const support of supports) {
const indices = support.groundingChunkIndices || [];
for (const index of indices) {
const chunk = groundingMetadata?.groundingChunks?.[index];
if (chunk?.web) {
citations.push(chunk?.web);
}
}
}
const renderedContent = groundingMetadata?.searchEntryPoint?.renderedContent || '';
const searchQueries = (groundingMetadata?.webSearchQueries || [])
.filter((queries) => !!queries);
return {
citations,
renderedContent,
searchQueries
};
}
The constructCitations function uses the grounding supports and grounding chunk indices to build the inline citations. citations is a list of sources in URL format. searchQueries is a list of non-empty web search queries to look up information in Google Search. Finally renderedContent is an HTML string for rendering Google Search suggestions.
9. Angular Component: Thought Summary and Token Metrics
After the data is properly prepared, it can be rendered in different Angular components.
@let tokenStat = tokenUsage();
<h3>Token Usage</h3>
<div class="w-full">
<div>
<span>Input tokens</span>
<span>Output tokens</span>
<span>Thought tokens</span>
<span>Total tokens</span>
</div>
<div>
<span>{{ tokenStat.promptTokenCount }}</span>
<span>{{ tokenStat.outputTokenCount }}</span>
<span>{{ tokenStat.thoughtTokenCount }}</span>
<span>{{ tokenStat.totalTokenCount }}</span>
</div>
</div>
import { TokenUsage } from '@/ai/types/token-usage.type';
import { Component, input } from '@angular/core';
@Component({
selector: 'app-token-usage',
})
export class TokenUsageComponent {
tokenUsage = input<TokenUsage| undefined>(undefined);
}
TokenUsageComponent renders Total Tokens, Input Tokens, Output Tokens, and the newly introduced Thought Tokens.
<div class="w-full mt-6">
@let citations = groundingMetadata()?.citations;
@let numCitations = citations?.length || 0;
@if (numCitations > 0) {
<h3>Inline Citations</h3>
<ol>
@for (citation of citations; track citation.title) {
<li><a [href]="citation.uri" target="blank">{{ citation.title }}</a></li>
}
</ol>
}
@let searchQueries = groundingMetadata()?.searchQueries;
@if (searchQueries && searchQueries.length > 0) {
<h3>Search Queries</h3>
<ol>
@for (query of groundingMetadata()?.searchQueries; track query) {
@if (query) {
<li><span>{{ query }}</span></li>
}
}
</ol>
}
@if (safeRenderedContents().length > 0) {
<h3>Google Search Suggestions</h3>
@for (renderedContent of safeRenderedContents(); track renderedContent) {
<div [innerHTML]="renderedContent"></div>
}
}
</div>
When the model uses Google Search, GroundingComponent optionally displays the citations, the web search queries, and the Google Search suggestions (renderedContents) provided in the metadata.
import { MetadataGroup } from '@/ai/types/grounding-metadata.type';
import { Component, computed, inject, input } from '@angular/core';
@Component({
selector: 'app-grounding',
templateUrl: './grounding.component.html',
})
export class GroundingComponent {
groundingMetadata = input<MetadataGroup | undefined>(undefined);
sanitizer = inject(DomSanitizer);
safeRenderedContents = computed(() => {
const unsafeContents = this.groundingMetadata()?.renderedContents || [];
return unsafeContents.map((unsafeContent) => this.sanitizer.bypassSecurityTrustHtml(unsafeContent));
});
}
Finally, ThoughtSummaryComponent displays the thinking process of the AI model where it analyzes the user prompt and generates the image output.
<div>
<app-token-usage [tokenUsage]="tokenUsage()" />
<app-grounding [groundingMetadata]="groundingMetadata()" />
@let thoughts = htmlThoughts();
@if (thoughts && thoughts.length > 0) {
<div>
<h3>Thought Summary</h3>
@let numThoughts = thoughts.length;
@if (numThoughts > 1) {
<label for="thoughts">
Choose the thought summary:
</label>
<select id="thoughts" name="thoughts" [(ngModel)]="thoughtSummaryIndex" >
@for (item of thoughtSummaryIndexList(); track item) {
<option [ngValue]="item">{{ item + 1 }}</option>
}
</select>
}
<div>
<p [innerHTML]="thoughts[thoughtSummaryIndex()]"></p>
</div>
</div>
}
</div>`
import { MetadataGroup } from '@/ai/types/grounding-metadata.type';
import { TokenUsage } from '@/ai/types/token-usage.type';
import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { marked } from 'marked';
import { GroundingComponent } from '../grounding/grounding.component';
import { TokenUsageComponent } from './token-usage/token-usage.component';
export class ThoughtSummaryComponent {
thoughtSummaries = input<string[]>([]);
tokenUsage = input<TokenUsage | undefined>(undefined);
groundingMetadata = input<MetadataGroup | undefined>(undefined);
thoughtSummaryIndex = signal(0);
thoughtSummaryIndexList = computed(() => Array.from({ length: this.thoughtSummaries().length }, (_, index) => index));
htmlThoughts = computed(() =>
this.thoughtSummaries().map((thoughtSummary) => marked(thoughtSummary.replace('\n\n', '<br />')) as string)
);
}
ThoughtSummaryComponent is a child component of a custom image viewer component, and the image viewer component is imported into the AppComponent. When the image viewer component is displayed, ThoughtSummaryComponent also displays the token usage, grounding, and the thinking process.
Conclusion
This concludes the journey of migrating my Photo Edit App from Gemini 2.5 Flash Image to Gemini 3 Pro Image and Firebase.
After the migration, the Angular application is free of the in-app configuration values. The dynamic parameters can be updated in Firebase without application redeployment, only application restart.
You may argue about the latency when retrieving the Firebase configuration from the Cloud function, but the speed should be negligible given the fast network connection in most regions today.
Resources
- Generate image with Firebase AI Logic
- Firebase Cloud Function via HTTP Request
- Firebase Client Remote Config
- Upgraded Photo Edit App to Gemini 3 Pro Image (a.k.a Nano Banana Pro YouTube video)
Note: Google Cloud credits are provided for this project.
Top comments (0)