Migrate Firebase Extensions 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 walks you through creating yours.

Throughout this guide, the Stream Cloud Firestore to BigQuery extension (firestore-bigquery-export) is used as a running 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-function-kits/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 as written, 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 package 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.

Migrate Firebase Extensions source to a 2nd gen function

(Optional) Automated migration with the Firebase Agent Skill

You can automate Steps 1 through 8 (inventorying resources, trigger upgrades, param and secret conversions, declarative IAM, lifecycle hooks, and generating the package README) using the official extension-to-functions-codebase AI agent skill.

Install the skill

If you or your AI coding assistant (Gemini in Firebase, Cursor, Claude Code, GitHub Copilot) haven't installed the skill yet, run the following command using the skills CLI:

npx skills add firebase/agent-skills --skill extension-to-functions-codebase

Once the skill is installed in your project, your AI coding assistant automatically follows its migration rules and transformation steps. You can use the following prompt:

"Please migrate this Firebase Extension into a publishable 2nd Gen Function Kit package following the instructions in the extension-to-functions-codebase skill."

1. 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 (Step 4).

  • Convert secrets into Cloud Functions secrets (Step 4).

  • Convert IAM roles into requiresRole(...) declarations (Step 6).

  • Convert required Google APIs into requiresAPI(...) declarations where appropriate (Step 6).

  • Convert install and update hooks into afterFirstDeploy(...) and afterRedeploy(...) declarations (Step 7).

  • Convert instance IDs from EXT_INSTANCE_ID to FIREBASE_KIT_INSTANCE_ID (Step 4).

Worked example: Stream Cloud 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 (Step 4)
apis bigquery.googleapis.com requiresAPI(...) (Step 6)
roles bigquery.dataEditor, datastore.user, bigquery.user requiresRole(...) (Step 6)
resources 1 event trigger (fsexportbigquery) + task queue functions (initBigQuerySync, setupBigQuerySync) Exported package functions (Step 3)
lifecycleEvents onInstall → initBigQuerySync; onUpdate / onConfigure → setupBigQuerySync afterFirstDeploy / afterRedeploy (Step 7)
Instance ID Not used (no EXT_INSTANCE_ID reads) Nothing to migrate
scripts/ import/ (backfill), gen-schema-view/ Kept as scripts (out of scope here)

Analysis. The extension declares no type: secret params, so there's nothing to migrate for secrets in Step 4. The event trigger is already 2nd gen; only the task queue functions are still 1st gen (relevant in Step 3).

2. 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.4.0 and firebase-admin >= 14.2.0 as dependencies. Declare your version of firebase-functions as a peer dependency as well, so that your users' Cloud Functions project has the same version of the SDK that your library was written with.

{
  "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.4.0"
  },
  "dependencies": {
    "firebase-functions": "^7.4.0",
    "firebase-admin": "^14.2.0"
  }
}

Worked example: Stream Cloud 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-function-kits/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.4.0"
  },
  "dependencies": {
    "@firebaseextensions/firestore-bigquery-change-tracker": "^2.0.4",
    "firebase-admin": "^14.2.0",
    "firebase-functions": "^7.4.0"
  }
}

3. Upgrade functions from 1st gen to 2nd gen

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

See the Cloud Functions 2nd gen upgrade guide. Notably, you can minimize rewrite efforts by using 2nd gen patched event destructuring and avoid rewriting your function logic because the 2nd gen SDK 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.

4. Convert extension params and secrets

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 preserves existing end-user param values automatically, but only when the names are unchanged.

Worked example: Stream Cloud 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, analogous to "required: true" in extension.yaml
  input: { text: { nonEmpty: true } }
}),

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

Instance ID

Extensions read their instance ID from EXT_INSTANCE_ID, injected by the Extensions runtime. Function kits read their instance ID from FIREBASE_KIT_INSTANCE_ID, which the Firebase CLI sets for each kit instance to the instance's key in the instances map in firebase.json. The CLI provides it during deploy-time discovery, in the emulator, and to the deployed functions.

The instance ID isn't a param, so don't declare it with defineString. In fact, FIREBASE_... is a reserved prefix in .env files, so users won't be able to set it or override it there. The values the CLI injects are not visible to the params system. Read it directly from the environment:

// Before
const instanceId = process.env.EXT_INSTANCE_ID;

// After
const instanceId = process.env.FIREBASE_KIT_INSTANCE_ID;

The variable will only be set when your package is deployed as a kit. If your code is also deployed as a standalone codebase (see Step 9), either treat it as optional or fail fast with a clear message when it's missing. If your extension exposed the instance ID as a user-facing param, you must remove that param, as the Firebase CLI owns the value now.

Worked example: Delete User Data

(The Stream Cloud Firestore to BigQuery extension doesn't read its instance ID, so there is nothing to migrate there. The Delete User Data extension uses it to name its Pub/Sub topics.)

Before. Read as a raw environment variable in config.ts with the ext- prefix that Extensions used for its resources:

// functions/src/config.ts
discoveryTopic: `ext-${process.env.EXT_INSTANCE_ID}-discovery`,
deletionTopic: `ext-${process.env.EXT_INSTANCE_ID}-deletion`,

After. A plain process.env read of FIREBASE_KIT_INSTANCE_ID used for two ordinary params so that users can override the topic names:

// src/config.ts
const instanceId = process.env.FIREBASE_KIT_INSTANCE_ID;

// Non-empty defaults so Pub/Sub trigger bindings resolve during deploy
// discovery without freezing an empty topic name into the manifest.
discoveryTopicName: defineString("DISCOVERY_TOPIC_NAME", {
  default: `kit-${instanceId}-discovery`,
}),
deletionTopicName: defineString("DELETION_TOPIC_NAME", {
  default: `kit-${instanceId}-deletion`,
}),

The defaults must be non-empty because trigger bindings get resolved at discovery time. An empty default would be written into the deploy manifest as the topic name. The kit is also defensive against running outside of the context of a kit. If the variable is missing, the module-level default would evaluate to kit-undefined-discovery, so the config loader fails with an explanatory error instead:

// ...
const instanceId = process.env.FIREBASE_KIT_INSTANCE_ID;
if (!instanceId) {
  throw new Error(
    "FIREBASE_KIT_INSTANCE_ID is not set. It is provided automatically to " +
      "kit instances by firebase-tools >= 15.32.0; deploy or emulate this " +
      "kit with a supported CLI version."
  );
}
// ...

This check runs when a handler first resolves its config, so a missing variable produces a clear runtime error rather than functions silently bound to kit-undefined-* topics. To reject the deploy itself, perform the check at the module scope so it runs during discovery. Because the CLI derives the instance ID from firebase.json, there is no configurable INSTANCE_ID, and nothing to keep in sync across multiple instances.

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 are 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 are 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();
    // ...
  }
);

5. 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 now targets task queues in the same context (for example, an extension or kit) 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 Enqueue functions with Cloud Tasks 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";

const queue = getFunctions().taskQueue(
  `locations/${process.env.FUNCTION_REGION}/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); see Review and install the replacement function kit instance and Test as a function kit.

6. 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 Cloud 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/bigquery.dataEditor");
requiresRole("roles/datastore.user");
requiresRole("roles/bigquery.user");

7. Convert lifecycle hooks

If your extension calls getExtensions().runtime() (for example, setProcessingState or setFatalError), delete those calls, as they 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 Cloud 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.

8. 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.
  • How the package obtains its instance ID (FIREBASE_KIT_INSTANCE_ID set by the CLI) and that all the instance's functions are deployed with a kit-<instanceId>- prefix.

Worked example: Stream Cloud Firestore to BigQuery

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

Concern As the extension As @firebase-function-kits/firestore-bigquery-export
Config Extension params Cloud 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)
Instance ID EXT_INSTANCE_ID injected by Extensions FIREBASE_KIT_INSTANCE_ID, set by the CLI from firebase.json

9. Test your 2nd gen function

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

To call setGlobalOptions to set global options such as a default region or CPU, you must do so only when deploying your kit as a standalone 2nd gen function. When your kit is installed as an npm package, your users call setGlobalOptions in their wrapping code to configure these parameters, and they will get warnings if this happens twice. You can guard this call by checking for the FIREBASE_KIT_INSTANCE_ID environment variable:

import { setGlobalOptions } from "firebase-functions";

if (!process.env.FIREBASE_KIT_INSTANCE_ID) {
  setGlobalOptions({
    region: "us-east1",
    maxInstances: 10,
  });
}

Make sure you are using firebase-tools >= 15.32.0, and deploy your converted 2nd gen function into a test project with the appropriate resources to test its behavior. If you already have a test project set up from testing your extension, run the following command:

firebase deploy --only functions

Fill out the resulting wizard prompting you for parameter values the same way you would have filled out the installation form in the Firebase console for the extension.

Worked example: Stream Cloud Firestore to BigQuery

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

  1. In the Cloud Firestore page of the Firebase 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 page of the Google Cloud console, query the raw changelog table. It should contain a single row logging the document creation:

    SELECT * FROM `PROJECT_ID.analytics.users_raw_changelog`
    
  4. 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`
    
  5. 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 (no prefix when deploying as a typical 2nd gen function and not a kit), not ext-<instanceId>-fsexportbigquery. Look for that name in the Cloud Functions dashboard and logs.
  • Your code now runs 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.

10. Publish your function kit on npm

Once you've validated your conversion from extension to 2nd gen function, you can publish a release candidate to npm for end-to-end testing using one of the following guides:

When your kit is published to npm, it can be installed with firebase functions:kits:install and listed as the official replacement for your extension.

We highly recommend publishing a release candidate first. Kits are installed by package name and version, so a pre-release lets you test the real installation flow against the registry without exposing an unfinished package to users who install with the default latest tag.

Before you publish:

  1. Choose a package name. Both scoped and unscoped names work (see the guides above). Note that scoped packages are private by default, so pass --access public.
  2. Build and check what ships. main and types point at your compiled output (lib/ in our worked example), so that directory must be included in the published tar file. Use .npmignore or a files allow-list, and inspect the result with npm pack --dry-run. A prepublishOnly script that runs your build prevents publishing stale output.

package.json additions that make publishing safe by default:

{
  "files": ["lib", "README.md", "CHANGELOG.md"],
  "publishConfig": { "access": "public", "tag": "next" },
  "scripts": {
    "build": "tsc -b",
    "prepublishOnly": "npm run build && npm test"
  }
}

The field "publishConfig": { "tag": "next" } ensures that a plain npm publish never overwrites latest.

Cutting a release candidate:

For example, to increment the version locally from 0.0.2-rc.3 to 0.0.2-rc.4 (this commits and tags in Git if package.json is at the repository root):

npm version prerelease --preid rc

To publish the release candidate and register @your-org/your-kit@0.0.2-rc.4 on npm under the next tag:

npm publish

The npm website can take a few minutes to show the new version; npm view reads the registry directly:

npm view @your-org/your-kit versions dist-tags

Once Step 11 and Step 12 of this guide are complete, you can promote the package to a stable version:

npm version 0.0.2
npm publish --tag latest
npm dist-tag add @your-org/your-kit@0.0.2 next

Note on npm-shrinkwrap.json: We highly recommend including an npm-shrinkwrap.json file with your package; the CLI warns users on install if you don't. It ensures users use the exact dependencies you tested against and helps protect against supply chain attacks. However, a shrinkwrap is applied verbatim in your users' projects, including during the Cloud Functions build (npm ci), where dev-only entries can fail with EBADPLATFORM. You may need to strip "dev": true entries and devDependencies from the published shrinkwrap copy.

Worked example: Stream Cloud Firestore to BigQuery

The kit's package.json at the time of its fourth release candidate:

{
  "name": "@firebase-function-kits/firestore-bigquery-export",
  "version": "0.0.2-rc.4",
  "repository": {
    "type": "git",
    "url": "https://github.com/firebase/extensions.git",
    "directory": "kits/firestore-bigquery-export"
  },
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "engines": { "node": "22" },
  "scripts": { "build": "tsc -b" }
}

Note that in this case, the kit lives in a monorepo, so it is important to include repository.directory for the npm registry link to point at the correct folder. Its CHANGELOG.md holds notes for the pending release.

11. Test as a function kit

Once you've published your kit, we recommend testing your kit using npm.

Make sure you are using firebase-tools version >= 15.32.0 and install the kit:

firebase functions:kits:install --package <your-package-name>@<your-prerelease-version>

This downloads your package from npm, sets it up inside a new source directory for your kit, and walks you through configuring the first instance similar to the extensions installation flow. Once you've installed and set up your package locally, run a deploy to create the resources in your Google Cloud project:

firebase deploy --only functions:<your-kit-instance-id>

After installation, the Firebase CLI prints a similar deploy command with the exact instance ID that you chose during installation.

Validate your kit again using the instructions in Step 9. Test your 2nd gen function. Now that you're deploying using kits, your functions are prefixed and named kit-<instance-id>-<method-name>. This allows kits to have multiple instances, deploying the same function multiple times in a project, each with a unique name.

12. Test migration replacement

You can set up a working extension instance and then use the user migration guide (using either firebase ext:migrate --package or the function kits CLI commands) to finish testing your function kit as a migration replacement.

13. Notify users and Google of your official extension replacement

Once your function kit replacement is ready and available as an npm package that users should migrate to, inform both your users and Google of this official replacement. Update the README.md file in the GitHub repository that hosts your extension with the following information:

<!-- FIREBASE_EXTENSION_REPLACEMENT: extension="<your-extesion-id>" package="<your-npm-package-name>" -->
> [!WARNING]
> **Deprecation Notice:** The Firebase Extension `<your-extension>` is deprecated. Migrate to the [<your-npm-package-name>](<link-to-your-npm-package>) package.

Google scans known extension READMEs for comments like <!-- FIREBASE_EXTENSION_REPLACEMENT: extension="firebase/firestore-bigquery-export" package="@firebase-function-kits/firestore-bigquery-export" --> and uses this to populate our official registry of replacements stored in the firebase-tools repository as replacements.json. You can also check replacements.json to see which README.md will be scanned for your extension. The official list of replacements is updated weekly.

Worked example: Stream Cloud Firestore to BigQuery

The firestore-bigquery-export extension README.md contains:

<!-- FIREBASE_EXTENSION_REPLACEMENT: extension="firebase/firestore-bigquery-export" package="@firebase-function-kits/firestore-bigquery-export" -->
> [!WARNING]
> **Deprecation Notice:** The Firebase Extension `firebase/firestore-bigquery-export` is deprecated. Please migrate to the [`@firebase-function-kits/firestore-bigquery-export`](https://www.npmjs.com/package/@firebase-function-kits/firestore-bigquery-export) package.