# Miro

- Tool: Bubble
- Difficulty: Intermediate
- Time required: 1–2 hours
- Last updated: July 2026

## TL;DR

Connect Bubble to Miro using two complementary paths: embed any Miro board instantly with an HTML iframe element (zero API key required), or pull structured board data — sticky notes, cards, frames — via the Miro REST API v2 with an app token stored in a Private API Connector header. The main gotcha is board IDs that contain equals signs, which must be URL-encoded as %3D to avoid silent 404 errors.

## Two ways to bring Miro into your Bubble app

Miro and Bubble solve complementary problems: Miro is where teams think visually, while Bubble is where founders build the apps that run their business. Connecting them unlocks powerful use cases — from surfacing retrospective action items as database records to embedding live planning boards inside customer portals.

The integration has two distinct paths, and choosing the right one matters. The **embed path** requires nothing more than an HTML element and a board ID. Paste Miro's iframe URL into an HTML element and the board is live, interactive, and always up to date. This works for public boards and boards your users have Miro access to.

The **data extraction path** uses Miro's REST API v2, authenticated with an OAuth 2.0 app token. In Bubble's API Connector, this token goes in a header marked Private — meaning it lives on Bubble's servers and never reaches the browser. From there, you can query board items by type (sticky notes, cards, frames), read their text content, and store the results in Bubble's database. One important gotcha: Miro board IDs often contain equals signs (`=`), which must be encoded as `%3D` in URL path segments or every API call silently returns 404.

For most Bubble founders, the right approach is both: embed the board for the visual experience and pull specific data items via the API to drive workflows, notifications, and database records.

## Before you start

- A Miro account — Free plan includes 3 active boards per team; Starter from $8/user/month is needed for unlimited boards
- A Miro developer app created at miro.com/app/settings/user-profile/apps to generate an app token — the app token scopes access to boards in your team
- The API Connector plugin installed in your Bubble app (Plugins tab → Add plugins → search 'API Connector' by Bubble)
- For the embed path only: the board must be either public or accessible to users who have a Miro account — private boards show a login prompt inside the iframe

## Step-by-step guide

### 1. Create a Miro developer app and copy your app token

Go to miro.com and log into your account. Click your profile avatar in the top-right corner and select 'Profile settings.' In the left sidebar, click 'Your apps' under the Developer section. Click 'Create new app' and give your app a name (for example, 'Bubble Integration'). Select the team you want the app to access — the app token will be scoped to boards in this team. After creating the app, scroll down to the 'App credentials' section. You will see two types of tokens: Client ID and Client Secret (for OAuth flows) and an App token. For a single-team internal tool, the App token is what you need — it is a long-lived token that doesn't expire unless you revoke it. Click 'Copy' next to the App token and save it somewhere safe. Under 'Permissions,' make sure to enable at minimum 'boards:read' to read board content. Save the app settings. If you need users from other Miro teams to access boards, you would need a full OAuth 2.0 authorization-code flow with token refresh — that requires Backend Workflows on a paid Bubble plan. For most single-team use cases, the static app token is the right choice and avoids that complexity entirely.

**Expected result:** You have a Miro app token (a long string starting with 'eyJ...') and the app has 'boards:read' permission enabled. Keep this token ready to paste into Bubble's API Connector.

### 2. Embed a live Miro board using an HTML element (no API key needed)

For the embed path — which requires zero API setup — open your Bubble editor and add an HTML element to the page where you want the board to appear. Set its height to at least 600 pixels so the board is usable. In the HTML element's content field, paste the following iframe markup, replacing `{BOARD_ID}` with your actual Miro board ID:

`<iframe src="https://miro.com/app/live-embed/{BOARD_ID}/" width="100%" height="100%" allowfullscreen allow="clipboard-read; clipboard-write"></iframe>`

To find your board ID, open the board in Miro and look at the URL. It will look like `https://miro.com/app/board/uXjVODk5hTQ%3D/` — the board ID is the part after `/board/` and before the trailing slash, which in this example is `uXjVODk5hTQ%3D`. Note that the `%3D` at the end is already URL-encoded (it represents `=`). Use this encoded version directly in the iframe src URL.

To make the board dynamic — showing a different board per client or per page — use Bubble's dynamic content in the HTML element. You can store the board ID in your Bubble database (for example, a `miro_board_id` Text field on a Project data type) and construct the iframe URL dynamically using Bubble's text expression: `<iframe src="https://miro.com/app/live-embed/` + Current Page Project's miro_board_id + `/" width="100%" height="100%" allowfullscreen></iframe>`. If the stored board ID has unencoded equals signs, use Bubble's `:find & replace` to swap `=` for `%3D` in the dynamic expression.

```
<!-- Miro board embed — paste into Bubble HTML element -->
<!-- Replace BOARD_ID with your actual board ID from the Miro URL -->
<iframe
  src="https://miro.com/app/live-embed/uXjVODk5hTQ%3D/"
  width="100%"
  height="600"
  frameborder="0"
  allowfullscreen
  allow="clipboard-read; clipboard-write">
</iframe>

<!-- Dynamic version — for use with Bubble's HTML element dynamic content -->
<!-- Set the src using: https://miro.com/app/live-embed/ + [board_id_field] + / -->
<!-- Use :find & replace to encode = as %3D in the dynamic board ID -->
```

**Expected result:** The HTML element on your Bubble page shows a live, interactive Miro board in an iframe. Users can scroll, zoom, and collaborate on the board without leaving your Bubble app.

### 3. Configure the Miro API Connector with a Private app token

Now set up the API Connector to pull structured board data. In your Bubble editor, click 'Plugins' in the left sidebar, then click 'Add plugins.' Search for 'API Connector' (published by Bubble) and click 'Install.' Close the plugin panel and click the API Connector in your plugins list. Click 'Add another API' and name it 'Miro API.' In the 'API Root URL' field, enter `https://api.miro.com/v2`. Under 'Shared headers,' click 'Add a shared header.' Set the key to `Authorization` and the value to `Bearer YOUR_APP_TOKEN_HERE`. Critically, check the **'Private'** checkbox next to this header — this marks the token as server-side only, meaning it is never sent to users' browsers. Bubble's API Connector runs server-side by default, so this combination keeps your Miro credentials completely secure.

Now add your first call. Click 'Add a call.' Name it 'List Boards.' Set the method to GET and the path to `/boards`. Under 'Use as,' select 'Data.' Leave the parameters empty for now. Click 'Initialize call' — Bubble will make a real request to the Miro API and detect the response field structure. If the call succeeds, Bubble will show the detected fields including `data[].id`, `data[].name`, `data[].description`, and `data[].viewLink`. If you see 'There was an issue setting up your call,' double-check the token format — it must be `Bearer ` followed by your app token, with a space between Bearer and the token. Also confirm the Private checkbox is checked.

```
{
  "api_name": "Miro API",
  "base_url": "https://api.miro.com/v2",
  "shared_headers": [
    {
      "key": "Authorization",
      "value": "Bearer <private: your_miro_app_token>",
      "private": true
    }
  ],
  "calls": [
    {
      "name": "List Boards",
      "method": "GET",
      "path": "/boards",
      "use_as": "Data"
    },
    {
      "name": "Get Board Items",
      "method": "GET",
      "path": "/boards/<board_id>/items",
      "parameters": [
        { "key": "type", "value": "sticky_note", "optional": true },
        { "key": "limit", "value": "50" },
        { "key": "cursor", "value": "<dynamic>", "optional": true }
      ],
      "use_as": "Data"
    }
  ]
}
```

**Expected result:** The Miro API Connector is configured with a Private Authorization header. The 'List Boards' call initializes successfully and shows detected response fields including board IDs and names.

### 4. URL-encode board IDs and fetch sticky notes

Miro board IDs frequently contain equals signs — for example, a board ID might be `uXjVODk5hTQ=`. When this ID appears in a URL path segment (such as `/boards/uXjVODk5hTQ=/items`), the equals sign breaks the URL and the API returns a 404 with no helpful error message. The fix is always to URL-encode equals signs as `%3D`. So the correct path becomes `/boards/uXjVODk5hTQ%3D/items`.

In Bubble's API Connector path field, if you're passing the board ID dynamically, apply Bubble's `:find & replace` operator on the board ID value: take the `board_id` dynamic value and replace `=` with `%3D` before including it in the URL. You can also store board IDs already URL-encoded in your Bubble database to avoid this step at runtime.

Now add the sticky-note fetch call. In the API Connector for 'Miro API,' add a new call named 'Get Sticky Notes.' Set method to GET, path to `/boards/<board_id>/items`. Add a URL parameter: key `type`, value `sticky_note`. Add another parameter: key `limit`, value `50`. Click 'Initialize call' and provide a valid board ID (already URL-encoded) as the test value for `board_id`. A successful response returns a `data` array where each item has `id`, `type`, `data.content` (the sticky note text as an HTML string like `<p>Action item</p>`), `style`, and `position` fields.

The content field returns HTML — to strip tags before saving to Bubble's Text fields, use Bubble's `:extract with regex` operator with the pattern `(?s)<.*?>` replaced with empty string. Alternatively, use Bubble's `:regex replace` with pattern `<[^>]*>` and replacement ``. Set 'Use as' to 'Data' and mark `data` as the list in the Initialize call.

```
{
  "call_name": "Get Sticky Notes",
  "method": "GET",
  "path": "/boards/<board_id>/items",
  "note": "board_id must have = encoded as %3D — e.g., uXjVODk5hTQ%3D",
  "parameters": [
    { "key": "type", "value": "sticky_note" },
    { "key": "limit", "value": "50" },
    { "key": "cursor", "value": "<dynamic: pagination cursor>", "optional": true }
  ],
  "sample_response": {
    "data": [
      {
        "id": "3458764567890123456",
        "type": "sticky_note",
        "data": {
          "content": "<p>Action item: update onboarding flow</p>",
          "shape": "square"
        },
        "style": { "fillColor": "yellow" },
        "position": { "x": 100.0, "y": -200.0 }
      }
    ],
    "cursor": "next_page_cursor_string",
    "total": 87
  }
}
```

**Expected result:** The 'Get Sticky Notes' call fetches up to 50 sticky notes from the specified board. Each item in the data array has an id, content (as HTML string), and position. Bubble auto-detects all response fields after a successful Initialize call.

### 5. Save board items to Bubble's database and build the repeating group

Now wire the Miro data into your Bubble app. First, create a new Data Type in the Data tab called 'Miro Item' with these fields: `item_id` (Text), `content` (Text), `board_id` (Text), `item_type` (Text), `color` (Text), and `created_date` (Date). This stores stripped note content separately from the raw API response.

In your workflow editor, create a workflow triggered by a button ('Sync from Miro'). The first action is 'Get data from an external API' — choose 'Miro API — Get Sticky Notes' and supply your board ID (URL-encoded). The second action is 'Create a list of Things' using 'each item in API result's data': set `item_id` to `item's id`, `content` to `item's data's content` with `:regex replace` stripping the HTML tags, `board_id` to the board ID input, `item_type` to `item's type`.

For the privacy rules: go to Data tab → Privacy → click on 'Miro Item.' Add a rule: 'Everyone else' → uncheck all permissions (View, Edit, Delete). Add a rule: 'Current User' → check 'View.' This prevents unauthenticated users from reading your board data through Bubble's Data API.

To display the data, add a Repeating Group to your page. Set data type to 'Miro Item' and data source to 'Search for Miro Items.' Inside the cell, add Text elements bound to 'Current cell's Miro Item's content' and 'Current cell's Miro Item's item_type.' For pagination across large boards, add a 'Load More' button that triggers the same workflow with the cursor value from the last response stored in a custom state.

**Expected result:** Clicking 'Sync from Miro' fetches sticky notes from the API, strips HTML tags from content, and saves them as Miro Item records in Bubble's database. The Repeating Group displays the saved items with clean text content.

### 6. Handle pagination for boards with more than 50 items

Miro's API returns a maximum of 50 items per call, and Bubble's Repeating Group also has a 50-item list cap by default. For boards with more sticky notes, frames, or cards than 50, you must implement pagination using Miro's cursor parameter.

First, add a custom state to your page: type Text, name `miro_cursor`. Initially it is empty. When the 'Get Sticky Notes' workflow runs, after creating Miro Item records from the response data, add a 'Set state' action: set `miro_cursor` to `result's cursor` (the cursor field from the API response).

Add a 'Load More' button to your page, visible only when `miro_cursor` is not empty. When clicked, the button triggers the same sync workflow but passes `Current Page's miro_cursor` as the `cursor` URL parameter in the API call. Miro returns the next 50 items starting after the cursor position, and a new cursor value for the following page (or an empty cursor when all items have been loaded).

For the Repeating Group display, change data source to 'Search for Miro Items: sorted by Created Date descending' rather than a live API call — this way the RG draws from Bubble's database (already paginated during sync), avoiding the 50-item cap at the UI layer. The database can hold all synced items; only the API calls are limited to 50 per request.

```
{
  "pagination_workflow": {
    "trigger": "Load More button clicked",
    "step_1": {
      "action": "Get data from external API",
      "api": "Miro API — Get Sticky Notes",
      "params": {
        "board_id": "<URL-encoded board ID>",
        "type": "sticky_note",
        "limit": "50",
        "cursor": "<Current Page miro_cursor state>"
      }
    },
    "step_2": {
      "action": "Create list of Things (Miro Item)",
      "source": "each item in Step 1's data"
    },
    "step_3": {
      "action": "Set state miro_cursor",
      "value": "Step 1's result cursor"
    }
  }
}
```

**Expected result:** Users can click 'Load More' to progressively load all sticky notes from large Miro boards. The cursor state updates after each load, and the button disappears automatically when all items have been fetched.

## Best practices

- Always URL-encode Miro board IDs before using them in API Connector path segments — replace `=` with `%3D`. Store IDs pre-encoded in Bubble's database to avoid transformation at call time.
- Use the static Miro app token for single-team internal tools — it doesn't expire and is far simpler than implementing a full OAuth 2.0 authorization-code flow. Reserve OAuth for multi-team or multi-user scenarios.
- Mark the Authorization header as Private in Bubble's API Connector — this is the correct way to keep API credentials server-side in Bubble. Never place the token in a visible page element, URL parameter, or client-side JavaScript.
- Strip HTML tags from Miro content fields at write time (when saving to Bubble's database), not at display time. Storing clean text is more efficient and avoids re-processing on every page load.
- Add Bubble Data Privacy rules to any Data Type that stores Miro content — set 'Everyone else' to no permissions. Bubble's default setting makes all records readable to any logged-in user via the Data API.
- Monitor WU consumption in Bubble's Logs tab after launching. Each API call to Miro consumes Workload Units — avoid polling patterns (checking Miro on every page load) and prefer a manual 'Sync' button or scheduled Backend Workflow instead.
- For boards with more than 50 items, implement cursor-based pagination. Store the cursor value from each API response in a Bubble custom state and display a 'Load More' button that passes it on the next call.

## Use cases

### Retrospective Action Item Tracker

At the end of each team retrospective on Miro, pull all sticky notes tagged as action items into a Bubble database. Display them in a task-management interface, assign owners, set due dates, and track completion — all without leaving your Bubble app. Use GET /boards/{id}/items?type=sticky_note to fetch the notes and strip HTML from the content before saving.

Prompt example:

```
Build a Bubble page that fetches all sticky notes from a specific Miro board, strips HTML tags from their content, saves them as Task records in Bubble's database with a 'source: Miro' field, and displays them in a sortable repeating group with an 'Assign' dropdown per row.
```

### Embedded Planning Board in Customer Portal

Add a live, interactive Miro board to your Bubble customer portal so clients can see and collaborate on project plans, roadmaps, or design reviews in real time. Use Miro's iframe embed URL inside a Bubble HTML element, dynamically swapping the board ID based on the logged-in client's account record stored in Bubble's database.

Prompt example:

```
Create a Bubble client portal page that displays a live embedded Miro board in an iframe. The board ID is stored per client in the Client data type. When a client logs in, show their specific board using a dynamic :find & replace on the embed URL.
```

### Sprint Planning Dashboard

Connect your Miro sprint planning board to a Bubble dashboard that aggregates card data across multiple boards, showing story count, frame breakdowns, and team member assignments in charts and tables. Use GET /boards/{id}/items?type=card to pull sprint cards and display totals using Bubble's aggregation operators.

Prompt example:

```
Build a Bubble dashboard that calls the Miro API to fetch all cards from three different board IDs, groups them by the frame they belong to, counts items per frame, and displays the totals in a bar chart using Bubble's built-in chart element.
```

## Troubleshooting

### API call returns 404 even though the board exists in Miro

Cause: The board ID contains equals signs (=) that are not URL-encoded. Miro board IDs often end with one or two `=` characters (base64 padding). When placed in a URL path segment without encoding, the equals sign breaks the URL structure silently.

Solution: Check your board ID by opening the board in Miro — the URL will show the ID, often with `%3D` already encoded in the browser bar. Use the encoded version (`%3D` instead of `=`) everywhere you reference the board ID in Bubble's API Connector path. If the board ID is stored in Bubble's database, apply `:find & replace` at the point where you build the API call URL: replace `=` with `%3D`.

### Initialize call returns 'There was an issue setting up your call' or a 401 error

Cause: The Authorization header format is incorrect, or the app token is missing or expired. The most common mistake is omitting the word 'Bearer' or the space before the token. A 401 also appears if the app token was revoked in Miro's developer settings.

Solution: Verify the shared header value is exactly `Bearer YOUR_APP_TOKEN` with a single space between 'Bearer' and the token. Confirm the Private checkbox is checked. If the token looks correct, go to miro.com → Profile Settings → Your apps → your app → App credentials and verify the token is still active (not revoked). Copy the token again fresh to avoid invisible character issues.

### Sticky note content shows raw HTML tags like <p> and </p> instead of plain text

Cause: Miro returns item content as an HTML string — `<p>Action item</p>` rather than `Action item`. Bubble displays this raw string without rendering the HTML when used in a Text element.

Solution: Apply Bubble's `:regex replace` operator on the content field: match pattern `<[^>]*>` and replace with an empty string. Do this at the point of saving to Bubble's database (in the Create Thing workflow step) so the stored content is always clean plain text. Alternatively, apply the transformation at display time in the Text element's dynamic expression.

### The repeating group only shows 50 items even though the board has more

Cause: Miro returns a maximum of 50 items per API call. If the workflow creates Bubble database records from the API response but there is no pagination logic, only the first 50 items are ever synced.

Solution: After the initial sync workflow runs, check whether `result's cursor` from the API response is non-empty. If it is, store it in a page custom state and show a 'Load More' button. Each click triggers the same workflow with the cursor parameter filled, fetching the next batch. Continue until `result's cursor` is empty, indicating all items have been loaded.

### The embedded Miro board shows a login screen inside the iframe instead of the board

Cause: The board is set to 'Private' in Miro's sharing settings. Private boards require Miro authentication, which the iframe cannot handle — users see Miro's login page instead of the board.

Solution: Open the board in Miro, click the Share button, and change the link access to 'Anyone with the link can view.' For the embed URL specifically, boards must be publicly accessible or the viewing user must be logged into Miro in the same browser. If the board must remain private, use the REST API data path instead of the iframe embed.

## Frequently asked questions

### Do I need a paid Miro plan to use the REST API?

No — Miro's REST API is available on the Free plan. However, the Free plan limits you to 3 active boards per team. If you need more boards, a Starter ($8/user/month) or Business ($16/user/month) plan is required. The API itself doesn't have a plan gate; board access is what's gated.

### Why is my board ID showing %3D instead of = in the Miro URL?

The `%3D` is the URL-encoded form of the equals sign `=`. Miro board IDs use base64 encoding, which frequently ends in one or two `=` characters. Browsers automatically display these as `%3D` in the URL bar. When using the board ID in API calls, always use the `%3D` encoded version in the URL path — using `=` directly will break the URL silently.

### Can I write data back to Miro from Bubble — for example, create sticky notes?

Yes — the Miro REST API v2 supports POST requests to create items on a board. Add a second API Connector call with method POST to `/boards/{board_id}/items`, set the body type to JSON, and include the item type, content, and position fields. This lets Bubble workflows create sticky notes or cards on a Miro board programmatically.

### Does Bubble need a paid plan for this integration?

The basic integration — embedding a board via iframe and reading data via the API Connector — works on Bubble's Free plan. A paid Bubble plan is only required if you want to receive real-time Miro webhook events (which requires Backend Workflows, a paid feature) or if you implement a full OAuth 2.0 user-level token flow with token refresh Backend Workflows.

### How do I handle Miro access tokens that expire after 1 hour?

If you are using a static Miro app token (the recommended approach for single-team tools), it does not expire — you don't need to handle refresh. If you implement a full OAuth 2.0 authorization-code flow for multi-user access, the resulting access tokens expire after 1 hour. In that case, store the refresh token in Bubble's User data type and build a Backend Workflow that calls Miro's token refresh endpoint before each batch of API calls. This requires a paid Bubble plan.

### What is the difference between the Miro embed iframe and the REST API?

The iframe embed shows a live, interactive Miro board inside your Bubble page — users can zoom, pan, and (if they have edit access) add sticky notes. No API key is needed, but the experience depends on the user's Miro board permissions. The REST API extracts structured data from the board — sticky note text, card metadata, frame labels — into Bubble's database, where you can run workflows, build charts, and trigger actions. Most useful integrations combine both: the iframe for real-time visual collaboration and the API for data extraction and automation.

---

Source: https://www.rapidevelopers.com/bubble-integrations/miro
© RapidDev — https://www.rapidevelopers.com/bubble-integrations/miro
