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.yamlbecomes a defined parameter in your package code.Declarative IAM roles and required APIs. Each role you declare in
extension.yamlbecomes arequiresRole(...)call, and each API becomes arequiresAPI(...)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(...)andafterRedeploy(...). These replace thelifecycleEventsthat you declare inextension.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, andPOSTINSTALL.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(...)andafterRedeploy(...)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
READMEthat explains, at a minimum:The
.envvalues 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:
- In the Cloud Firestore console, create the collection you set as COLLECTION_PATH (users) if it doesn't already exist.
- Create a document named bigquery-mirror-test containing any fields with any values.
- 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`
- 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`
- Delete the
bigquery-mirror-testdocument in Cloud Firestore. It disappears from the latest view, and aDELETEevent 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), notext-<instanceId>-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
.envrather than the installation form, so reruns offirebase deployare non-interactive once.envis complete.