Skip to content

Send email with SvelteKit

Send email from SvelteKit form actions and +server.ts endpoints with the PostStack SDK, keeping the API key in $env/static/private.

SvelteKit gives you two server-side places to send email: form actions in +page.server.ts, which work without JavaScript and return errors to the form with fail(), and +server.ts endpoints for JSON APIs and webhooks. The PostStack TypeScript SDK only needs fetch, so it runs on adapter-node, adapter-vercel, adapter-cloudflare and adapter-netlify without changes. Below: a server-only client in $lib/server, a contact form action, the matching Svelte form, a JSON endpoint, attachments, and error handling.

1. Install the SDK

bash
npm install @poststack.dev/sdk

2. Create a server-only client

typescript
// .env
// POSTSTACK_API_KEY=sk_live_...

// src/lib/server/poststack.ts — $lib/server can never be imported by client code
import { PostStack } from '@poststack.dev/sdk';
import { POSTSTACK_API_KEY } from '$env/static/private';

export const poststack = new PostStack(POSTSTACK_API_KEY);

3. Send from a form action

typescript
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
import { PostStackError } from '@poststack.dev/sdk';
import { poststack } from '$lib/server/poststack';
import type { Actions } from './$types';

export const actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const email = String(data.get('email') ?? '');
    const message = String(data.get('message') ?? '');
    if (!email || !message) return fail(400, { email, missing: true });

    try {
      const { id } = await poststack.emails.send({
        from: 'Website <contact@yourdomain.com>',   // must be on a verified domain
        to: ['support@yourdomain.com'],             // always an array
        reply_to: email,
        subject: 'New contact form message',
        text: message,
      });
      return { success: true, id };
    } catch (err) {
      if (err instanceof PostStackError) {
        return fail(err.statusCode === 422 ? 422 : 502, { email, sendFailed: true });
      }
      throw err;
    }
  },
} satisfies Actions;

4. Handle errors

SvelteKit idioms for error handling, retries, and structured logging when sending through PostStack.

typescript
// src/lib/server/send.ts — one place to log and classify failures
import { PostStackError } from '@poststack.dev/sdk';
import { poststack } from '$lib/server/poststack';

type SendInput = Parameters<typeof poststack.emails.send>[0];

export async function sendEmail(input: SendInput) {
  try {
    // The SDK retries 408, 429 and 5xx (3 retries, jittered backoff) with an
    // Idempotency-Key, so a retried request is never delivered twice.
    return { ok: true as const, ...(await poststack.emails.send(input)) };
  } 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,
    });
    // 400 invalid payload, 401/403 key problem, 422 unverified domain or
    // suppressed recipient, 429/5xx after retries
    return { ok: false as const, status: err.statusCode, message: err.message };
  }
}

The form

use:enhance submits without a full page reload when JavaScript is available; the action works either way.

html
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  let { form } = $props();
</script>

<form method="POST" use:enhance>
  <input name="email" type="email" value={form?.email ?? ''} required />
  <textarea name="message" required></textarea>
  <button>Send</button>
</form>

{#if form?.success}<p>Thanks — we will reply soon.</p>{/if}
{#if form?.sendFailed}<p>Sorry, your message could not be sent.</p>{/if}

A JSON endpoint with +server.ts

For API clients, webhooks or fetch calls from the page, export a POST handler and return json().

typescript
// src/routes/api/welcome/+server.ts
import { error, json } from '@sveltejs/kit';
import { sendEmail } from '$lib/server/send';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request }) => {
  const { email, name } = (await request.json()) as { email: string; name: string };
  if (!email) error(400, 'email is required');

  const result = await sendEmail({
    from: 'Acme <hello@yourdomain.com>',
    to: [email],
    subject: `Welcome, ${name}`,
    html: `<h1>Welcome, ${name}!</h1>`,
    tags: ['welcome'],
  });
  if (!result.ok) error(result.status >= 500 ? 502 : result.status, result.message);
  return json({ id: result.id }, { status: 202 });
};

Attachments from a file input

Read the File from form data and base64-encode it. Add enctype="multipart/form-data" to the form. Limits: 10 attachments, 10 MB per file, 25 MB in total.

typescript
// inside an action
const file = data.get('attachment');
const attachments =
  file instanceof File && file.size > 0
    ? [
        {
          filename: file.name,
          content: Buffer.from(await file.arrayBuffer()).toString('base64'),
          content_type: file.type || 'application/octet-stream',
        },
      ]
    : undefined;

await poststack.emails.send({
  from: 'Website <contact@yourdomain.com>',
  to: ['support@yourdomain.com'],
  subject: 'Message with attachment',
  text: String(data.get('message')),
  attachments,
});

Framework integrations

Form actions

The simplest path for contact, signup and invite forms. Return fail(status, data) for errors so the page re-renders with the submitted values, and use use:enhance for a no-reload experience.

+server.ts endpoints

Export POST from a +server.ts file for JSON APIs, webhooks from other services, or cron-triggered routes. Return json() and throw error() for failures.

Hooks and background work

On serverless adapters a request ends when the response is sent, so await the send before returning. On adapter-node you can move sends to a job queue such as BullMQ if you need retries beyond the SDK’s own.

Adapters

The SDK works on adapter-node, adapter-vercel (Node and Edge), adapter-cloudflare and adapter-netlify. On Cloudflare, read the key from $env/dynamic/private or platform.env.

Common pitfalls

  • Importing the client in a +page.ts or .svelte file

    Universal +page.ts load functions and components also run in the browser. Keep the client in $lib/server and only import it from +page.server.ts, +server.ts or hooks.server.ts.

  • Using $env/static/public

    PUBLIC_-prefixed variables are shipped to the client. Name the variable POSTSTACK_API_KEY and import it from a private module.

  • to must be an array

    to: email fails validation with a 400. Use to: [email]; the same applies to cc and bcc.

  • 422 "Domain … is not verified"

    The from address must be on a domain verified in PostStack. reply_to can be any address, which is how a contact form lets you answer the visitor directly.

Notes

  • Import the key from $env/static/private (inlined at build) or $env/dynamic/private (read at runtime) — both are server-only
  • Anything under src/lib/server/ fails the build if client code imports it, which keeps the key out of the bundle
  • An sk_test_... key validates and logs sends without delivering them

FAQ

How do I send email from SvelteKit?

Install @poststack.dev/sdk, create the client in src/lib/server/poststack.ts with the key from $env/static/private, and call poststack.emails.send({ from, to: [...], subject, html }) inside a form action or a +server.ts handler.

Form action or +server.ts?

Form actions for HTML forms — they work without JavaScript and return errors with fail(). +server.ts for JSON APIs, webhooks and calls from other services.

Can I use nodemailer in SvelteKit?

On adapter-node, yes: point an SMTP transport at smtp.poststack.dev with your API key as the password. Edge adapters cannot open TCP connections, so use the SDK there.

Does the SDK work on Cloudflare Pages?

Yes. It only uses fetch and web-standard APIs. Read the key from $env/dynamic/private so it comes from the Pages environment at runtime.

Related guides

Ready to send emails with SvelteKit?

Create a free account and get your API key in under a minute.