What is a Bubble plugin, and when should you build one?

Bubble offers several ways to inject code: the HTML element, the “Run JavaScript” action, and the plugin. The first two are stopgaps; the plugin is a different category altogether. An HTML element is a one-off injection, not reusable and hard to configure. A plugin, by contrast, is a first-class citizen of the ecosystem: it exposes properties you can configure in the editor, states other elements can read, events that trigger workflows, and actions usable in your app’s logic.

There are three types, which a single plugin can combine.

TypeWhat it does
Visual elementCreates a new type of element (a date picker, an editor, a signature pad). Runs entirely in the browser.
ActionAdds a workflow action, client-side (DOM, analytics) or server-side (API calls, heavy computation, secrets).
API connectionAdds authenticated connections that are reusable and shareable across applications.

You build a plugin the moment a need is reusable, has to be configured in the editor, expose states, run server-side, or integrate a third-party library.

An element’s lifecycle: initialize, update, reset

This is the heart of an element, and the number-one source of bugs. Bubble calls three functions at specific moments.

initialize runs once, when the element becomes visible: this is where you build the DOM, instantiate libraries, and attach listeners. Always attach your content through instance.canvas, never through document.body, or you fall out of Bubble’s render flow.

update runs on every property change, so potentially many times. This is the classic trap: your code has to be idempotent. Keep the previous value in memory so you only rerun a costly operation when it has actually changed.

function(instance, properties, context) {
  // update() can fire again on every change: keep the state
  if (instance.data.lastTitle === properties.title) return;
  instance.data.lastTitle = properties.title;

  loadContent(properties.title); // the costly operation, only once
}

A subtle but costly trap: when you wrap a data access in a try...catch, you must rethrow the not ready error. Swallowing it leaves the element frozen, never loading its data, with no visible error.

reset restores the initial state when a workflow asks for it (clearing a form, resetting a map).

Client or server: where each action belongs

The distinction is fundamental, especially for security.

Client-side (browser)Server-side (Node.js)
Access to the DOM and browser APIsNo DOM, but the full Node runtime
Front-end librariesnpm modules
Inspectable by the userIsolated execution, invisible to the client
UI, analytics, tokenisationSecure API calls, heavy computation, secrets

Iron rule: never put a secret key in a client-side action. Anything that runs in the browser is inspectable through DevTools. Secrets live in the Additional Keys, read server-side via context.keys. Since API v4, server actions run on Node.js (Node 22 as we write this) and are written with Promises and async/await; the synchronous syntax of the old v3 no longer works.

Handling API errors, explicitly

A production plugin doesn’t crash in front of the user: it fails cleanly. The right approach is a fail() helper that returns every output value with the right type, on every path, including the error path.

async function(properties, context) {
  function fail(msg) {
    // every declared output, correctly typed and never undefined
    return { success: false, error_message: msg, result_id: "" };
  }
  try {
    // 1. validate inputs  2. call the API  3. return success
  } catch (e) {
    var msg = e.message || "Unknown error";
    if (e.statusCode) msg = "HTTP " + e.statusCode + " : " + msg;
    console.error("[MyPlugin] " + msg);
    return fail(msg);
  }
}

Why fail() rather than throw? A throw shows the user a generic popup with no recourse. A fail() hands them a structured error_message they can display or handle in their own workflow. Every message should answer three questions: what happened, what the user provided, and how to fix it.

A word on the type contract: Bubble is strict. An output declared “boolean” that returns the string "false", or a text field that returns undefined, will crash the action. Hence the helper that guarantees types everywhere, including on the failure path.

Accessibility, from the design stage

When a plugin produces UI, it inherits the same obligations as an application: keyboard-operable, readable by assistive technology, targets large enough, states perceptible by more than colour alone. A custom button must respond to Enter and Space, expose a role, manage focus. This isn’t a finishing touch but a design constraint: a component you can only drive with a mouse excludes some of the final app’s users, and puts your client’s compliance with the European Accessibility Act on the line.

What Bubble’s documentation doesn’t tell you

The real know-how begins where the docs stop. Server actions run on AWS Lambda, with limits Bubble doesn’t document: roughly 128 MB of memory, a 30-second total timeout, no file system, no browser API. You code accordingly: few dependencies (each module adds the risk of a known vulnerability that blocks publishing, and eats into those 128 MB), Node’s native modules when they’re enough (crypto rather than uuid, fetch rather than axios), and console.error for tracing, since it’s the only level the Server Logs capture. It’s these details, invisible in the editor, that separate a plugin that works from a plugin that holds up in production.

Our test bench

We don’t theorise: we design, test and document our own Bubble plugins on a public demo app. Scientific Calculators, Toggle Studio and Observable Plot Charts live there, proven in real conditions before release. That’s where we check what this article describes: a clean lifecycle, explicit errors, keyboard accessibility. See the lab.


A plugin to build? Let’s talk.