DEV Community

Connie Leung
Connie Leung

Posted on

Building a Thinking Photo Editor: Migrate to Gemini 3 Pro Image with Angular and Firebase

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
Enter fullscreen mode Exit fullscreen mode

Install firebase-tools globally using npm.

firebase logout
Enter fullscreen mode Exit fullscreen mode
firebase login
Enter fullscreen mode Exit fullscreen mode

Log out of Firebase and re-login to perform proper Firebase authentication.

firebase init
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
setGlobalOptions({maxInstances: 2, region: process.env.GOOGLE_FUNCTION_LOCATION});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
import dotenv from "dotenv";
dotenv.config();
Enter fullscreen mode Exit fullscreen mode

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");
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

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(),
  ]
};
Enter fullscreen mode Exit fullscreen mode

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>"
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
            }
        }
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode
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.');
}
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    };
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
  });
}
Enter fullscreen mode Exit fullscreen mode

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>`
Enter fullscreen mode Exit fullscreen mode
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)
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Note: Google Cloud credits are provided for this project.

Top comments (0)