Nuxt Email Verification Tutorial With a Server Route
This tutorial creates a Nuxt 4 page and a Nitro server route that verify a signup email without exposing the project key. You will start from a new Nuxt app, submit the form in a browser, and test the route directly with curl.
Create the Nuxt app
npm create nuxt@latest nuxt-email-check
cd nuxt-email-check
Select npm and the minimal starter when prompted. The files used in this tutorial are:
nuxt-email-check/
├─ app/pages/index.vue
├─ server/api/signup/email.post.ts
├─ nuxt.config.ts
└─ .env
Nuxt maps app/pages/index.vue to / and server/api/signup/email.post.ts to a POST-only API route. The Nuxt installation guide covers the project command.
Configure the private key
Create an emailverifier.dev account and project, then copy the API key. Add a private runtime entry to nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
emailVerifierApiKey: ''
}
})
Create .env:
NUXT_EMAIL_VERIFIER_API_KEY=replace_with_your_project_key
The NUXT_ name overrides the matching runtime configuration key. Keep it outside runtimeConfig.public; public values are available to browser code.
Create the Nitro route
Add server/api/signup/email.post.ts:
type VerificationResult = {
email: string
status: 'deliverable' | 'risky' | 'undeliverable' | 'unknown'
action: 'allow' | 'block' | 'review'
flagged: boolean
signals: string[]
suggestion: string | null
}
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: unknown }>(event)
const email = typeof body.email === 'string'
? body.email.trim()
: ''
if (!email) {
throw createError({
status: 400,
statusMessage: 'Email is required.'
})
}
const config = useRuntimeConfig(event)
if (!config.emailVerifierApiKey) {
throw createError({
status: 500,
statusMessage: 'Email verification is not configured.'
})
}
let response: Response
try {
response = await fetch(
'https://emailverifier.dev/api/v1/verify',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': config.emailVerifierApiKey
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(5000)
}
)
} catch {
throw createError({
status: 503,
statusMessage:
'Email verification is temporarily unavailable.'
})
}
if (!response.ok) {
throw createError({
status: 503,
statusMessage:
'Email verification is temporarily unavailable.'
})
}
const result = (await response.json()) as VerificationResult
if (result.action === 'block') {
throw createError({
status: 422,
statusMessage: result.signals.includes('disposable_address')
? 'Use a permanent email address.'
: 'Check the email address and try again.',
data: {
status: result.status,
signals: result.signals,
suggestion: result.suggestion
}
})
}
return {
accepted: true,
needsReview: result.action === 'review',
result
}
})
The route validates cheap input before spending a credit. It converts confirmed blocking results to 422, keeps inconclusive results under review, and uses 503 for network or authenticated API failures. The exact upstream fields are in the REST API reference.
Build the page
Replace app/pages/index.vue with:
<script setup lang="ts">
type Decision = {
accepted: true
needsReview: boolean
result: {
status: 'deliverable' | 'risky' | 'unknown'
action: 'allow' | 'review'
signals: string[]
suggestion: string | null
}
}
const email = ref('')
const pending = ref(false)
const decision = ref<Decision | null>(null)
const errorMessage = ref('')
async function submit() {
pending.value = true
decision.value = null
errorMessage.value = ''
try {
decision.value = await $fetch<Decision>(
'/api/signup/email',
{
method: 'POST',
body: { email: email.value }
}
)
} catch (error) {
const failure = error as {
data?: { statusMessage?: string }
}
errorMessage.value =
failure.data?.statusMessage ?? 'Request failed. Try again.'
} finally {
pending.value = false
}
}
</script>
<template>
<main>
<h1>Check a signup email</h1>
<form @submit.prevent="submit">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
required
>
<button :disabled="pending">
{{ pending ? 'Checking…' : 'Check email' }}
</button>
</form>
<p v-if="errorMessage" role="alert">
{{ errorMessage }}
</p>
<section v-if="decision" aria-live="polite">
<p>Status: {{ decision.result.status }}</p>
<p>Action: {{ decision.result.action }}</p>
<p>
{{ decision.needsReview
? 'Continue with email confirmation.'
: 'Email accepted.' }}
</p>
<p v-if="decision.result.suggestion">
Suggested address: {{ decision.result.suggestion }}
</p>
</section>
</main>
</template>
$fetch is the current Nuxt recommendation for client-side event handlers. The page calls only the local Nitro route.
Run and test it
npm run dev
Open http://localhost:3000, submit an address, and confirm the page displays status and action. Test the route without the page:
curl http://localhost:3000/api/signup/email \
-H "Content-Type: application/json" \
-d "{\"email\":\"person@example.com\"}"
The tutorial is complete when a normal response returns accepted, a review result sets needsReview, missing input returns 400, a blocking result returns 422, and the project key is absent from browser source and network payloads.
Connect account creation
Move the existing account-creation call into the successful branch after $fetch resolves. Use these four result rules:
deliverable: allow, or review a separate role-address or catch-all signal.risky: block disposable addresses and surface typo suggestions.undeliverable: block after a confirmed syntax, routing, or recipient failure.unknown: continue through confirmation or restricted access because the mailbox evidence is inconclusive.
For queues, retries, and stored decisions beyond this form, use the production result-handling guide.
Verification should run before the user mutation when a blocked address must not receive an account or trial. Email confirmation can run afterward to prove inbox control.