Send email with Nuxt
Send email from a Nuxt 3 server route with the PostStack SDK: runtime config for the key, a Nitro API endpoint, and a form that posts to it.
In Nuxt 3, email is sent from the server side: a Nitro route under server/api/ that the page calls with $fetch. The PostStack TypeScript SDK (@poststack.dev/sdk) only needs fetch, so the same code runs on Node, Bun, Deno and edge presets such as Cloudflare Workers or Vercel Edge. Below: runtime config for the API key, a cached client in server/utils, a validated API route, a form that calls it, attachments, and how to map PostStack errors to HTTP responses.
1. Install the SDK
npm install @poststack.dev/sdk2. Add the key to runtime config
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// server-only; set it with the NUXT_POSTSTACK_API_KEY environment variable
poststackApiKey: '',
},
});
// server/utils/poststack.ts — auto-imported in every server route
import type { H3Event } from 'h3';
import { PostStack } from '@poststack.dev/sdk';
let client: PostStack | undefined;
// Pass the event so edge presets (Cloudflare) read the key from the request's env.
export function usePostStack(event: H3Event) {
client ??= new PostStack(useRuntimeConfig(event).poststackApiKey);
return client;
}3. Send from a server route
// server/api/send.post.ts -> POST /api/send
import { PostStackError } from '@poststack.dev/sdk';
export default defineEventHandler(async (event) => {
const { email, name } = await readBody<{ email: string; name: string }>(event);
try {
const { id } = await usePostStack(event).emails.send({
from: 'Acme <hello@yourdomain.com>', // must be on a verified domain
to: [email], // always an array
subject: `Welcome, ${name}`,
html: `<h1>Welcome, ${name}!</h1>`,
text: `Welcome, ${name}!`,
});
return { id }; // "em_..."
} catch (err) {
if (err instanceof PostStackError) {
throw createError({ statusCode: err.statusCode, statusMessage: err.message });
}
throw err;
}
});4. Handle errors
Nuxt idioms for error handling, retries, and structured logging when sending through PostStack.
// server/api/contact.post.ts — validate input, then map PostStack errors
import { z } from 'zod';
import { PostStackError } from '@poststack.dev/sdk';
const Contact = z.object({
email: z.string().email(),
message: z.string().min(1).max(5000),
});
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, Contact.parse); // 400 on bad input
try {
// The SDK already retries 408, 429 and 5xx with backoff and an idempotency key.
const { id } = await usePostStack(event).emails.send({
from: 'Website <contact@yourdomain.com>',
to: ['support@yourdomain.com'],
reply_to: body.email,
subject: 'New contact form message',
text: body.message,
});
return { id };
} catch (err) {
if (!(err instanceof PostStackError)) throw err; // network error after retries
console.error('PostStack error', {
status: err.statusCode,
message: err.message,
requestId: err.requestId,
});
// 422 = unverified from-domain or suppressed recipient; 401/403 = key problem
throw createError({
statusCode: err.statusCode >= 500 ? 502 : err.statusCode,
statusMessage: 'Could not send your message',
});
}
});Call the route from a page
Use $fetch in an event handler (not useFetch, which runs on render). The API key stays on the server.
<!-- pages/signup.vue -->
<script setup lang="ts">
const email = ref('');
const status = ref<'idle' | 'sent' | 'error'>('idle');
async function submit() {
try {
await $fetch('/api/send', { method: 'POST', body: { email: email.value, name: 'there' } });
status.value = 'sent';
} catch {
status.value = 'error';
}
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="email" type="email" required />
<button type="submit">Send welcome email</button>
<p v-if="status === 'sent'">Check your inbox.</p>
<p v-if="status === 'error'">Something went wrong.</p>
</form>
</template>Attachments
Attachments are base64 strings. Read uploads with readMultipartFormData. Limits: 10 attachments, 10 MB per file, 25 MB in total.
// server/api/report.post.ts
export default defineEventHandler(async (event) => {
const parts = (await readMultipartFormData(event)) ?? [];
const file = parts.find((p) => p.name === 'file' && p.filename);
if (!file) throw createError({ statusCode: 400, statusMessage: 'No file' });
const { id } = await usePostStack(event).emails.send({
from: 'reports@yourdomain.com',
to: ['team@yourdomain.com'],
subject: `Report: ${file.filename}`,
text: 'Report attached.',
attachments: [
{
filename: file.filename!,
content: Buffer.from(file.data).toString('base64'),
content_type: file.type,
},
],
});
return { id };
});Framework integrations
Server routes (Nitro)
Files in server/api/ become endpoints; the .post.ts suffix restricts the method. Utilities in server/utils/ are auto-imported, so usePostStack(event) is available in every route without an import.
Runtime config
Values under runtimeConfig are server-only and overridable with NUXT_-prefixed environment variables at runtime. Anything under runtimeConfig.public is shipped to the browser — never put the API key there.
Edge and serverless presets
The SDK uses only fetch, crypto.randomUUID and AbortSignal.timeout, so it works on the node-server, bun, cloudflare, vercel-edge and netlify-edge Nitro presets unchanged.
Scheduled sends with Nitro tasks
For digests and reminders, either pass scheduled_at (ISO 8601) on the send so PostStack delivers later, or run a Nitro scheduled task that calls usePostStack(event).emails.batch(...) with up to 100 emails per request.
Common pitfalls
The key is empty at runtime
runtimeConfig.poststackApiKeyis only overridden byNUXT_POSTSTACK_API_KEY— a variable namedPOSTSTACK_API_KEYis not picked up. Set the prefixed name on your host.Sending from a component
Calling the SDK in a
.vuefile or composable ships the key to the browser. Only import the SDK underserver/.tomust be an arrayto: "user@example.com"fails validation with a 400. Useto: ["user@example.com"]; the same applies toccandbcc.422 "Domain … is not verified"
The
fromaddress must be on a domain you have added and verified in PostStack. Add the DNS records from the dashboard and wait for verification.
Notes
- Keep the key in
runtimeConfig(notruntimeConfig.public) so it never reaches the browser bundle - Nuxt maps
NUXT_POSTSTACK_API_KEYtoruntimeConfig.poststackApiKeyat runtime — no rebuild needed to rotate it - An
sk_test_...key validates and logs sends without delivering them
FAQ
How do I send an email from Nuxt?
Install @poststack.dev/sdk, add poststackApiKey to runtimeConfig, create server/api/send.post.ts with defineEventHandler, and call emails.send({ from, to: [...], subject, html }). Call the route from your page with $fetch.
Does it work with Nuxt on Cloudflare or Vercel Edge?
Yes. The SDK has no Node-only dependencies, so it runs on edge presets unchanged.
Can I use nodemailer with Nuxt instead?
On the Node preset, yes — point an SMTP transport at smtp.poststack.dev with your API key as the password. Edge presets have no TCP sockets, so use the SDK there.
Does this work with Nuxt 2?
This guide targets Nuxt 3 and Nitro. On Nuxt 2, put the same emails.send call in a serverMiddleware handler.
Related guides
Ready to send emails with Nuxt?
Create a free account and get your API key in under a minute.