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
useEmailVerificationcomposable 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
| Practice | Why |
|---|---|
| Debounce ~500ms | One call per pause, not per keystroke |
| Abort in-flight requests | Avoids out-of-order results as users type |
| Proxy through your server | Keeps the API key off the client |
| Re-verify on submit | Client state is advisory, not authoritative |
Surface didYouMean | Recovers users from typos like gmial.com |
Production checklist
- Verification endpoint rate-limited per IP
-
MAILBEAM_KEYonly on the server - Submit handler re-verifies server-side
- UI fails open if the check errors
- Loading and error states are accessible (aria-live)