Send email from Nuxt
Nuxt ships Nitro with it, so you have a server without adding one. This page puts the send in server/api/send.post.ts and reads the key through runtimeConfig, which is the part people get wrong. Anything under server/ never reaches the client bundle.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_. Copy it once.
- 02
Add it to .env
Nuxt maps NUXT_EMAILSSH_API_KEY onto runtimeConfig.emailsshApiKey automatically. The camelCase key must exist in nuxt.config.ts for the override to apply.
- 03
Declare it in runtimeConfig
Top level runtimeConfig entries are server only. Only what you put under runtimeConfig.public reaches the browser, so keep the key out of there.
- 04
Add server/api/send.post.ts
Nitro turns the filename into POST /api/send. Files under server/ are bundled separately from the client, so the SDK and the key stay server-side.
npm install @emails.sh/sdkexport default defineNuxtConfig({
runtimeConfig: {
// Server only. NUXT_EMAILSSH_API_KEY in .env overrides this at runtime.
emailsshApiKey: '',
emailsshFrom: 'Acme <onboarding@emails.sh>'
}
});The server route
server/api/send.post.ts (Nuxt 3)
import { Emailssh } from '@emails.sh/sdk';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
const body = await readBody<{ email?: string; name?: string }>(event);
if (!body.email || !body.email.includes('@')) {
throw createError({ statusCode: 400, statusMessage: 'A valid email is required' });
}
const mail = new Emailssh({ apiKey: config.emailsshApiKey });
const sent = await mail.send({
from: config.emailsshFrom,
to: [body.email],
subject: 'Welcome to Acme',
html: `<p>Hi ${body.name ?? 'there'}, your Acme account is ready.</p>`,
text: `Hi ${body.name ?? 'there'}, your Acme account is ready.`
});
return { id: sent.id, status: sent.status };
});The page that calls it
pages/signup.vue
<script setup lang="ts">
const email = ref('');
const name = ref('');
const state = ref<'idle' | 'sending' | 'sent' | 'error'>('idle');
async function submit() {
state.value = 'sending';
try {
await $fetch('/api/send', {
method: 'POST',
body: { email: email.value, name: name.value }
});
state.value = 'sent';
} catch {
state.value = 'error';
}
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="name" placeholder="Your name" />
<input v-model="email" type="email" placeholder="you@example.com" required />
<button type="submit" :disabled="state === 'sending'">
{{ state === 'sending' ? 'Sending' : 'Sign up' }}
</button>
<p v-if="state === 'sent'">Check your inbox.</p>
<p v-if="state === 'error'">That did not send. Try again.</p>
</form>
</template>Worth knowing
runtimeConfig.public is the leak
Anything under runtimeConfig.public is serialised into the HTML payload sent to every visitor. The API key belongs at the top level of runtimeConfig, which Nuxt only resolves on the server.
The env var name is not free-form
Nuxt only overrides a runtimeConfig entry if the env var matches NUXT_ plus the SCREAMING_SNAKE form of the key. emailsshApiKey maps to NUXT_EMAILSSH_API_KEY. A key you never declared in nuxt.config.ts stays undefined no matter what is in .env.
Call useRuntimeConfig with the event
Inside a server route, pass the event: useRuntimeConfig(event). Without it you can pick up config from the wrong request context in some deployment presets.
Static generation has no server routes
If you deploy with nuxt generate to a pure static host, server/api never runs. Use a preset with a server (node-server, vercel, netlify, cloudflare) or the form will 404 in production while working in dev.
Questions
How do I send email from Nuxt without a separate backend?
Nitro is the backend. A file at server/api/send.post.ts is a real POST endpoint on the same deployment, no extra service needed.
Can I call emails.sh from a Vue component?
No. That puts your Authorization header in the browser. Call your own /api/send, and let the server route hold the key.
Why is config.emailsshApiKey an empty string?
Either the key is missing from runtimeConfig in nuxt.config.ts, or the env var is not named NUXT_EMAILSSH_API_KEY. Both are required for the override to happen.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.