Integration
Send email from Nuxt with SNDR.
Send transactional email from Nuxt — server/api routes, Nitro endpoints, useFetch from forms, webhook receivers via readRawBody. Works on Node, Vercel, Netlify, and Cloudflare presets.
Nuxt's server directory + Nitro server engine give you the same Web standards Request/Response your client code already uses. The SDK works unchanged. The recipes below show server/api routes for typed endpoints, Nitro h3 helpers for body handling, and webhook receivers using readRawBody so HMAC verification stays correct.
Setup
Install + configure
pnpm add @rkiza/sndr
# or
npm install @rkiza/sndr
# or
yarn add @rkiza/sndr# .env
NUXT_SNDR_API_KEY=sndr_live_x9k2...
NUXT_SNDR_WEBHOOK_SECRET=...
# Expose via runtimeConfig in nuxt.config.ts:
# export default defineNuxtConfig({
# runtimeConfig: {
# sndrApiKey: '', // overridden by NUXT_SNDR_API_KEY
# sndrWebhookSecret: '', // overridden by NUXT_SNDR_WEBHOOK_SECRET
# },
# });Recipes
The patterns you actually need.
Send from a server/api route
Nuxt's server/api/<path>.ts files become HTTP handlers automatically. defineEventHandler is the Nitro shape; readBody gives you the parsed JSON.
// server/api/welcome.post.ts import { Sndr, SndrError } from '@rkiza/sndr'; export default defineEventHandler(async (event) => { const config = useRuntimeConfig(); const sndr = new Sndr(config.sndrApiKey); const { email, name } = await readBody<{ email: string; name: string }>(event); try { const { id } = await sndr.emails.send({ from: 'hello@yourdomain.com', to: email, subject: `Welcome, ${name}`, html: `<p>Thanks for joining us, ${name}.</p>`, }); return { id }; } catch (err) { if (err instanceof SndrError) { throw createError({ statusCode: err.status, statusMessage: err.code, data: { message: err.message }, }); } throw err; } });Form submission from a Vue page
useFetch (or $fetch in handlers) is the SSR-aware client. The server handler validates and sends; the page surfaces the result.
<!-- pages/contact.vue --> <script setup lang="ts"> const form = ref({ name: '', email: '', message: '' }); const result = ref<{ ok: boolean; error?: string } | null>(null); async function submit() { try { await $fetch('/api/contact', { method: 'POST', body: form.value }); result.value = { ok: true }; } catch (err: any) { result.value = { ok: false, error: err.data?.message ?? 'send failed' }; } } </script> <template> <form @submit.prevent="submit"> <input v-model="form.name" required minlength="2" /> <input v-model="form.email" type="email" required /> <textarea v-model="form.message" required minlength="10" /> <button type="submit">Send</button> <p v-if="result?.ok">Message received.</p> <p v-if="result?.error">{{ result.error }}</p> </form> </template>Webhook receiver — readRawBody
Nitro's readRawBody helper exposes the raw bytes before any body parsing kicks in — exactly what HMAC verification needs. Don't use readBody here; the parsed-then-re-serialised JSON breaks the signature.
// server/api/webhooks/sndr.post.ts import { verifyWebhookSignature } from '@rkiza/sndr'; import type { WebhookEventPayload } from '@rkiza/sndr'; export default defineEventHandler(async (event) => { const config = useRuntimeConfig(); const rawString = await readRawBody(event); if (!rawString) { throw createError({ statusCode: 400, statusMessage: 'empty body' }); } const raw = Buffer.from(rawString, 'utf8'); const ok = verifyWebhookSignature( raw, getHeader(event, 'x-sndr-signature') ?? '', config.sndrWebhookSecret, ); if (!ok) { throw createError({ statusCode: 400, statusMessage: 'bad signature' }); } const payload = JSON.parse(raw.toString('utf8')) as WebhookEventPayload; switch (payload.type) { case 'email.delivered': // payload.data.email_id, payload.data.to: string[] break; case 'email.bounced': // payload.data.email_id, payload.data.error break; case 'email.unsubscribed': // payload.data.addresses: string[] break; } return { ok: true }; });Server middleware for shared validation
If multiple routes need the same auth or rate-limiting, a server/middleware.ts file runs on every request. Keep Sndr instantiation per-route or per-module — middleware is not the right place for it.
// server/middleware/rate-limit.ts const counts = new Map<string, { count: number; reset: number }>(); export default defineEventHandler((event) => { if (!event.path.startsWith('/api/')) return; const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'; const now = Date.now(); const entry = counts.get(ip) ?? { count: 0, reset: now + 60_000 }; if (now > entry.reset) { entry.count = 0; entry.reset = now + 60_000; } entry.count += 1; counts.set(ip, entry); if (entry.count > 30) { throw createError({ statusCode: 429, statusMessage: 'rate limited' }); } });
Nuxt FAQ
Does the SDK work on every Nuxt deployment preset?
Yes for Node, Vercel, Netlify. For Cloudflare, the send path works; the webhook verifier requires the nodejs_compat flag (same as raw Workers).
Why readRawBody instead of readBody for webhooks?
readBody parses (JSON / FormData / multipart). HMAC must be computed over the exact bytes SNDR signed; re-serialised JSON loses ordering and whitespace. readRawBody returns the raw string.
How do I expose the API key safely?
Use runtimeConfig (not publicRuntimeConfig). Nuxt strips runtimeConfig keys from the client bundle automatically. NUXT_SNDR_API_KEY overrides the empty default at runtime.
Should I queue sends in Nuxt?
For high-volume or critical sends, yes. Pair with a job queue (BullMQ on Redis, or Cloudflare Queues on the CF preset) so transactional sends survive request failures.
Ship your first Nuxt email in 5 minutes.
Free tier includes 3,000 emails / month. No card required.
Start building
Your next message starts here.
Connect your domain, create an API key, and send with SNDR.