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
npm install @poststack.dev/sdk2. Create a server-only client
// .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
// 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.
// 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.
<!-- 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().
// 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.
// 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.tsload functions and components also run in the browser. Keep the client in$lib/serverand only import it from+page.server.ts,+server.tsorhooks.server.ts.Using $env/static/public
PUBLIC_-prefixed variables are shipped to the client. Name the variablePOSTSTACK_API_KEYand import it from a private module.tomust be an arrayto: emailfails validation with a 400. Useto: [email]; the same applies toccandbcc.422 "Domain … is not verified"
The
fromaddress must be on a domain verified in PostStack.reply_tocan 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.