# MySQL

- Tool: Bubble
- Difficulty: Intermediate
- Time required: 1.5–3 hours
- Last updated: July 2026

## TL;DR

Bubble cannot open a TCP connection to MySQL port 3306 — all database calls must go through HTTPS. The two main paths are: PlanetScale's HTTP API (the cleanest modern approach, no server to maintain) and a custom REST proxy in front of your existing MySQL database. Both use Bubble's API Connector with credentials in Private headers. Write operations should go through Backend Workflows on paid plans.

## Bubble + MySQL: No Direct Connection — Two Patterns That Work

MySQL is the database behind millions of LAMP stack applications, WordPress installs, and legacy business systems. When Bubble founders first try to connect to MySQL, they often expect something like Retool's direct database connection panel — enter host, user, password, and you're in. That approach is impossible in Bubble because Bubble applications run in users' browsers, which cannot open TCP connections to external servers on port 3306. The browser's security model simply doesn't allow it.

The solution is a REST API layer sitting between Bubble and MySQL. This layer accepts HTTP requests from Bubble's API Connector, executes MySQL queries server-side (where the connection credentials live safely), and returns JSON responses. Two patterns dominate:

**Pattern 1: PlanetScale HTTP API.** PlanetScale is a MySQL-compatible database platform that provides an HTTP API endpoint — you send it a SQL query as a JSON POST body and receive a JSON result set. This is the cleanest modern path: no server to maintain, no deployment, and the HTTP API works natively with Bubble's API Connector. PlanetScale deprecated its MySQL binary protocol for new connections, so the HTTP API is now the standard PlanetScale path.

**Pattern 2: Custom REST Proxy.** If you have an existing MySQL database (a shared hosting environment, an AWS RDS instance, a DigitalOcean Droplet running MySQL), you need to deploy a small REST API that exposes your data over HTTPS. This could be a simple Express.js app on Railway or Render, a PHP REST endpoint on your shared hosting server, or an AWS Lambda with the `mysql2` npm package. The proxy stores MySQL credentials in environment variables and accepts requests from Bubble with a private API key.

The critical security requirement for both patterns is parameterized queries. If the proxy concatenates Bubble-supplied values directly into SQL strings (`SELECT * FROM users WHERE id = '` + userId + `'`), your database is vulnerable to SQL injection even from your own Bubble app — a malicious user can craft an input that escapes the string and executes arbitrary SQL. The proxy must use parameterized queries (`?` placeholders in mysql2, `PDO::prepare` in PHP) where the driver handles escaping, not the application code.

Write operations (INSERT, UPDATE, DELETE) should be routed through Bubble Backend Workflows rather than client-side API Connector calls — not because client-side calls are inherently less secure (the Private header protects the API key either way), but because server-side workflows don't appear in browser DevTools network logs, reducing the attack surface for manipulation.

## Before you start

- A Bubble app (free or paid; client-side reads work on any plan; Backend Workflows for secure write operations require a paid plan)
- The Bubble API Connector plugin installed (Plugins tab → Add plugins → search 'API Connector' by Bubble)
- Either a PlanetScale account (planetscale.com) or an existing MySQL database with a REST proxy you can deploy
- For PlanetScale: a service token created in PlanetScale console with read+write access to your database
- For a custom REST proxy: basic familiarity with Node.js/Express or PHP, and a hosting environment (Railway, Render, or shared hosting)

## Step-by-step guide

### 1. Choose your MySQL integration path

Before configuring Bubble, decide which pattern fits your situation:

**Choose PlanetScale HTTP API if:**
- You're starting a new project with no existing MySQL database
- You want managed MySQL without maintaining a server
- You're comfortable writing raw SQL in Bubble's API Connector call bodies

**Choose a Custom REST Proxy if:**
- You have an existing MySQL database (shared hosting, RDS, DigitalOcean, etc.) that you cannot or do not want to migrate to PlanetScale
- You have an existing LAMP/PHP application with REST endpoints already in place
- You want to define business logic in the proxy (access control, data transformation) rather than in Bubble workflows

If you're choosing PlanetScale: go to planetscale.com → Create a new database → choose a region → create it (takes about 30 seconds). In the PlanetScale console, go to your database → Settings → Passwords → create a service token with `read_write` access. Note the **token ID** and **token value** — you'll need both for the Authorization header.

If you're using a custom proxy: verify your MySQL database is accessible from the internet (or from your proxy deployment platform). Most shared hosts and cloud databases are publicly accessible on a specific host + port. Note your MySQL host, port, database name, username, and password.

**Expected result:** You've chosen your integration path. For PlanetScale: you have a database, a service token ID, and a token value. For a custom proxy: your MySQL database credentials are noted and the database is reachable from your deployment platform.

### 2. (PlanetScale path) Configure Bubble API Connector for PlanetScale HTTP API

In Bubble, go to Plugins tab → Add plugins → search 'API Connector' → Install. Open the API Connector and click 'Add another API'. Name it 'PlanetScale MySQL'.

Add a shared header:
- Name: `Authorization`
- Value: `Bearer {token_id}:{token_value}` — concatenate your service token ID, a colon, and your service token value
- Check the **Private** checkbox

Add another shared header:
- Name: `Content-Type`
- Value: `application/json`

Click 'Add another call'. Configure:
- **Name**: `Execute Query`
- **Method**: POST
- **URL**: `https://us-east.connect.psdb.cloud/api/v1/execute` (use your actual region endpoint)
- **Body**: JSON with two fields:
  - `query`: a dynamic text field (the SQL query string)
  - `args`: a dynamic text field (the query arguments as a comma-separated list, or an array)

The call configuration:

```json
{
  "method": "POST",
  "url": "https://us-east.connect.psdb.cloud/api/v1/execute",
  "headers": {
    "Authorization": "Bearer <private>",
    "Content-Type": "application/json"
  },
  "body": {
    "query": "<dynamic>",
    "args": ["<dynamic>"]
  }
}
```

For the Initialize call, test with a simple SELECT: `query = 'SELECT 1 as test'`, `args = []`. If PlanetScale is connected, you'll see the response `{"rows": [{"test": "1"}], "rowsAffected": 0, "insertId": "", "fields": [...]}`. Click Save.

```
{
  "method": "POST",
  "url": "https://us-east.connect.psdb.cloud/api/v1/execute",
  "headers": {
    "Authorization": "Bearer <token_id>:<token_value>",
    "Content-Type": "application/json"
  },
  "body": {
    "query": "<dynamic-sql>",
    "args": ["<dynamic-args>"]
  }
}
```

**Expected result:** The PlanetScale API Connector group is configured with the Authorization shared header marked Private. The 'Execute Query' call is initialized and returns a valid PlanetScale JSON response structure.

### 3. (Custom proxy path) Deploy a REST proxy for existing MySQL

If you're using an existing MySQL database, deploy a simple REST API that Bubble can call. Here's a minimal Node.js/Express proxy that you can deploy to Railway or Render for free:

```javascript
import express from 'express';
import mysql2 from 'mysql2/promise';

const app = express();
app.use(express.json());

// Validate API key from Bubble
const API_KEY = process.env.BUBBLE_API_KEY;
app.use((req, res, next) => {
  if (req.headers['x-api-key'] !== API_KEY) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
});

const pool = mysql2.createPool({
  host: process.env.MYSQL_HOST,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_DATABASE,
  waitForConnections: true,
  connectionLimit: 10
});

// Example: GET users with optional status filter
app.get('/users', async (req, res) => {
  const { status } = req.query;
  const [rows] = await pool.execute(
    status ? 'SELECT id, name, email, status FROM users WHERE status = ?' : 'SELECT id, name, email, status FROM users',
    status ? [status] : []
  );
  res.json({ rows });
});

// Example: POST to create a user
app.post('/users', async (req, res) => {
  const { name, email } = req.body;
  const [result] = await pool.execute(
    'INSERT INTO users (name, email, created_at) VALUES (?, ?, NOW())',
    [name, email]
  );
  res.json({ insertId: result.insertId });
});

app.listen(process.env.PORT || 3000);
```

Store environment variables in your deployment platform (Railway: Variables tab, Render: Environment tab): `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`, and `BUBBLE_API_KEY` (a random string you generate and use in Bubble's Private header).

After deployment, your proxy URL will be something like `https://your-app.up.railway.app`. Test it with a REST client (curl or Postman) before connecting Bubble.

```
import express from 'express';
import mysql2 from 'mysql2/promise';

const app = express();
app.use(express.json());

const API_KEY = process.env.BUBBLE_API_KEY;
app.use((req, res, next) => {
  if (req.headers['x-api-key'] !== API_KEY) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
});

const pool = mysql2.createPool({
  host: process.env.MYSQL_HOST,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_DATABASE,
  waitForConnections: true,
  connectionLimit: 10
});

app.get('/users', async (req, res) => {
  const { status } = req.query;
  const [rows] = await pool.execute(
    status ? 'SELECT id, name, email, status FROM users WHERE status = ?' : 'SELECT id, name, email, status FROM users',
    status ? [status] : []
  );
  res.json({ rows });
});

app.post('/users', async (req, res) => {
  const { name, email } = req.body;
  const [result] = await pool.execute(
    'INSERT INTO users (name, email, created_at) VALUES (?, ?, NOW())',
    [name, email]
  );
  res.json({ insertId: result.insertId });
});

app.listen(process.env.PORT || 3000);
```

**Expected result:** The proxy is deployed and accessible via HTTPS. Testing with curl returns JSON rows from your MySQL database. The API key header is required and returns 401 without it.

### 4. Configure Bubble API Connector for the custom REST proxy

In Bubble's API Connector, click 'Add another API'. Name it 'MySQL API'. Set the base URL to your deployed proxy URL (e.g. `https://your-app.up.railway.app`).

Add a shared header:
- Name: `x-api-key`
- Value: your BUBBLE_API_KEY value
- Check the **Private** checkbox

Add another shared header:
- Name: `Content-Type`
- Value: `application/json`

Add your first call for reads:
- **Name**: `Get Users`
- **Method**: GET
- **URL**: `/users`
- **Parameters**: add a query parameter `status` (text, dynamic, optional)
- **Use as**: Data (for repeating groups)

Add a call for writes:
- **Name**: `Create User`
- **Method**: POST
- **URL**: `/users`
- **Body**: JSON `{"name": "{{name}}", "email": "{{email}}"}`
- **Use as**: Action

For the Initialize call on 'Get Users': click Initialize call → Run (no dynamic params needed for a basic GET). If your MySQL table has data, Bubble will detect the field schema from the `rows` array in the response. If the table is empty, insert a test row first.

For the Initialize call on 'Create User': provide test values `name = 'Test'` and `email = 'test@test.com'`. The proxy should insert the row and return `{"insertId": 1}`. Click Save.

This API call configuration looks like:

```json
{
  "method": "GET",
  "url": "https://your-app.up.railway.app/users",
  "headers": {
    "x-api-key": "<private>",
    "Content-Type": "application/json"
  },
  "params": {
    "status": "<dynamic-optional>"
  }
}
```

```
{
  "method": "GET",
  "url": "https://your-app.up.railway.app/users",
  "headers": {
    "x-api-key": "<private>",
    "Content-Type": "application/json"
  },
  "params": {
    "status": "<dynamic-optional>"
  }
}
```

**Expected result:** Both 'Get Users' and 'Create User' API Connector calls are initialized and return expected data. Bubble has detected the field schema from the MySQL rows in the response. The x-api-key header is marked Private.

### 5. Build Bubble workflows for reads and writes

With initialized API calls, connect them to your Bubble UI.

**Reads (Repeating Group)**: Set a Repeating Group's Data source to 'Get Users' (or your MySQL GET call). The data type should match the detected API response fields. Each row in the repeating group can display `current cell's Get Users result's name`, `current cell's Get Users result's email`, etc.

For filtered reads, pass a dynamic parameter. For example, a Dropdown filter for user status: set the `status` parameter in the Data source to `Dropdown Filter Status's value`. Bubble re-fetches the API call whenever the parameter value changes.

**Writes (Backend Workflow for paid plan)**: For INSERT, UPDATE, DELETE operations, use a Bubble Backend Workflow rather than a direct client-side API call. In the Backend Workflows section, create a new workflow called 'Create MySQL User'. Add parameters: `name` (text), `email` (text). Add action: Plugins → API Connector → Create User. Map `name = Parameter: name`, `email = Parameter: email`.

On the client-side, trigger this Backend Workflow from a button click: Workflow → Schedule API Workflow → 'Create MySQL User' → pass `name = Input Name's value`, `email = Input Email's value`.

**For client-side writes on free plan**: If you're on Bubble's free plan and cannot use Backend Workflows, you can make client-side POST calls with the Private header. The key stays hidden in transit. However, Backend Workflows are still preferred for writes because they run server-side and don't appear in browser DevTools.

RapidDev's team has connected Bubble to dozens of MySQL databases across LAMP stacks, PlanetScale, and custom REST proxies — book a free scoping call at rapidevelopers.com/contact if you'd like help designing your specific integration.

**Expected result:** A Bubble Repeating Group displays data from the MySQL database via the API Connector. A button workflow triggers a Backend Workflow that creates a new row in MySQL. The MySQL database is updated as confirmed in a database viewer or via a GET call.

### 6. Add error handling and test write security

MySQL write operations can fail in ways that Bubble won't surface as visible errors by default. Add explicit error handling to your Bubble workflows.

For Backend Workflows that call MySQL write endpoints: after the API Connector action, add a conditional branch: if the API response status is not 200 (or if the `insertId` field is empty for POST calls), show an error message to the user and log the failure to a Bubble data type for debugging.

For PlanetScale specifically: the HTTP API returns errors as HTTP 400 with a JSON body containing an error message. Check Bubble's 'API response error' event in the workflow to catch these.

Test SQL injection protection:
1. In your Bubble app's input field, enter a value like `' OR '1'='1` and submit the form
2. Check your MySQL database — the submitted value should appear literally as a row field value, NOT cause additional rows to be returned
3. If using PlanetScale's HTTP API with parameterized queries, this is handled automatically
4. If using a custom proxy, verify your proxy uses `pool.execute()` with `?` placeholders — if it uses string concatenation, the injection test will return unexpected results

Finally, add Bubble privacy rules on any data types that store MySQL-sourced data cached in Bubble's native database. Go to Data tab → Data types → [Your Type] → Privacy → add a rule restricting reads to the current user.

**Expected result:** Error handling is added to write workflows. SQL injection test confirms parameterized queries are working correctly — injected values are stored literally, not executed as SQL. Bubble privacy rules are configured for any native data types that cache MySQL data.

## Best practices

- Always use parameterized queries with ? placeholders — never concatenate Bubble input values directly into SQL strings. SQL injection is a real risk even in internal apps, and parameterized queries are the only reliable protection.
- Keep MySQL credentials (password, connection string) exclusively in server-side environment variables — in your custom proxy's deployment platform settings or PlanetScale's managed service, never in Bubble's API Connector fields without the Private checkbox.
- Route write operations (INSERT, UPDATE, DELETE) through Bubble Backend Workflows (paid plan) rather than client-side API calls. This keeps write calls off the browser's network panel and reduces the risk of request manipulation.
- Design REST proxy endpoints around business operations, not raw SQL pass-through. A generic `/query` endpoint that accepts any SQL from Bubble is a security risk — even with authentication, it allows any authenticated user to query any table. Use specific endpoints like GET /orders, POST /orders, PATCH /orders/{id}.
- Add explicit error handling to MySQL write workflows in Bubble. API Connector calls that fail return an error state — add conditional branches after write actions to detect failures and show users meaningful error messages rather than silent failures.
- For PlanetScale, test SQL queries in PlanetScale's Console SQL editor before adding them to Bubble's API Connector. The Console shows exact error messages and query execution plans, which are much easier to debug than API Connector initialization failures.
- Cache frequently-read MySQL data in Bubble's native database when data doesn't change in real time — use a scheduled Backend Workflow to sync MySQL data to Bubble's database every few minutes. This reduces the number of API calls per page load and lowers WU consumption.
- Monitor WU consumption for MySQL-heavy pages in Bubble's Logs tab → Workload section. Each API Connector call consumes WUs. Combine multiple data needs into one REST endpoint call rather than making five separate API calls per page.

## Use cases

### Bubble frontend for an existing LAMP/PHP application

Your company has an existing PHP application with a MySQL database containing years of production data. Build a Bubble frontend or admin panel on top of it by pointing Bubble's API Connector at the existing PHP API endpoints — many LAMP apps already have REST-ish endpoints that return JSON. No new database, no data migration.

Prompt example:

```
Build a Bubble admin dashboard that calls existing PHP REST endpoints at api.yourapp.com/users and api.yourapp.com/orders, displays the data in repeating groups, and allows status updates via PATCH calls authorized with an internal API key in a Private header.
```

### New SaaS application on PlanetScale

Start a new Bubble SaaS project using PlanetScale as the scalable MySQL backend. Use PlanetScale's branching feature (like Git branches for your database schema) during development, then promote the schema to production. Bubble reads and writes via the HTTP API, and PlanetScale handles scaling, backups, and high availability automatically.

Prompt example:

```
Create a PlanetScale database with a 'subscriptions' table and an 'api_usage' table. Configure Bubble's API Connector to POST SQL queries to PlanetScale's HTTP API endpoint and display subscription data in a user dashboard repeating group.
```

### Data sync between WordPress and Bubble

If you run a WordPress site on MySQL and want to display or manage WordPress content in a Bubble app (orders from WooCommerce, posts, user accounts), deploy a simple REST endpoint that queries the WordPress database and returns JSON. Bubble reads this data without touching WordPress's native PHP — useful for building custom admin panels or mobile-app APIs on top of WordPress data.

Prompt example:

```
Deploy a small Node.js REST endpoint that connects to the WordPress MySQL database and exposes GET /wp-posts and GET /wc-orders endpoints with an API key. Connect Bubble's API Connector to these endpoints to build a custom content management dashboard.
```

## Troubleshooting

### API Connector call returns 'There was an issue setting up your call' during Initialize

Cause: The API endpoint returned a non-200 status code during initialization, or the response body was not valid JSON. For PlanetScale, this commonly means the Authorization header is formatted incorrectly. For custom proxies, it usually means a connection error to MySQL.

Solution: Check Bubble's Logs tab → API Connector for the raw response status and body. For PlanetScale: verify the Authorization header is exactly `Bearer {token_id}:{token_value}` with no spaces around the colon. For custom proxies: check your deployment logs for MySQL connection errors (wrong host, wrong credentials, MySQL not accepting connections from your proxy's IP).

### PlanetScale HTTP API returns HTTP 400 with a JSON error body

Cause: The SQL query syntax is invalid, a referenced table or column doesn't exist, or the args array length doesn't match the number of ? placeholders in the query.

Solution: Check the error message in the PlanetScale 400 response body (visible in Bubble's Logs tab). Common issues: table name misspelled, missing column in INSERT (NOT NULL column with no value provided), or args array has fewer items than ? placeholders. Test your query directly in PlanetScale Console's SQL editor before adding it to Bubble.

### Initialize call succeeds but Bubble shows no fields detected from the response

Cause: The response structure nests the data inside a key (e.g. `{"rows": [...]}`) but Bubble is looking for a top-level array. Or the `rows` array is empty because the table has no data.

Solution: In the API Connector Initialize call response, check how Bubble displays the data structure. If the response is `{"rows": [{...}]}`, Bubble should detect `rows` as a list — access fields as `result's rows[0]'s fieldname`. If the rows array is empty, insert a test row first, then re-initialize. For PlanetScale, the response always has a `rows` key — use that in your Bubble data bindings.

### Write operations from client-side Bubble workflows appear in browser DevTools network requests

Cause: Client-side Bubble API Connector calls (those made directly in page workflows or button click workflows, not Backend Workflows) are visible in the browser's network panel even with Private headers — Private hides the header VALUE, but the request itself is visible.

Solution: Move INSERT, UPDATE, and DELETE operations to Bubble Backend Workflows (paid plan). Backend Workflows run on Bubble's servers, not in the user's browser, so they don't appear in the user's DevTools. From client-side, trigger them using 'Schedule API Workflow' action. This also prevents users from potentially manipulating request bodies through browser tools.

### PlanetScale deprecated MySQL binary protocol message appears in documentation

Cause: PlanetScale deprecated the MySQL binary protocol (port 3306 connections) for new connections in favor of its HTTP API. Any tutorial or connection string format using host:port MySQL connections is outdated for PlanetScale.

Solution: Use only PlanetScale's HTTP API endpoint (`https://{region}.connect.psdb.cloud/api/v1/execute`) for all Bubble integrations. If you have an existing PlanetScale connection using the MySQL binary protocol that you want to maintain, check PlanetScale's current documentation for your options — the HTTP API is the supported path going forward.

## Frequently asked questions

### Can I connect Bubble directly to MySQL without any proxy or external service?

No. Bubble runs in the browser, and browsers cannot open TCP connections to MySQL's port 3306. Every Bubble-MySQL integration requires an HTTPS REST layer in between. Your two main options are PlanetScale's HTTP API (managed, no server required) or a custom REST proxy you deploy to a cloud platform.

### Is PlanetScale still a good option after they deprecated MySQL binary protocol connections?

Yes — PlanetScale deprecated the MySQL binary protocol for NEW connections but provides the HTTP API as the modern replacement, which is actually easier to use with Bubble's API Connector than direct MySQL connections ever were. Existing binary protocol connections are grandfathered in, but all new Bubble integrations with PlanetScale should use the HTTP API.

### Do I need a paid Bubble plan to connect to MySQL?

For read operations (GET calls), the free plan works — client-side API Connector calls work on any Bubble plan. For write operations (INSERT, UPDATE, DELETE), you technically CAN make those from client-side workflows on the free plan, but it's strongly recommended to use Backend Workflows (paid plan) to keep write operations off the browser's network panel. There's no hard technical requirement for a paid plan, but the security posture is better with Backend Workflows.

### How do I prevent SQL injection in my custom REST proxy?

Use parameterized queries exclusively. In Node.js with mysql2, use `pool.execute('SELECT * FROM users WHERE id = ?', [userId])` — the `?` placeholder is replaced by the driver with a properly escaped value, not by string concatenation. In PHP with PDO, use `$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?')` followed by `$stmt->execute([$userId])`. Never build SQL strings by concatenating values from Bubble: `'SELECT * FROM users WHERE id = ' + bubbleParam` is a SQL injection vulnerability.

### How do I handle MySQL errors in Bubble workflows?

API Connector calls that fail return an error state accessible in Bubble's workflow conditional logic. After a write action (Create User, etc.), add a conditional 'only when result's insertId is empty' branch to show an error popup. For PlanetScale, HTTP 400 errors include a JSON body with an error message — check Bubble's Logs tab → API Connector calls to see the exact error message during testing. For custom proxies, ensure your proxy returns structured JSON error responses (e.g. {"error": "Duplicate email"}) rather than plain text errors, so Bubble can parse them.

---

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