splitforms.com
All articles/ TUTORIALS9 MIN READPublished May 11, 2026

How to Add a Custom Contact Form to Bubble.io 2026

Add a custom contact form to your Bubble.io app in 2026 — HTML element, splitforms backend, validation, file uploads, and email delivery without workflows.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Why Bubble's native form workflow is a bad default

Most Bubble tutorials tell you to build a contact form with native input elements and a workflow that runs "Create a new thing" followed by "Send email". It works. It also burns workflow units on every submission, and WUs are how Bubble bills you. A single contact form submission on a busy site can chew through 8–15 WUs once you account for input change events, validation conditions, the database write, the email send, and any third-party connector calls layered on top.

Here's the actual math. Bubble's Starter plan gives you 175k WUs/month for $32. That sounds like a lot until you realize every page load that triggers a workflow, every validation check, and every email send counts. A landing page running paid traffic with a contact form converting at 3% on 10k visits/month means 300 submissions × 12 WUs = 3,600 WUs just for the form. That's not catastrophic by itself, but it's 3,600 WUs spent on something that should be free.

The other problem with native Bubble forms is email deliverability. Bubble's "Send email" action uses SendGrid under the hood and the from-address is locked to your account email. If you want a custom from-domain, SPF/DKIM alignment, or reply-to threading, you have to configure SendGrid separately and route through it — at which point you're building infrastructure inside a no-code tool, which defeats the point. Deliverability problems are the #1 reason contact forms silently fail.

An external form backend handles all of this outside Bubble. The browser POSTs directly to splitforms, the email goes out from splitforms' SMTP (or your own custom SMTP, your choice), and Bubble never touches the request. Zero workflow units, zero plugin dependency, zero plan-tier lock-in.

Step 1: Get a splitforms access key (1 minute)

  1. Go to splitforms.com/login
  2. Enter your email — you'll get a 6-digit code, paste it
  3. The dashboard auto-generates your access key. Copy it.

That's the entire signup. No credit card, no plan to pick. The free tier is 1,000 submissions/month and it's permanent — there's no "trial" that expires. If you ever need more, Pro is $5/mo for 5,000 submissions or $59 for 4 years. Most Bubble apps with a single contact form never leave the free tier.

While you're in the dashboard, set the destination email (where submissions get delivered) and add your Bubble domain to the Allowed Domains list. For development you usually want both yourapp.bubbleapps.io and your custom domain if you've set one. Save.

Step 2: Drop an HTML element on your Bubble page

In the Bubble editor:

  1. Open the page where you want the form (Contact, About, Landing)
  2. From the left palette, drag an HTML element onto the page
  3. Size it to roughly the dimensions you want the form to occupy (the height will auto-grow with content)
  4. Double-click the element to open its property editor — you'll see a multi-line text field for HTML content

The HTML element is one of the most underused parts of Bubble. It accepts raw markup including forms, scripts, and styles. There's no sandbox restriction on form action URLs, so external POSTs work out of the box. You don't need a plugin from the marketplace and you don't need to upgrade your Bubble plan.

Why HTML element beats the "Embed" plugin

You'll see "Embed iframe" plugins in the Bubble marketplace that wrap external content. Skip those. They add an extra iframe layer between Bubble and your form which makes styling harder, breaks the form's width on mobile, and adds an unnecessary cross-origin boundary. The native HTML element renders inline — your form is part of the page DOM, styled by the page's CSS, and resizes naturally.

Step 3: Paste the splitforms form HTML

Inside the HTML element's content field, paste this:

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />

  <label>Name
    <input type="text"  name="name"    required />
  </label>

  <label>Email
    <input type="email" name="email"   required />
  </label>

  <label>Message
    <textarea           name="message" required rows="5"></textarea>
  </label>

  <!-- Anti-spam honeypot (invisible to humans) -->
  <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />

  <!-- Optional: where to send the user after submit -->
  <input type="hidden" name="redirect" value="https://yourapp.com/thanks" />

  <button type="submit">Send</button>
</form>

Replace YOUR_ACCESS_KEY with the key you copied from the dashboard. That's the entire integration. Preview your Bubble page, submit a test message, check your inbox.

Note that the form fields use plain HTML name attributes — Bubble's data type system has no involvement. splitforms accepts any field names you send and includes all of them in the notification email. So if you want a phone number, add <input type="tel" name="phone" /> and you're done. The same paste-in pattern works in the Next.js form backend or React form backend integrations if you ever migrate off Bubble.

Step 4: Styling the form (Bubble-native CSS)

Bubble doesn't apply your design system to elements inside an HTML element — they render unstyled by default. You have two options.

Option A: Inline styles inside the HTML element

Add a <style> block at the top of the HTML element content:

<style>
  .sf-form { display: flex; flex-direction: column; gap: 14px; max-width: 480px; }
  .sf-form label { font-size: 14px; font-weight: 600; color: #1a1a1a; }
  .sf-form input, .sf-form textarea {
    width: 100%; padding: 10px 14px; font-size: 15px;
    border: 1px solid #d4d4d8; border-radius: 8px; background: #fff;
  }
  .sf-form input:focus, .sf-form textarea:focus {
    outline: 2px solid #2563eb; outline-offset: 2px;
  }
  .sf-form button {
    background: #1a1a1a; color: #fff; border: 0; border-radius: 8px;
    padding: 12px 20px; font-weight: 600; cursor: pointer;
  }
</style>

<form class="sf-form" action="https://splitforms.com/api/submit" method="POST">
  <!-- fields from Step 3 -->
</form>

Self-contained, portable, and survives Bubble edits. Recommended.

Option B: Match Bubble's style variables

If you want the form to inherit your Bubble app's typography and colors, set the form fields to use font-family: inherit and reference Bubble's color via your own CSS variables defined in the page's general appearance settings. This couples the form to your Bubble theme, which is convenient if you change brand colors often.

Step 5: Client-side validation

The required attribute on each input gives you free browser-native validation. Email format is checked by type="email". Phone format you usually leave loose (international formats vary). If you want stricter validation, add a pattern attribute:

<input type="text" name="phone"
       pattern="[0-9 +()\-]{7,}"
       title="Phone number, digits and + - ( ) allowed"
       required />

For richer validation (cross-field rules, async checks), drop a small inline script:

<script>
  document.querySelector('.sf-form').addEventListener('submit', function(e) {
    var msg = this.querySelector('[name="message"]').value;
    if (msg.length < 10) {
      e.preventDefault();
      alert('Message must be at least 10 characters.');
    }
  });
</script>

Keep validation inside the HTML element. Don't try to wire it through Bubble workflow conditions — that defeats the whole point of skipping native Bubble forms.

Step 6: Sending submissions back into Bubble's database (optional)

If you want submissions to also appear as "Things" in your Bubble database (so they show up in a Bubble admin page), use a splitforms webhook hitting Bubble's Workflow API.

Enable Bubble's Workflow API

  1. In the Bubble editor, go to Settings → API
  2. Check Enable Workflow API and backend workflows
  3. Check Generate endpoints for backend workflows

Create a backend workflow to receive the submission

  1. Open the Backend workflows tab
  2. Add a new API workflow, name it receive_contact
  3. Add parameters matching your form fields: name (text), email (text), message (text)
  4. Set the workflow to expose as a public endpoint (or require authentication — see Bubble's docs in node_modules/next/dist/docs/ if you're wiring this through a Next.js proxy)
  5. Add a "Create a new thing" action: type ContactSubmission, fields mapped from parameters

Wire splitforms webhook to that endpoint

In your splitforms dashboard, go to Integrations → Webhooks, add a new webhook:

  • URL: https://yourapp.bubbleapps.io/version-live/api/1.1/wf/receive_contact
  • Method: POST
  • Payload: default JSON envelope (splitforms sends form field values as top-level keys)

Save. Now every submission emails you AND creates a row in your Bubble database. Webhooks are free on splitforms — most competitors paywall this. The webhook envelope is documented at /docs and /api-reference.

File uploads: Bubble storage vs splitforms native

You have two paths. Pick based on whether the file needs to live in Bubble's database long-term.

Path A: splitforms native file uploads (recommended for "email me this")

Just add a file input to your HTML form and set the enctype:

<form action="https://splitforms.com/api/submit"
      method="POST"
      enctype="multipart/form-data">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
  <input type="text"  name="name"  required />
  <input type="email" name="email" required />
  <input type="file"  name="resume" accept=".pdf,.docx" />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

After Storage is connected, splitforms accepts up to 5 files per submission at 10 MB each. The file is streamed to splitforms' storage. You don't configure anything in Bubble — Bubble never sees the file because the browser POSTs it straight to splitforms.

Path B: Bubble's FileUploader element (for "keep this in my app")

If the uploaded file needs to be a Thing in Bubble (visible in your admin UI, attached to a user account), use Bubble's native FileUploader element separately, then pass the resulting URL into the splitforms form as a hidden field:

<input type="hidden" name="uploaded_file_url"
       value="DYNAMIC_VALUE_FROM_FILEUPLOADER" />

The dynamic value bit is where Bubble's expression editor inserts the FileUploader's value at runtime. The splitforms submission then includes the file URL in the email — your recipient clicks it to download from Bubble's S3 bucket.

For most contact forms, Path A is the right call. Path B is for product forms where the file is operationally part of your app.

Spam protection without CAPTCHA

The botcheck honeypot field in the example above catches roughly 95% of automated spam. Bots fill every visible and hidden field; humans don't see hidden fields. splitforms silently drops submissions where botcheck has a value.

For the remaining 5% (smarter bots, scripted spammers), splitforms runs every submission through an AI classifier on the free tier — no setup, no API keys, no Google CAPTCHA injection into your Bubble page. The classifier looks at message content, sender IP reputation, and submission patterns. Read the detail in AI form spam detection.

If you still want CAPTCHA for compliance reasons (financial sites, regulated industries), the comparison in honeypot vs reCAPTCHA covers the tradeoffs. For most Bubble apps, honeypot + AI is enough.

Deploy considerations: dev vs live environments

Bubble runs your app in two environments: Development (yourapp.bubbleapps.io/version-test) and Live (yourapp.bubbleapps.io or your custom domain). The HTML element content is the same in both, but a few things change.

  • Allowed Domains. In your splitforms dashboard, allow both yourapp.bubbleapps.io and your custom domain. If you skip the dev domain, your test submissions get rejected with 403.
  • Backend workflow URLs. If you're using the webhook → Bubble database route, the URL differs between dev (/version-test/api/1.1/wf/...) and live (/version-live/api/1.1/wf/...). Have two webhooks configured in splitforms or swap the URL when you push to live.
  • Access key. You can use one key across both environments, or create two splitforms forms (one for dev, one for live) so dev test submissions don't pollute your real inbox.
  • Custom domain. After you connect a custom domain in Bubble's settings, add it to splitforms' Allowed Domains. Forget this and your live form goes dark with no obvious error.

The deploy itself is just a Bubble "Deploy current version to live" click — there's no build step for the form because the HTML element ships as-is.

Troubleshooting: iframe sandbox, mobile preview, and 403s

  • Form doesn't submit in Bubble's preview pane. Bubble's editor wraps page previews in an iframe with a sandbox attribute. Form submission targeting an external origin can be blocked. Always test the published URL (Preview button → opens new tab → submit there), not the embedded preview pane.
  • Form submits but no email arrives. Check the splitforms dashboard at splitforms.com/dashboard/submissions — if the submission is there, it's a deliverability issue (check spam folder, set up SPF for your destination domain). If it's not there, the form never reached splitforms — check Allowed Domains and access_key.
  • 403 Forbidden response. Allowed Domains list is missing the domain your Bubble app is on. Add both bubbleapps.io subdomain and custom domain.
  • Mobile preview shows the form but submit button does nothing. Bubble's mobile responsive engine occasionally collapses the HTML element to zero height on small breakpoints if you didn't set a min-height. Open the element's responsive settings and set min-height to 400px or auto.
  • Submission appears twice in inbox. You probably have both a splitforms email destination AND a webhook pointing to a Bubble workflow that also sends an email. Pick one path.
  • Honeypot catching real users. Some Bubble themes ship with auto-fill aggressive CSS that targets all checkboxes — confirm the botcheck input has style="display:none" inline (not via a class that might be overridden) and autocomplete="off" to dodge password managers.
  • Webhook to Bubble returning 401. The Workflow API requires authentication if you didn't expose the endpoint as public. Either toggle public access in the workflow settings or add a Bubble API key as an Authorization: Bearer header in the splitforms webhook config.

Next steps and where to get help

FAQ

Why not just use Bubble's native form workflow?

Native Bubble forms burn workflow units on every submission — input change events, validation conditions, the 'Create a new thing' action, and the 'Send email' action all add up. On a busy contact page that's hundreds of WUs per day for what is fundamentally a free operation. An external form backend like splitforms handles the POST, the email, and the storage outside Bubble's WU meter. Your Bubble app stays cheap and your contact form stops being the most expensive workflow you run.

Will the form work inside Bubble's mobile preview?

Yes, but with a caveat. Bubble's mobile responsive preview renders HTML elements correctly, but iframe sandbox behavior in the preview pane can block form submissions from displaying success states. Always test the real published URL on a real phone, not the in-editor preview. The submission itself goes through fine — only the visual feedback may be off in the preview.

Do I need a paid Bubble plan to use a custom HTML form?

No. The HTML element is available on every Bubble plan, including the free hobby tier. There are no Bubble-side limits on how many times users submit through an HTML element because Bubble isn't processing the submission — your visitor's browser is POSTing directly to splitforms. You get 1,000 free submissions per month through splitforms regardless of which Bubble plan you're on.

Can I still save submissions into Bubble's database?

Yes, two ways. Easiest: configure a splitforms webhook to hit a Bubble API workflow endpoint (Settings → API → Enable Workflow API), and Bubble will receive the JSON and create a new thing. Alternative: use Bubble's API Connector to POST directly from a Bubble workflow on form-submitted events. The webhook route is cleaner because it works even if the user closes the tab right after submitting.

What about file uploads — do I use Bubble's storage or splitforms?

Depends on what you do with the file. If the file just needs to be retained with the submission (resumes, screenshots, project briefs), use splitforms' native file uploads with Storage connected — retained files are private and exposed through signed links. If the file needs to live in your Bubble app (user avatars, app-side documents), use Bubble's FileUploader element and pass the resulting URL to splitforms as a regular text field.

Will the form break when I deploy from development to live?

Not if you set Allowed Domains correctly. In your splitforms dashboard, add both your Bubble subdomain (yourapp.bubbleapps.io) and your custom domain (yourapp.com) to the allow-list. Bubble serves the same HTML element code on both environments, so the access_key stays the same. The only risk is forgetting to include the development domain when you want to test, or forgetting the custom domain after launch.

Does splitforms work with Bubble's responsive engine?

Yes. The HTML element is a regular DOM container — Bubble's responsive engine controls its outer width and visibility, and standard CSS inside the element controls the form layout. You can use flexbox, grid, or media queries inside the HTML element exactly like you would on any other site. The only thing to avoid is setting the HTML element to a fixed pixel height, because the form's content height changes when validation errors appear.

How do I handle the success/thank-you state?

Two options. (1) Set a hidden redirect input pointing to a Bubble page: <input type="hidden" name="redirect" value="https://yourapp.com/thanks" /> — splitforms will 302 the browser there after submit. (2) Use AJAX submission with fetch() and toggle a div inside the HTML element on success. The redirect option is simpler; the AJAX option is smoother because the page doesn't reload.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open