Migration Supabase → Firebase : Connect a Contact Form to Firestore Without Backend
Published on 29 April 2026
- The scene: a form that stores nothing
- Why Firebase instead of Supabase?
- Phase 1: Create the Firebase project
- Phase 2: Rewrite the submission JavaScript
- Phase 3: Clean up dead Supabase code
- Phase 4 : Configure the footer
- Phase 5: Final architecture
- What this migration says about dogfooding
- Next steps (backlog)
- References
For months, the contact form on this site ran on a JavaScript mock — an 85% success promise, a fake Firestore, zero data stored. The initial plan had a Supabase backend with Google Apps Script for email notifications. Abandoned. Today, I tell the story of migrating to Firebase Firestore: project creation, security rules, JS rewriting, cleaning up dead Supabase code. And why this choice says something larger about development philosophy.
- toc
-
[]
The scene: a form that stores nothing
This site is generated by JBake, my Gradle plugin`bakery`. It is 100% static — no backend, no database. Except that I have a contact form. The page`contact.html`it exists, the HTML is ready (fields name, email, phone, subject, message, HTML5 validation, anti-spam honeypot), the Bootstrap styles are in place. Visually, everything is perfect.
Except that at submission, nothing happens.
const firebaseMock = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.85) {
resolve({ status: 201, message: 'Message stored in Firestore.' });
} else {
reject({ status: 500, message: 'Firestore write failed.' });
}
}, 1500);
});
A mock. A promise that pretends. The user sees a spinner, then a message « Message sent successfully! ». But the data goes into the void. No message is stored anywhere.
The situation is worse than a broken form — it’s a form that lies.
Supabase heritage
The initial plan, documented in`content/draft/integration_formulaire_contact_supabase.adoc`, expected :
-
A Supabase database with table`contacts`and Row Level Security
-
an RPC`handle_contact_form`server side
-
A SQL trigger calling a Google Apps Script webhook
-
Google Apps Script that sends a Gmail notification email
The corresponding JavaScript code still exists in`script.js`. There is a class`SupabaseManager`that initializes a Supabase client with global variables`SUPABASE_URL` et SUPABASE_KEY, and a class`ContactFormHandler`who listens to the form’s submit event and calls`SupabaseManager.submitContactForm()`.
Problem: these global variables are no longer injected into the footer. the`<script src="supabase-js">`has been removed. The code calls`supabase.createClient()`on a variable`supabase`that no longer exists. So:
console.error : 'Supabase client library (supabase-js) is not loaded.'
Not only are the data not stored, but the submission code is dead.
The phantom double submission
To make matters worse, there is asilent competitionbetween two handlers on the same form :
-
`contact.js`listen to the submit, call the mock Firebase
-
script.js— via`ContactFormHandler`— also listen to the submit, call`SupabaseManager`
Both do`event.preventDefault() + event.stopPropagation(). As`contact.js`is loaded first in`footer.thyme, its handler is attached first. It blocks propagation.`ContactFormHandler`will never be triggered.
It’s not even an active bug — it’s a zombie. Code that never gets a chance to run.
@startuml skinparam backgroundColor #FEFEFE title Initial State — Contact Form rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme\n(Firebase SDK with placeholder config)" as FOOT rectangle "contact.js\n(mock Firebase, 85% success)" as CONT rectangle "script.js\n(SupabaseManager + ContactFormHandler, dead)" as SCRIPT rectangle "User" as USER USER -> FORM : Soumission du formulaire FORM -> CONT : submit event (attaché en premier) CONT -> CONT : preventDefault + stopPropagation CONT -> CONT : mock Promise (1.5s, aléatoire) note right of CONT ⚠️ Aucune donnée stockée end note FORM ..> SCRIPT : submit event (bloqué par stopPropagation) note right of SCRIPT : ❌ Jamais déclenché note right of SCRIPT : ❌ supabase-js non chargé @enduml
Why Firebase instead of Supabase?
The migration decision is documented in`AGENT.adoc`:
Firebase is now chosen for the following reasons: better free plan, native Firestore, integrated Cloud Functions, more suitable Google ecosystem. The existing Supabase implementation is marked « ⚠️ Deprecated ».
Beyond the free plan, there is an architectural reason. This site lives in the Google ecosystem: the target repository is`cheroliv.github.io`, the CNAME points to GitHub Pages, the Gradle build pushes to GitHub via JGit. Adding a Google service (Firebase) instead of a third‑party service (Supabase) reduces the attack surface.
Firestore in native mode (not in Datastore mode) is also closer to the document NoSQL mental model I have in mind: collections, documents, typed fields, server timestamps, built-in security rules.
Phase 1: Create the Firebase project
Initialization
The Firebase CLI not being installed on my machine, I go through the web console:
-
Go onhttps://console.firebase.google.com/[Firebase Console]
-
Create a project`cheroliv-contact`(or reuse an existing project)
-
Enable Firestore in native mode (not Datastore)
-
Create a database in region`eur3`(Europe)
For a minimalist usage like ours (one collection, public write), native mode is the right choice. No need for complex Datastore rules.
Firestore Security Rules
The form is public — anyone can send a message. But I want to limit abuse:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /contact_messages/{messageId} {
// Lecture : admin uniquement (authentifié)
allow read: if request.auth != null;
// Écriture : publique, mais limitée
allow create: if request.auth == null
&& request.resource.data.name is string
&& request.resource.data.name.size() >= 1
&& request.resource.data.name.size() <= 100
&& request.resource.data.email is string
&& request.resource.data.email.matches('.*@.*\\..*')
&& request.resource.data.email.size() <= 254
&& request.resource.data.subject is string
&& request.resource.data.subject.size() >= 3
&& request.resource.data.subject.size() <= 200
&& request.resource.data.message is string
&& request.resource.data.message.size() >= 10
&& request.resource.data.message.size() <= 5000
&& request.resource.data.created_at == request.time
&& request.resource.data.user_agent is string
&& request.resource.data.user_agent.size() <= 500;
}
}
}
Key points:
-
allow read— only authenticated users can read the messages (me, via the Firebase console) -
allow create— anyone can create a document, but with field validation -
Server-side validation: min/max sizes, email format,
created_at`must correspond to`request.time(anti-falsification) -
`user_agent`is sent for traceability (not critical but useful)
These rules are stricter than a simple`allow write: if true;`. They prevent an attacker from injecting huge payloads or malformed fields.
Phase 2: Rewrite the submission JavaScript
The contract is simple:
-
Read the form data
-
Check the honeypot (field`hp_name`— if it’s filled, it’s a bot, we simulate a success without sending anything)
-
Call`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
Display success or error
The dependency window.FIREBASE.
in`footer.thyme`, a module script initializes the Firebase SDK and exposes a global object :
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.0/firebase-app.js";
import { getFirestore, collection, addDoc, serverTimestamp }
from "https://www.gstatic.com/firebasejs/11.6.0/firebase-firestore.js";
const firebaseConfig = { /* valeurs réelles */ };
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
window.__FIREBASE__ = { db, collection, addDoc, serverTimestamp };
</script>
The module scripts execute before`DOMContentLoaded`, so`window.FIREBASE`is guaranteed available when the handler`contact.js`is triggered. As a precaution, I still add a 5‑second polling just in case the CDN is slow.
The new `contact.js
document.addEventListener('DOMContentLoaded', function () {
'use strict';
const form = document.getElementById('contact-form');
if (!form) return;
const submitButton = form.querySelector('button[type="submit"]');
const successMessage = document.getElementById('contact-success-message');
const errorMessage = document.getElementById('contact-error-message');
// Éléments de validation
const nameInput = form.querySelector('input[name="name"]');
const emailInput = form.querySelector('input[name="email"]');
const phoneInput = form.querySelector('input[name="phone"]');
const subjectInput = form.querySelector('input[name="subject"]');
const messageInput = form.querySelector('textarea[name="message"]');
const honeypotInput = form.querySelector('input[name="hp_name"]');
/**
* Attend que window.__FIREBASE__ soit disponible.
* Timeout de 5 secondes — si le CDN Firebase est lent, on abandonne.
*/
function waitForFirebase(timeoutMs = 5000) {
return new Promise((resolve, reject) => {
if (window.__FIREBASE__) {
resolve(window.__FIREBASE__);
return;
}
const start = Date.now();
const interval = setInterval(() => {
if (window.__FIREBASE__) {
clearInterval(interval);
resolve(window.__FIREBASE__);
} else if (Date.now() - start > timeoutMs) {
clearInterval(interval);
reject(new Error('Firebase SDK non disponible après timeout'));
}
}, 100);
});
}
// --- Validation (identique à l'existant) ---
function validateForm() {
nameInput.setCustomValidity('');
emailInput.setCustomValidity('');
if (phoneInput) phoneInput.setCustomValidity('');
subjectInput.setCustomValidity('');
messageInput.setCustomValidity('');
if (nameInput.value.trim().length < 1) {
nameInput.setCustomValidity('Veuillez saisir votre nom.');
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(emailInput.value.trim())) {
emailInput.setCustomValidity('Veuillez saisir une adresse email valide.');
}
if (phoneInput && phoneInput.value.trim() !== '') {
const phonePattern = /^\d{10,15}$/;
if (!phonePattern.test(phoneInput.value.trim())) {
phoneInput.setCustomValidity('Veuillez saisir un numéro valide (10 à 15 chiffres).');
}
}
if (subjectInput.value.trim().length < 3) {
subjectInput.setCustomValidity('Veuillez saisir un sujet (3 caractères minimum).');
}
if (messageInput.value.trim().length < 10) {
messageInput.setCustomValidity('Veuillez saisir un message (10 caractères minimum).');
}
form.classList.add('was-validated');
return form.checkValidity();
}
// --- Handler de soumission ---
form.addEventListener('submit', async function (event) {
event.preventDefault();
event.stopPropagation();
if (!validateForm()) return;
// Honeypot : si rempli, simuler un succès sans rien envoyer
if (honeypotInput && honeypotInput.value.trim() !== '') {
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
return;
}
// UI : état d'envoi
submitButton.disabled = true;
submitButton.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Envoi en cours...
`;
successMessage.style.display = 'none';
errorMessage.style.display = 'none';
try {
const fb = await waitForFirebase();
const messagesCollection = fb.collection(fb.db, 'contact_messages');
await fb.addDoc(messagesCollection, {
name: nameInput.value.trim(),
email: emailInput.value.trim(),
phone: phoneInput ? phoneInput.value.trim() : '',
subject: subjectInput.value.trim(),
message: messageInput.value.trim(),
created_at: fb.serverTimestamp(),
user_agent: navigator.userAgent.substring(0, 500)
});
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
} catch (error) {
console.error('Erreur Firestore:', error);
errorMessage.style.display = 'block';
} finally {
submitButton.disabled = false;
submitButton.innerHTML = `
<i class="bi bi-send me-2"></i>
Envoyer le Message
`;
}
}, false);
});
Changes compared to the mock:
-
waitForFirebase()— polling with timeout, robust even if the CDN is slow -
honeypot— if the hidden field`hp_name`Filled, simulate an unequivocal Firestore success. The bot thinks it succeeded but nothing is stored. -
addDoc(collection, {…})— real Firestore call with`serverTimestamp()` etuser_agent -
Error handling with`try/catch`asynchronous
-
Cleaning of the`finally`(restauration du bouton) ⇒ (restoration of the button)
|
Why`user_agent`? It’s optional, but useful for diagnostics. If a strange message arrives, knowing whether it comes from a desktop browser, mobile, or a curl script helps with sorting. |
Phase 3: Clean up dead Supabase code
`script.js`contains 250 lines of dead code :
-
SupabaseManager(lines 417-481) — 65 lines -
ContactFormHandler(lines 490-551) — 62 lines -
Initialization block (lines 645-654) — 10 lines
Total: ~140 lines to delete.
The block`DOMContentLoaded`creates a`SupabaseManager`then a`ContactFormHandler`attached to the form. As explained above, this code never executes (blocked by`contact.js`), and even if it were executing, it would fail (no Supabase SDK loaded).
I delete :
-
The class`SupabaseManager`
-
The class`ContactFormHandler`
-
The initialization block in`DOMContentLoaded`(lines 645-654)
Le reste de script.js`is intact :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.
Phase 4 : Configure the footer
`footer.thyme`already has the Firebase boilerplate but with placeholder values. I’m replacing:
const firebaseConfig = {
apiKey: "REMPLACER_PAR_VOTRE_API_KEY",
authDomain: "REMPLACER_PAR_VOTRE_AUTH_DOMAIN",
projectId: "REMPLACER_PAR_VOTRE_PROJECT_ID",
storageBucket: "REMPLACER_PAR_VOTRE_STORAGE_BUCKET",
messagingSenderId: "REMPLACER_PAR_VOTRE_SENDER_ID",
appId: "REMPLACER_PAR_VOTRE_APP_ID"
};
By the real values retrieved sinceProject Settings > General > Your apps > Web appin the Firebase console.
The values are sensitive (apiKey est publique par design chez Firebase, mais je préfère ne pas les commiter en clair). Je les stocke dans site.yml(already in`.gitignore`) and the plugin`bakery`injects them into the template via a logic to be added on the build side
For now, I’m putting them directly in`footer.thyme`— the build`./gradlew serve`will load them locally. At deployment, I will migrate the injection to`site.yml`or toward a Gradle variable.
|
L'`apiKey`Firebase is notnota secret. It is public by design. What protects your data are theFirestore security rules, not the API key. Don’t put it in a`.env`Loaded server-side—it is intended to be exposed to the browser. |
Phase 5: Final architecture
@startuml
skinparam backgroundColor #FEFEFE
title Final Architecture — Firebase Contact Form
actor Utilisateur as USER
package "Browser" #E8F5E9 {
rectangle "contact.thyme\n(HTML, honeypot)" as FORM
rectangle "contact.js\n(validation + Firestore)" as CONT
rectangle "footer.thyme\n(Firebase SDK init)" as FOOT
}
cloud "Firebase" #BBDEFB {
rectangle "Firestore\n(contact_messages)" as FS
}
FORM --> CONT : submit event
CONT --> FOOT : window.__FIREBASE__.addDoc()
FOOT --> FS : Insert document\n(règles de sécurité validées)
note right of FS
Firestore Rules :
- create: public, validé
- read: auth uniquement
end note
@enduml
What this migration says about dogfooding
This site is generated by my own Gradle plugin`bakery`. The contact form lives inside the site. The Supabase → Firebase migration is documented in`AGENT.adoc`, it is discussed in the backlog, it is tested via`./gradlew serve`, and it generates a blog post (the one you are reading).
It’s pure dogfooding. The site is the product of the plugin, the plugin is the product of the developer, the developer documents the process on the site itself.
The loop is closed.
Having kept a mock around for months (entire sessions where the form silently lied) made me realize something: the backlog of a personal static site is never 'finished'. There’s always a prioritized user story, always a draft article, always a commented section in a template. Discipline isn’t about finishing everything — it’s about finishing what’s visible to the user.
A broken contact form is worse than having no contact form at all. It’s an unfulfilled promise.
Summary of changes
File |
Modification |
Impact |
|
Creation of the article |
Documentation |
|
Rewrite (mock → real Firestore) |
Functional |
|
Deletion SupabaseManager + ContactFormHandler + init block |
Cleaning |
|
Replacement config placeholder → actual values |
Configuration |
Next steps (backlog)
-
Notification email: A Cloud Function`onCreate`on`contact_messages`who sends an email via SendGrid. The form stores, but I am not notified. Medium priority — the messages are visible in the Firebase console.
-
Rate limiting client-side: Add a localStorage timestamp to prevent multiple submissions in rapid succession. The honeypot blocks naive bots, a rate limiter would block slightly more clever bots.
-
Tests: A Playwright test that submits the form and verifies that the document appears in Firestore. For now, I’m testing manually via`./gradlew serve`.