Why a Chrome extension still needs a contact form
A Chrome extension doesn't have a server of its own. manifest.json describes a popup and maybe a background service worker, but there's nowhere inside the extension for a <form>to submit to — no PHP, no Node process, nothing listening on a port. That's fine until something breaks or a user wants a feature you didn't build.
Without a feedback channel, that user doesn't file a GitHub issue — they just uninstall, one more quiet loss with no explanation. A hosted free form submission API closes that gap with zero backend: the popup collects a message, fetch() POSTs it out, and the submission lands where you can read it — a complete Chrome extension feedback form in three small files.
popup.html: the form markup
The popup is a normal, tiny HTML page — Chrome renders it in a fixed overlay, typically 300-400px wide, so keep the markup minimal: a message field, a hidden access_key, and a honeypot field for the default spam stack.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Send feedback</title>
<style>
body { width: 280px; font-family: system-ui, sans-serif; padding: 14px; }
label { display: block; font-size: 12px; margin: 10px 0 4px; color: #444; }
input, textarea { width: 100%; box-sizing: border-box; padding: 6px 8px; font-size: 13px; }
button { margin-top: 12px; width: 100%; padding: 8px; cursor: pointer; }
#form-status { margin-top: 10px; font-size: 12px; }
</style>
</head>
<body>
<form id="feedback-form">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<label for="email">Your email (optional)</label>
<input id="email" name="email" type="email" />
<label for="message">What happened?</label>
<textarea id="message" name="message" rows="4" required></textarea>
<!-- Honeypot spam trap -->
<input name="botcheck" type="checkbox" style="display:none" tabindex="-1" />
<button type="submit" id="submit-btn">Send feedback</button>
<div id="form-status" role="status" aria-live="polite"></div>
</form>
<script src="popup.js"></script>
</body>
</html>The <style> block in the head is fine under Manifest V3's default policy, which restricts scripts, not styles. The <script src="popup.js"> tag at the bottom is the only place JavaScript is allowed to load from.
popup.js: submit with fetch
Everything interactive lives here — Manifest V3's CSP won't run code written directly inside popup.html. The handler follows the same fetch-then-inline-update contract as any AJAX form (see how to send form data to an API for the pattern outside an extension): grab the FormData, POST it, check response.ok, update the status div.
const form = document.getElementById('feedback-form');
const button = document.getElementById('submit-btn');
const status = document.getElementById('form-status');
form.addEventListener('submit', async (e) => {
e.preventDefault();
button.disabled = true;
button.textContent = 'Sending...';
status.textContent = '';
try {
const formData = new FormData(form);
// Extension-specific context fields — see below.
formData.append('extension_version', chrome.runtime.getManifest().version);
formData.append('locale', navigator.language || 'unknown');
formData.append('context', 'chrome-extension-popup');
const response = await fetch('https://splitforms.com/api/submit', {
method: 'POST',
body: formData,
});
if (response.ok) {
status.textContent = 'Thanks - got it.';
status.style.color = 'green';
form.reset();
} else {
throw new Error('Server error');
}
} catch (error) {
status.textContent = 'Something went wrong. Try again in a moment.';
status.style.color = '#dc2626';
} finally {
button.disabled = false;
button.textContent = 'Send feedback';
}
});Swap in your own access key from the splitforms dashboard and the popup has a working submit flow — no redirect, no page navigation, just the fetch call any web page would use.
manifest.json: the Manifest V3 config
The manifest wires the popup up, and one key does the real work here — action.default_popup:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A short description of what this extension does.",
"action": {
"default_popup": "popup.html",
"default_title": "Send feedback"
}
}That's a complete, valid manifest for an extension whose only UI is this popup — no permissions array, no background service worker required. Between these three files, that's the entire Manifest V3 form submission setup; the next section covers when you'd add host_permissions back in.
Manifest V3 gotchas: CSP, remote scripts, and CORS
Every extension page — popup.html included — ships under Manifest V3's default content-security-policy: script-src 'self'; object-src 'self';. In practice that blocks three things people trip over:
- Inline
<script>tags written directly inpopup.html - Inline event-handler attributes, like
onclick="..." - A
<script src>pointing at a remote host — a CDN, a CMS, anything not packaged inside the extension
That's why the handler lives entirely in popup.js, loaded with a plain <script src="popup.js"> tag instead of sitting inline — not a splitforms-specific restriction, just how every Manifest V3 extension works.
That rule blocks loading script code, not fetch() calls — a fetch() to a remote API from popup.js isn't a "remote script." Whether it succeeds is a CORS question, not CSP: splitforms' contact form API accepts posts from any origin, so the plain fetch() POST above needs no extra config. Pointed at a stricter endpoint, or want the host declared explicitly? Add it under host_permissions:
{
"host_permissions": ["https://splitforms.com/*"]
}That grants elevated fetch access to the host — Chrome's standard way of letting an extension bypass CORS. Add it if you see a CORS error; it isn't required otherwise.
Manifest V3 specifics move over time — confirm current CSP and host_permissions behavior in Chrome's own docs before publishing.
Success and error states inside a tiny popup
A popup is small and fragile — typically 300-400px wide, and it closes the instant a user clicks outside it. That rules out the redirect pattern some contact forms use: there's no page to navigate to, and no guarantee the popup survives long enough to show one anyway. Everything happens inline, without leaving popup.html.
The fetch/JSON flow in popup.js above already fits that constraint — the same pending-then-inline-update pattern covered in more depth in splitforms' AJAX contact form guide: disable the button, show a sending state, swap in a thank-you message on response.ok, show a plain error on failure. No redirect field, no navigation.
One habit worth avoiding: don't call window.close() right after a successful submission. It erases the confirmation before most people register it — leave the message in place and let the user close the popup themselves.
Where submissions go: dashboard and email
A submission that hits https://splitforms.com/api/submit from a popup lands in the same place one from a website form would: the splitforms dashboard, plus an email notification if you've turned one on. Email notificationsare free on every plan, including free, so a side-project extension doesn't need to pay just to get pinged about a bug.
The free plan covers 500 submissions a month across unlimited forms — plenty for a feedback popup. Pro is $5/month, Pro is $5/month, and a 3-Year plan runs $59 if the extension grows into real volume.
Publishing notes: the Chrome Web Store privacy disclosure
One remaining step has nothing to do with code. If the form collects anything that identifies a person — an email address, here — the Chrome Web Store dashboard requires you to declare that under the listing's privacy practices: what you collect, why, and a link to a privacy policy. That applies no matter where the data ends up.
Skip it and an update can bounce in review for an undisclosed data-collection practice — slower than filling out the disclosure correctly the first time.
FAQ
Does a Chrome extension need its own backend to have a contact form?
No. A Chrome extension can't run server code, but its popup can still submit a form to any endpoint on the web. Point popup.js's fetch call at a hosted backend like splitforms (https://splitforms.com/api/submit) with your access key, and the extension has a working feedback channel with no server to run.
Why can't I put an onclick handler or inline <script> in popup.html?
Manifest V3's default CSP (script-src 'self') blocks inline <script> tags and inline event handlers on every extension page, popup.html included. Put the logic in a separate popup.js file and attach it with addEventListener instead.
Do I need host_permissions in manifest.json to submit the form?
Not necessarily. If the endpoint's CORS policy is open, as splitforms' submit endpoint is, a plain fetch() POST from the popup works with no host_permissions entry. Add it for the endpoint's domain only if you hit a CORS error, or want the host declared explicitly.
Does this work in Firefox, or is it Chrome-only?
The pattern is browser-agnostic. Firefox's WebExtensions support both Manifest V2 and V3 and kept V2 around longer than Chrome did, but popup.html/popup.js/fetch() doesn't depend on which one you target — fetch is standard, and Firefox's CSP blocks inline scripts the same way. Manifest keys differ slightly (action vs. browser_action); the form logic carries over unchanged.
Can the background service worker submit the form instead of the popup?
Yes — useful if a submission needs to survive the popup closing, since popup JavaScript is torn down the instant it loses focus, which can abort an in-flight fetch. Routing through the service worker instead — the popup messages it with chrome.runtime.sendMessage, and it calls fetch — keeps the request alive independent of the popup.
What happens to hidden fields like extension version and locale?
They arrive as ordinary form fields, no different from name or email — visible in the splitforms dashboard and any email notification alongside the rest of the submission. Nothing extension-specific happens to them on the backend; they're just extra context you chose to send.
Do I need a Chrome Web Store privacy disclosure for a feedback form?
If the form collects anything identifying — an email address is the common case — yes. The Chrome Web Store dashboard requires a privacy-practices disclosure describing what you collect and why, plus a link to a privacy policy, before an update clears review.
Will visitors hit a CAPTCHA submitting from the popup?
Not by default. splitforms' spam stack — honeypot field, time-trap, and IP-based rate limiting — runs server-side with no visible challenge, which matters more in a 300px popup than a full page. Add a visible CAPTCHA only if measured spam volume actually warrants it.
Building the popup now? Read the docs to grab a free access key — splitforms is free for 500 submissions a month, with honeypot, time-trap, and rate-limit spam protection on by default.