reading time: 12 minutes

For months, this site’s contact form ran on a JavaScript mock — a promise with an 85% success rate, a fake Firestore, zero stored data. The initial plan involved a Supabase backend with Google Apps Script for email notifications. Abandoned. Today, I describe the migration to Firebase Firestore: project creation, security rules, rewriting the JS, cleaning up dead Supabase code. And why this choice says something broader 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`exists, the HTML is ready (name, email, phone, subject, message fields, HTML5 validation, anti-spam honeypot), and the Bootstrap styles are in place. Visually, everything is perfect.

Except that upon 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 sent successfully!" message. But the data goes into a void. No message is stored anywhere.

The situation is worse than a broken form — it is a form that lies.

The Supabase Legacy

The initial plan, documented in`content/draft/integration_formulaire_contact_supabase.adoc`, provided for:

  1. A Supabase database with a`contacts`table and Row Level Security

  2. A server-side`handle_contact_form`RPC

  3. A SQL trigger calling a Google Apps Script webhook

  4. Google Apps Script that sends a Gmail notification email

The corresponding JavaScript code still exists in`script.js`. There is a`SupabaseManager`class that initializes a Supabase client with`SUPABASE_URL` et SUPABASE_KEY`global variables, and a`ContactFormHandler`class that listens for the form 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`supabase`variable that no longer exists. Therefore:

console.error : 'Supabase client library (supabase-js) is not loaded.'

Not only is 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:

  1. `contact.js`listens for submit, calls the Firebase mock

  2. script.js— via`ContactFormHandler`— also listens for submit, calls`SupabaseManager`

Both perform`event.preventDefault() + event.stopPropagation(). Since`contact.js`is loaded first in`footer.thyme, its handler is attached first. It blocks propagation.`ContactFormHandler`will never be triggered.

This isn’t even an active bug — it’s a zombie. Code that never gets the chance to execute.

État initial — double handler et mock

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 "⚠️ Abandoned".

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) rather than a third-party service (Supabase) reduces the dispersion surface.

Firestore in native mode (not Datastore mode) is also closer to the NoSQL document mental model I have in mind: collections, documents, typed fields, server timestamps, built-in security rules.

Phase 1: Creating the Firebase Project

Initialization

Since the Firebase CLI is not installed on my machine, I use the web console:

  1. Go tohttps://console.firebase.google.com/[Firebase Console]

  2. Create a project`cheroliv-contact`(or reuse an existing project)

  3. Activate Firestore in native mode (not Datastore)

  4. Create a database in the`eur3`(Europe) region

For a minimalist use case like ours (a single 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 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 match`request.time(anti-spoofing)

  • `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: Rewriting the Submission JavaScript

The contract is simple:

  1. Read form data

  2. Check the honeypot (field`hp_name`— if it is filled, it’s a bot; we simulate success without sending anything)

  3. Call`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…​})`

  4. Display success or error

The window.FIREBASE dependency

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>

Module scripts execute before`DOMContentLoaded`, so`window.FIREBASE`is guaranteed to be available when the`contact.js`handler triggers. As a precaution, I still add a 5-second polling 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`is filled, simulate success without a Firestore call. The bot thinks it succeeded, but nothing is stored

  • addDoc(collection, {…​})— real Firestore call with`serverTimestamp()` et user_agent

  • Error handling with`try/catch`asynchronous

  • Cleanup of the`finally`(button restoration)

Why`user_agent`? It’s optional, but useful for diagnostics. If a strange message arrives, knowing if it comes from a desktop browser, mobile, or a curl script helps with sorting.

Phase 3: Cleaning 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`DOMContentLoaded`block 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 did, it would fail (no Supabase SDK loaded).

I delete:

  1. The`SupabaseManager`

  2. class`ContactFormHandler`

  3. The`DOMContentLoaded`class

Le reste de script.js`The initialization block in`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager(lines 645-654)

remains intact:

footer.thyme.

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"
};

Phase 4: Configuring the Footeralready has the Firebase boilerplate but with placeholder values. I replace them:With the actual values retrieved from

Project Settings > General > Your apps > Web app`apiKey`in the Firebase console.site.yml`The values are sensitive (.gitignore`is public by design in Firebase, but I prefer not to commit them in plain text). I store them in`bakery`(already in

) and the`footer.thyme`plugin injects them into the template via logic to be added to the build side../gradlew serve`For now, I put them directly in`site.yml— the

L'`apiKey`build will load them locally. Upon deployment, I will migrate the injection toor to a Gradle variable.Firebase API keys arenota secret. They are public by design. What protects your data are the`.env`Firestore security rules

, not the API key. Do not put it in a

Architecture finale — Firebase Firestore

loaded server-side — it is intended to be exposed to the browser.

Phase 5: Final Architecture`bakery`What this migration says about dogfooding`AGENT.adoc`This site is generated by my own Gradle plugin`./gradlew serve`. The contact form lives inside the site. The Supabase → Firebase migration is documented in

, it is discussed in the backlog, it is tested via

, and it generates a blog post (the one you are reading).

This is pure dogfooding. The site is the product of the plugin, the plugin is the product of the developer, the developer documents the process within the site itself.

The loop is closed.

The fact of having dragged a mock for months (entire sessions where the form lied silently) made me realize something: the backlog of a personal static site is never "finished". There is always a priority US, always a draft article, always a commented-out section in a template. Discipline is not about finishing everything — it’s about finishing what is visible to the user.

A broken contact form is worse than no form at all. It is a broken promise.

Summary of changes

File

blog/2026/0113_*.adoc

Modification

Impact

assets/js/contact.js

Article creation

Documentation

assets/js/script.js

Rewriting (mock → real Firestore)

Functional

templates/footer.thyme

Removal of SupabaseManager + ContactFormHandler + init block

Cleanup

Replacement of placeholder config → real values

  • ConfigurationNext steps (backlog)onCreate`Email notification`contact_messages: A Cloud Function

  • onthat sends an email via SendGrid. The form stores data, but I am not notified. Medium priority — messages are visible in the Firebase console.

  • Client-side rate limiting: Add a localStorage timestamp to prevent rapid multiple submissions. The honeypot blocks naive bots; a rate limiter would block slightly smarter bots.`./gradlew serve`Tests

: A Playwright test that submits the form and verifies that the document appears in Firestore. For now, I test manually via

Related articles