Mailbeam
Vue 3 + Composition APIIntermediate20 minutesUpdated January 2025

Email Verification in Vue 3

This tutorial builds a reusable useEmailVerification composable for Vue 3's Composition API. It gives users instant feedback as they type — valid, invalid, or "did you mean gmail.com?" — while keeping your secret API key safely on the server.

What you'll build

  • A small server endpoint that proxies verification (so the API key never reaches the browser)
  • A debounced useEmailVerification composable returning reactive state
  • A signup form with inline status, typo suggestions, and a submit-time re-check

Prerequisites

  • Vue 3 (Vite or Nuxt) with TypeScript
  • A backend route you control (Node, Python, etc.) for the proxy
  • A Mailbeam API key (sign up free)

Step 1 — Add a server-side verification endpoint

Never call Mailbeam directly from the browser — that would expose your key. Add a thin proxy on your backend. Here it is in Node/Express:

// server/routes/verify-email.js
import Mailbeam from "@mailbeam/sdk";

const mb = new Mailbeam({ apiKey: process.env.MAILBEAM_KEY });

export async function verifyEmail(req, res) {
  const { email } = req.body;
  if (!email) return res.status(400).json({ error: "email required" });

  try {
    const { valid, score, reason, didYouMean } = await mb.verify(email);
    res.json({ acceptable: valid && score >= 60, reason, didYouMean });
  } catch (err) {
    // Fail open: don't block the UI on an API error
    res.json({ acceptable: true, reason: null, didYouMean: null });
  }
}

Step 2 — Create the composable

// src/composables/useEmailVerification.ts
import { ref, watch } from "vue";

type Status = "idle" | "checking" | "valid" | "invalid" | "error";

export function useEmailVerification(emailRef: import("vue").Ref<string>) {
  const status = ref<Status>("idle");
  const reason = ref<string | null>(null);
  const suggestion = ref<string | null>(null);

  let controller: AbortController | null = null;
  let timer: ReturnType<typeof setTimeout> | null = null;

  async function check(email: string) {
    controller?.abort();
    controller = new AbortController();
    status.value = "checking";

    try {
      const res = await fetch("/api/verify-email", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
        signal: controller.signal,
      });
      const data = await res.json();
      suggestion.value = data.didYouMean ?? null;
      if (data.acceptable) {
        status.value = "valid";
        reason.value = null;
      } else {
        status.value = "invalid";
        reason.value = data.reason ?? "invalid_email";
      }
    } catch (err) {
      if ((err as Error).name === "AbortError") return;
      status.value = "error"; // fail open in the UI
      reason.value = null;
    }
  }

  // Debounce: wait 500ms after the user stops typing
  watch(emailRef, (email) => {
    if (timer) clearTimeout(timer);
    suggestion.value = null;

    if (!email || !email.includes("@")) {
      status.value = "idle";
      return;
    }
    timer = setTimeout(() => check(email), 500);
  });

  return { status, reason, suggestion };
}

Step 3 — Wire it into a form

<!-- src/components/SignupForm.vue -->
<script setup lang="ts">
import { ref, computed } from "vue";
import { useEmailVerification } from "@/composables/useEmailVerification";

const email = ref("");
const password = ref("");
const { status, reason, suggestion } = useEmailVerification(email);

const canSubmit = computed(
  () => status.value === "valid" && password.value.length >= 8
);

async function onSubmit() {
  // Re-check server-side on submit — never trust client state alone
  const res = await fetch("/api/signup", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: email.value, password: password.value }),
  });
  if (!res.ok) {
    // surface server validation error
  }
}

function applySuggestion() {
  if (suggestion.value) email.value = suggestion.value;
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <label for="email">Email</label>
    <input id="email" v-model="email" type="email" autocomplete="email" />

    <p v-if="status === 'checking'" class="hint">Checking…</p>
    <p v-else-if="status === 'valid'" class="hint ok">✓ Looks good</p>
    <p v-else-if="status === 'invalid'" class="hint err">
      That email looks undeliverable ({{ reason }}).
    </p>

    <p v-if="suggestion" class="hint">
      Did you mean
      <button type="button" @click="applySuggestion">{{ suggestion }}</button>?
    </p>

    <input v-model="password" type="password" autocomplete="new-password" />

    <button type="submit" :disabled="!canSubmit">Create account</button>
  </form>
</template>

Step 4 — Always re-verify on the server

The composable improves UX, but client state can be faked or skipped. Your /api/signup handler must run the same verification before creating the user — reuse the proxy logic from Step 1.

Best practices

PracticeWhy
Debounce ~500msOne call per pause, not per keystroke
Abort in-flight requestsAvoids out-of-order results as users type
Proxy through your serverKeeps the API key off the client
Re-verify on submitClient state is advisory, not authoritative
Surface didYouMeanRecovers users from typos like gmial.com

Production checklist

  • Verification endpoint rate-limited per IP
  • MAILBEAM_KEY only on the server
  • Submit handler re-verifies server-side
  • UI fails open if the check errors
  • Loading and error states are accessible (aria-live)

Next steps