Prepare Firebase Extensions for migration to Cloud Functions

This guide shows you how to migrate your extensions from the deprecated Firebase Extensions environment to a function that your users install and deploy in their own Cloud Functions for Firebase (2nd gen) codebase.

This is the recommended migration path. Firebase will maintain a list of extensions with official npm equivalents; this guide will walk you through creating yours.

Throughout this guide, the Stream Firestore to BigQuery extension (firestore-bigquery-export) is used as an example. Each section ends with a Worked example that shows what that extension looked like before the migration, and what it looks like after, as the @firebase/firestore-bigquery-export package.

Sign up to get more information and help on migrating extensions

If you have questions on how to migrate from Firebase Extensions you can reach us at firebase-extensions-migrator-support-external@google.com. We will also email this group as we update the guide with more information on packaging, testing, and distributing your 2nd gen functions.

To join this group, send a message to firebase-extensions-migrator-support-external+subscribe@google.com, which will respond with a membership request email. You must reply to that email, not click the "Join This Group" button.

Before you begin

To complete this migration, you'll use the following features of Cloud Functions:

  • Parameterized Configuration. Each param that you declare in extension.yaml becomes a defined parameter in your package code.

  • Declarative IAM roles and required APIs. Each role you declare in extension.yaml becomes a requiresRole(...) call, and each API becomes a requiresAPI(...) call in your function code. At deploy time, the Firebase CLI grants the declared roles to a managed runtime service account and enables the declared APIs on your behalf.

  • Lifecycle events for Cloud Functions codebases. Cloud Functions codebases now support lifecycle events analogous to Firebase Extensions. Declare install-time and update-time setup with the lifecycle hooks afterFirstDeploy(...) and afterRedeploy(...). These replace the lifecycleEvents that you declare in extension.yaml.

Inventory the Extension

Start by taking an inventory of your extension: a complete list of everything the extension declares, ships, and documents, so that every behavior has a defined destination in the 2nd gen function and nothing is lost in the migration.

Review each of the following, and note what you find:

  • extension.yaml, which declares your params, functions, events, IAM roles, required APIs, secrets, and lifecycle hooks.

  • functions/, which contains your function code, dependencies, build configuration, triggers, and task queue functions.

  • README.md, PREINSTALL.md, and POSTINSTALL.md, which contain setup steps, warnings, and billing notes.

  • scripts/, which contains any import, backfill, IAM, repair, or migration utilities, and any other tooling that you ship alongside the extension.

Then, for each item in extension.yaml, decide where it goes in the npm package:

  • Convert user configuration into Cloud Functions params (section 5).

  • Convert secrets into Cloud Functions secrets (section 6).

  • Convert IAM roles into requiresRole(...) declarations (section 8).

  • Convert required Google APIs into requiresAPI(...) declarations where appropriate (section 8).

  • Convert install and update hooks into afterFirstDeploy(...) and afterRedeploy(...) declarations (section 8).

Worked example: Stream Firestore to BigQuery

Reading firestore-bigquery-export/extension.yaml and functions/ produces this inventory:

In extension.yaml Count / value Where it goes
params 25 (COLLECTION_PATH, DATASET_ID, TABLE_ID, DATASET_LOCATION, VIEW_TYPE, …) Cloud Functions params (section 5)
apis bigquery.googleapis.com requiresAPI(...) (section 7)
roles bigquery.dataEditor, datastore.user, bigquery.user requiresRole(...) (section 7)
resources 1 event trigger (fsexportbigquery) + task queue functions (initBigQuerySync, setupBigQuerySync) Exported package functions (section 3)
lifecycleEvents onInstall → initBigQuerySync; onUpdate / onConfigure → setupBigQuerySync afterFirstDeploy / afterRedeploy (section 9)
scripts/ import/ (backfill), gen-schema-view/ Kept as scripts (out of scope here)

The extension declares no type: secret params, so there's nothing to migrate in section 6 of this guide. The event trigger is already 2nd gen; only the task queue functions are still 1st gen (relevant in section 3).

Update package.json

Update your extension’s package.json file. If you're migrating one extension, this can be the root package.json. If you're migrating many extensions in one repository, give each extension its own package.

Minimum SDK versions. Declare firebase-functions >= 7.3 and firebase-admin >= 14.2.0 as dependencies. Declare your version of firebase-functions as a peer dependency as well

{
  "name": "<package-name>",
  "version": "1.0.0",
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "exports": {
    ".": {
      "types": "./lib/index.d.ts",
      "default": "./lib/index.js"
    }
  },
  "engines": {
    "node": ">=22"
  },
  "peerDependencies": {
    "firebase-functions": "^7.3.0"
  },
  "dependencies": {
    "firebase-functions": "^7.3.0",
    "firebase-admin": "^14.2.0"
  }
}

Declare firebase-functions as a peer dependency in addition to your normal dependency, so that your users' Cloud Functions project has the same version of the SDK that your library was written with.

Worked example: Stream Firestore to BigQuery

Before. The extension's functions/package.json is private, names the extension ID, and declares firebase-functions as a direct dependency:

{
  "name": "firestore-bigquery-export",
  "main": "lib/index.js",
  "private": true,
  "dependencies": {
    "@firebaseextensions/firestore-bigquery-change-tracker": "^2.0.4",
    "firebase-admin": "^14.2.0",
    "firebase-functions": "^6.3.2"
  }
}

After. A publishable package: scoped name, an exports map, and firebase-functions moved to peerDependencies:

{
  "name": "@firebase/firestore-bigquery-export",
  "version": "0.1.0",
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "exports": {
    ".":     { "types": "./lib/index.d.ts", "default": "./lib/index.js" },  },
  "engines": { "node": ">=22" },
  "peerDependencies": { "firebase-functions": "^7.3.0" },
  "dependencies": {
      "@firebaseextensions/firestore-bigquery-change-tracker": "^2.0.4",
      "firebase-admin": "^14.2.0",
      "firebase-functions": "^7.3.0"
    }
}

Upgrade functions from 1st gen to 2nd gen

If your extension still exports 1st gen functions, convert each function to its 2nd gen equivalent. Import from the firebase-functions/... modules, and pass runtime settings in the function options.

You can minimize rewrite efforts with 2nd gen patched event destructuring and avoid rewriting your function logic because the 2nd gen SDK now exposes the V1 parameters as fields in the event object, allowing you to use destructured/named parameters and keep your business logic unchanged.

Before. 1st gen:

import * as functions from "firebase-functions/v1";

export const sync = functions.firestore
  .document("{collectionId}/{documentId}")
  .onWrite(async (change, context) => {
    await handleWrite(change.before, change.after, context.params);
  });

After. 2nd gen:

import { onDocumentWritten } from "firebase-functions/firestore";

export const syncV2 = onDocumentWritten(
  { document: "{collectionId}/{documentId}" },
  async ({change,context}) =>
    await handleWrite(change.before, change.after, context.params);
);

See the Cloud Functions version comparison for a comprehensive list of differences between 1st gen and 2nd gen functions.

Convert extension params and secrets

Convert params

Each param that you declare in extension.yaml becomes a Cloud Functions param.

Convert direct environment reads:

const collectionPath = process.env.COLLECTION_PATH;

into Cloud Functions params:

import { defineString } from "firebase-functions/params";
import { onDocumentWritten} from "firebase-functions/firestore";

const collectionPath = defineString("COLLECTION_PATH");

// Pass the param directly when used as a placeholder (e.g. trigger path)
export const sync = onDocumentWritten(
  { document: collectionPath },
  async (event) => {
    // Call .value() to read the string inside a handler
    const path = collectionPath.value();
    await handleWrite(path, event);
  }
);

Use collectionPath.value() to read the string inside a handler; use collectionPath directly where a placeholder is expected, such as a function trigger path.

The Firebase CLI discovers your params and reads their values from .env, .env.projectId, or prompts your users during deploy. Keep the same param names, so that values from an existing installation carry over.

It is important that you do not change the param names declared in your code at all. Extension migration will preserve existing end-user param value automatically, but only when the names are unchanged.

Worked example: Stream Firestore to BigQuery Before. A param declared in extension.yaml, read as a raw environment variable in config.ts:

# extension.yaml
-   param: COLLECTION_PATH
  label: Collection path
  type: string
  required: true
// functions/src/config.ts
collectionPath: process.env.COLLECTION_PATH,

After. One defineString; the CLI discovers it and reads from .env:

// src/config.ts
import { defineString } from "firebase-functions/params";

collectionPath: defineString("COLLECTION_PATH", {
  label: "Collection path",
  // We now support "nonEmpty: true" to ensure a value other than the empty string
  // is entered, analagous to "required: true" in extensions.yaml
  input: { text: { nonEmpty: true} }
}),

The param name is unchanged, so an existing .env keeps working.

Convert secrets

In extension.yaml, you declare secrets with type: secret. The Extensions runtime stores and binds them, so your extension code can read process.env.PARAM_NAME directly. In a typical Cloud Functions codebase, you declare and bind each secret explicitly:

import { defineSecret } from "firebase-functions/params";
import { onRequest } from "firebase-functions/https";

const apiKey = defineSecret("API_KEY");
export const fn = onRequest({ secrets: [apiKey] }, handler);

Once your extension is migrated to an npm package/kit, secret references will be managed in the end user’s .env file. It is important that you do not change the secret names declared in your code at all. During migration end-user secrets will be migrated accordingly.

Worked example: Trigger Email From Cloud Firestore

Before. MAIL_COLLECTION and SMTP_PASSWORD read as raw environment variables in config.ts:

# extension.yaml
-   param: MAIL_COLLECTION
  label: Email documents collection
  type: string
  default: mail
  required: true

-   param: SMTP_PASSWORD
  label: SMTP password
  type: secret
// functions/src/config.ts
mailCollection: process.env.MAIL_COLLECTION,
smtpPassword: process.env.SMTP_PASSWORD,

After. One defineString and one defineSecret; the CLI discovers both and reads from .env

import { defineString, defineSecret } from "firebase-functions/params";
import { onDocumentWritten } from "firebase-functions/firestore";

const mailCollection = defineString("MAIL_COLLECTION",{ label: "Email documents collection",
 default: "mail"
});

const smtpPassword = defineSecret("SMTP_PASSWORD", { label: "SMTP password" });

export const processQueue = onDocumentWritten(
  { document: `${mailCollection}/{documentId}`, secrets: [smtpPassword] },
  async (event) => {
    const collection = mailCollection.value();
    const password = smtpPassword.value();
    // ...
  }
);

Migrate internal task-queue calls

Some extensions enqueue work onto their own task queues from inside their function code, using the Firebase Admin SDK. This is different from receiving a dispatched task (covered in the Upgrade functions and Convert lifecycle hooks sections). Here your code is the producer that calls queue.enqueue(...).

Previous versions of the Admin SDK required extensions to pass their own extension instance ID as a second parameter to target a Task Queue function in the same extension. As of `firebase-admin` 14.2.0 this is neither required nor recommended. The Task Queue API will now target task queues in the same context (e.g. extension) by default. It is safe and encouraged to remove this parameter in your code both as an extension and as standalone functions. Removing this parameter ensures portability and forwards compatibility.

Everything else about the enqueue call — the locations/region/functions/name resource path, the task payload, and your retry logic — stays the same.

See /docs/functions/task-functions for more details on enqueueing functions with Cloud Tasks.

Before. 1st gen extension

import { getFunctions } from "firebase-admin/functions";

const queue = getFunctions().taskQueue(
  `locations/${config.location}/functions/syncBigQuery`,
  process.env.EXT_INSTANCE_ID, // extension instance ID, injected by the runtime
);
await queue.enqueue(taskData);

After. 2nd gen extension

import { getFunctions } from "firebase-admin/functions";
import { region } from "firebase-functions/params";

const queue = getFunctions().taskQueue(
  `locations/${region.value()}/functions/syncBigQuery`);
await queue.enqueue(taskData);

If your enqueue call targets a prefixed codebase, the discovered function name is prefixed too (for example, orders-syncBigQuery).

Declare required APIs and IAM roles

Move your extension's IAM and API requirements out of extension.yaml and into code:

import { requiresAPI, requiresRole } from "firebase-functions"

requiresAPI("bigquery.googleapis.com", "Needed to write changelog rows");
requiresRole("roles/bigquery.dataEditor");
requiresRole("roles/bigquery.user");

With declarative security, the Firebase CLI creates or updates a managed runtime service account for the codebase, and grants it the union of all declared roles. Document for your users that all functions in the codebase run with those roles, unless the final API supports a narrower model.

Worked example: Stream Firestore to BigQuery

Before. Declared in extension.yaml; the Extensions runtime enabled the API and granted the roles to a managed account:

apis:
  -   apiName: bigquery.googleapis.com
roles:
  -   role: bigquery.dataEditor
  -   role: datastore.user
  -   role: bigquery.user

After. Declared in code with requiresAPI and requiresRole:

import { requiresAPI, requiresRole } from "firebase-functions/";
requiresAPI("bigquery.googleapis.com",
  "Needed to write changelog rows and views");
requiresRole("roles/biguqery.dataEditor");
requiresRole("roles/datastore.user");
requiresRole("roles/bigquery.user");

Convert lifecycle hooks

If your extension calls getExtensions().runtime(), for example setProcessingState or setFatalError, delete those calls, as they will throw an error if called from a normally deployed 2nd gen function. Lifecycle state is now driven by afterFirstDeploy and afterRedeploy where this state tracking is not used.

Firebase Extensions can run setup when a user installs, updates, or reconfigures an extension. In your npm package, declare equivalent lifecycle actions in code.

For one-time setup:

import { afterFirstDeploy } from "firebase-functions/lifecycle";
import { onTaskDispatched } from "firebase-functions/tasks";

export const runInitialSetup = onTaskDispatched(async (request) => {
  await initializeResources(request.data);
});

afterFirstDeploy({
  task: {
    function: "runInitialSetup",
    body: {}
  }
});

For config or code updates:

import { afterRedeploy } from "firebase-functions/lifecycle";

afterRedeploy({
  task: {
    function: "runInitialSetup",
    body: { reconcile: true }
  }
});

Make your lifecycle actions idempotent. Your users may need to rerun them manually if dispatch or execution fails:

firebase functions:lifecycle:run afterFirstDeploy CODEBASE_NAME
firebase functions:lifecycle:run afterRedeploy CODEBASE_NAME

Worked example: Stream Firestore to BigQuery

Before. lifecycleEvents in extension.yaml, driven by the Extensions runtime:

lifecycleEvents:
  onInstall:
    function: initBigQuerySync
    processingMessage: Configuring BigQuery Sync.
  onUpdate:
    function: setupBigQuerySync
    processingMessage: Configuring BigQuery Sync
  onConfigure:
    function: setupBigQuerySync
    processingMessage: Configuring BigQuery Sync

After. Declared in code; the task provisions BigQuery on first deploy:

import { afterFirstDeploy, afterRedeploy } from "firebase-functions/lifecycle";

afterFirstDeploy({ task: { function: "initBigQuerySync" } });
afterRedeploy({ task: { function: "setupBigQuerySync" } });

Provisioning is idempotent, so a rerun reconciles the dataset, table, and views. Users can rerun manually with firebase functions:lifecycle:run afterFirstDeploy CODEBASE_NAME.

Document setup for your users

  • Write a package README that explains, at a minimum:

  • The .env values that the package requires.

  • The secrets that the package requires, and how to migrate existing secret values.

  • The IAM roles that the package declares with requiresRole(...).

  • The Google APIs that the package enables or requires.

  • The lifecycle hooks that the package declares, and how to rerun them manually.

  • Billing notes.

  • What changed compared to the original extension.

Worked example: Stream Firestore to BigQuery

The package README ships a concrete "what changed" table:

Concern As the extension As @firebase/firestore-bigquery-export
Config Extension params Functions params via .env
IAM Granted by Extensions requiresRole(...), applied at deploy
Provisioning Lifecycle task by Extensions afterFirstDeploy / afterRedeploy task
Function names ext-instanceId-fsexportbigquery fsexportbigquery (optionally prefixed)

Test your 2nd gen function

You should now have a 2nd gen function that, when deployed, will behave identically to a new installation of your extension. The last step is to verify and fix any issues accidentally introduced along the way.

Make sure you are using firebase-tools >= 15.24.0, and deploy your converted 2nd gen function into a test project with the appropriate resources to test its behavior. If you’ve already got a test project setup from testing your extension, use the command:

firebase deploy --only functions

After entering this command, fill out the resulting wizard prompting your for parameter values the same way you would have filled out the installation form in the Firebase console for the extension.

Worked example: Stream Firestore to BigQuery

We verify the Cloud Firestore to BigQuery sync end-to-end:

  1. In the Cloud Firestore console, create the collection you set as COLLECTION_PATH (users) if it doesn't already exist.
  2. Create a document named bigquery-mirror-test containing any fields with any values.
  3. In the BigQuery console, query the raw changelog table. It should contain a single row logging the document creation:
SELECT * FROM `PROJECT_ID.analytics.users_raw_changelog`
  1. Query the latest view, which should return the latest change event for the only document present: bigquery-mirror-test
SELECT * FROM `PROJECT_ID.analytics.users_raw_latest`
  1. Delete the bigquery-mirror-test document in Cloud Firestore. It disappears from the latest view, and a DELETE event is appended to the raw changelog table.

You can inspect the full history of a single document with:

SELECT *
   FROM `PROJECT_ID.analytics.users_raw_changelog`
   WHERE document_name = "bigquery-mirror-test"
   ORDER BY timestamp ASC

Differences from testing the extension:

  • The trigger deploys as fsexportbigquery (optionally codebase-prefixed), not ext-&lt;instanceId&gt;-fsexportbigquery. Look for that name in the Cloud Functions dashboard and logs.
  • Your code will now run in the Firebase Local Emulator Suite as normal functions. You can set the value of parameters to use in the emulator with .env.local. You can also unit test your code using the firebase-functions-test SDK as described in Unit testing of Cloud Functions
  • Provisioning is no longer driven by the Extensions runtime. If the changelog table is missing after deploy, rerun the setup task manually: firebase functions:lifecycle:run afterFirstDeploy CODEBASE_NAME. The task is idempotent, so rerunning it reconciles the dataset, table, and views.
  • Param values come from .env rather than the installation form, so reruns of firebase deploy are non-interactive once .env is complete.