Skip to content

Send email with Nuxt

Send email from a Nuxt 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 and 4, 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.

Quickstart#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  1. Install the SDK

    bash
    npm install @poststack.dev/sdk
  2. Add the key to runtime config

    typescript
    // 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 in routes so edge presets (Cloudflare) read the key from the
    // request's env. Nitro tasks have no event; there it falls back to process config.
    export function usePostStack(event?: H3Event) {
      client ??= new PostStack(useRuntimeConfig(event).poststackApiKey);
      return client;
    }
  3. Send from a server route

    typescript
    // 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

    typescript
    // 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',
        });
      }
    });

Good to know

  • Keep the key in runtimeConfig (not runtimeConfig.public) so it never reaches the browser bundle
  • Nuxt maps NUXT_POSTSTACK_API_KEY to runtimeConfig.poststackApiKey at runtime — no rebuild needed to rotate it
  • An sk_test_... key validates and logs sends without delivering them

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.

html
<!-- 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.

typescript
// 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 (enable nitro.experimental.tasks) that calls usePostStack().emails.batch(...) with up to 100 emails per request — tasks have no request event, so call it without one.

Common pitfalls#

The key is empty at runtime

runtimeConfig.poststackApiKey is only overridden by NUXT_POSTSTACK_API_KEY — a variable named POSTSTACK_API_KEY is not picked up. Set the prefixed name on your host.

Sending from a component

Calling the SDK in a .vue file or composable ships the key to the browser. Only import the SDK under server/.

to must be an array

to: "user@example.com" fails validation with a 400. Use to: ["user@example.com"]; the same applies to cc and bcc.

422 "Domain … is not verified"

The from address must be on a domain you have added and verified in PostStack. Add the DNS records from the dashboard and wait for verification.

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 4 (Nitro). On Nuxt 2, put the same emails.send call in a serverMiddleware handler.

Ready to send from Nuxt?

Create a free account and an API key — no card needed.

Get started free

Next steps

Was this page helpful?