Skip to main content

Devlog · Phase 0 · 4/7

The Waitlist Signup, End to End

React 19 optimistic form, captcha-free anti-bot, four statuses for one POST, and a counter that counts only the confirmed: Aubia's waitlist signup journey, trade-off by trade-off.

Published on 3 August 20266 min read
  • waitlist
  • react
  • security
  • gdpr

The previous article showed that aubia.dev has no database, and that the signup goes out to aubia.cloud through a signed proxy. Here I zoom in one notch to tell the whole journey, from the email field to the confirmation email, and above all the trade-offs behind each decision. A signup looks trivial. I made it the most carefully built piece of the site.

The animated double opt-in flow: visitor POST to aubia.dev, HMAC-signed hand-off to api.aubia.cloud, email with a one-time token, confirmation click within 48 hours.

An Optimistic Form

The form is a React 19 component wired to Inertia. Nothing reloads: on submit, an optimistic signal fires to a global toast, built with React 19's useOptimistic hook. The interface confirms immediately, then the real server response reconciles the state, doing nothing on success and rolling back the display on error.

One detail refines the input: the moment you leave the field, a suggestion routine spots common typos in the domain and offers a clickable correction, the kind that turns gmial.com into gmail.com. None of this is cosmetic. Every mistyped email is a lost signup that will never get its confirmation.

Accessibility follows the same demand: an associated label, aria-invalid on the field in error, role="alert" on the messages, and a suggestion linked by aria-describedby.

Anti-Bot Without Friction: Honeypot Over Captcha

No captcha, to start with. A captcha protects against bots by taxing every human with a chore, and it brings a third party into the journey. I preferred two invisible traps. The first is a decoy field, hidden in CSS, that only a bot fills in and that the server rejects if it isn't empty. The second is a timing trap: a timestamp set when the page mounts, compared against the moment of submission. A bot that posts right on the heels of the load gives itself away.

public function rules(): array
{
    return [
        'email' => ['required', 'email:rfc', 'max:254'],
        '_fax' => ['prohibited'],              // decoy field, must stay empty
        'rendered_at' => ['required', 'integer', 'min:1'],
        'locale' => ['required', 'in:fr,en,es,de,it,pt'],
        // ... utm nullable
    ];
}

public function withValidator(Validator $validator): void
{
    $validator->after(function (Validator $validator): void {
        $nowMs = (int) (microtime(true) * 1000);

        if (($nowMs - (int) $this->input('rendered_at')) < self::MIN_FILL_DURATION_MS) {
            $validator->errors()->add('rendered_at', 'Submitted too fast.');
        }
    });
}

A named throttle rounds out the setup on the server side, with three simultaneous limits: one per IP address, a stricter one per email address, and a global ceiling that absorbs a distributed attack. You see none of this. A bot, though, runs into all three at once.

Four Statuses for a Single POST

One and the same action, signing up, covers several real situations. The cloud distinguishes them and the website translates them into precise messages, rather than a vague "it's sent." The proxy reads the status returned by aubia.cloud and maps it to a business enum.

return match (true) {
    $httpStatus >= 500 => self::CloudDown,
    $cloudStatus === 'confirmation_sent' => self::ConfirmationSent,
    $cloudStatus === 'confirmation_resent' => self::ConfirmationResent,
    $cloudStatus === 'already_pending' => self::AlreadyPending,
    $cloudStatus === 'already_confirmed' => self::AlreadyConfirmed,
    default => self::CloudDown,
};

Four success outcomes: a new signup, a confirmation resend after a delay, a signup already pending, a signup already confirmed. Each deserves its own message, because a visitor who has already confirmed and signs up again shouldn't think they've just started over.

The Double Opt-In, or Why I Prefer a Smaller List

Then comes the choice that structures everything else. I could have recorded every email and called the signup done. I chose the double opt-in: the cloud sends an email with a single-use confirmation link, valid for forty-eight hours, and until that link is clicked, no communication goes out. Unconfirmed signups are purged after thirty days.

This choice deliberately shrinks the list. That's the point. A list of a thousand genuinely confirmed addresses is worth more than a list of five thousand I know nothing about. It proves a dated consent, it validates that the address exists, and it guarantees that when the beta 0.1 launches I'm talking to people who really raised their hand. The list's quality matters more than its displayed volume.

A Confirmation With Several Outcomes, Kept Out of the Index

Clicking the link lands on a dedicated aubia.dev page. It reads the token from the URL, queries the cloud, and shows one of five outcomes: confirmed, already confirmed, invalid link, not found, or expired. This page, like the unsubscribe one, is explicitly removed from search engine indexing.

<SeoHead
    path="/waitlist/confirmed"
    robots="noindex"
    title={t('waitlist.confirmed.seo_title')}
/>

These URLs carry a single-use token and have no value in search. Excluding them from the index keeps a confirmation link from lingering in results and protects the cleanliness of the ranking. Unsubscribing, for its part, happens in one click from every email, in line with the RFC 8058 standard, and its logic lives on the cloud side, never on the website.

The security of this page rests on the semantics of the token itself: single-use, hashed on the cloud side, erased after consumption, expired after forty-eight hours.

A Counter That Counts Only the Confirmed

That leaves social proof. The landing shows a signup count, but it counts only the people who confirmed, never the pending addresses. And it doesn't show until a minimum threshold is reached: below that, the component renders nothing. Better to show no number than to show a low, discouraging one early in the campaign. An honest counter can't have it both ways. Either it inflates the number with unconfirmed signups and it lies, or it shows only what's real.

The whole signup logic leans the same way: the real, measured soberly, and nothing that isn't provable. On the GDPR side, it comes down to a single sentence under the form, a single purpose, the invitation to the beta, and no personal data at rest on the website.

All This for an Email Field

This journey draws on several building blocks: a form, a validation, a status enum, a proxy, a counter. On such a small repo, all of that deserves to be arranged with method rather than scattered. That organization into modules, I open it up right after.

To be notified when the beta 0.1 opens, join the waitlist.

This build is chronicled here, article after article. Come aboard: your feedback will shape what comes next.

Join the waitlist