Send email from SvelteKit

SvelteKit will refuse to build if you import a private env var into client code, which makes this one of the harder stacks to leak a key from. This page uses a form action for the common case and gives you the +server.ts endpoint when something external needs to POST.

Setup

  1. 01

    Create a key

    https://emails.sh/dashboard issues a key starting with esh_.

  2. 02

    Put it in .env

    EMAILSSH_API_KEY. Anything not prefixed PUBLIC_ is private in SvelteKit and importable only from server modules.

  3. 03

    Import from $env/static/private

    Static means it is inlined at build time and checked at build time, so a missing var fails the build rather than the first send.

  4. 04

    Add the form action

    src/routes/signup/+page.server.ts below. Actions run on the server and work without JavaScript in the browser, so the form still submits if hydration fails.

Install
npm install @emails.sh/sdk
.env
EMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"

The form action

src/routes/signup/+page.server.ts (SvelteKit 2)

The form action
import { fail } from '@sveltejs/kit';
import { Emailssh } from '@emails.sh/sdk';
import { EMAILSSH_API_KEY, EMAILSSH_FROM } from '$env/static/private';
import type { Actions } from './$types';

const mail = new Emailssh({ apiKey: EMAILSSH_API_KEY });

export const actions: Actions = {
	default: async ({ request }) => {
		const form = await request.formData();
		const email = String(form.get('email') ?? '');
		const name = String(form.get('name') ?? 'there');

		if (!email.includes('@')) {
			return fail(400, { email, error: 'A valid email is required' });
		}

		const sent = await mail.send({
			from: EMAILSSH_FROM,
			to: [email],
			subject: 'Welcome to Acme',
			html: `<p>Hi ${name}, your Acme account is ready.</p>`,
			text: `Hi ${name}, your Acme account is ready.`
		});

		return { sent: true, id: sent.id };
	}
};

The page

src/routes/signup/+page.svelte (Svelte 5 runes)

The page
<script lang="ts">
	import { enhance } from '$app/forms';
	import type { ActionData } from './$types';

	let { form }: { form: ActionData } = $props();
</script>

<form method="POST" use:enhance>
	<input name="name" placeholder="Your name" />
	<input name="email" type="email" placeholder="you@example.com" required />
	<button type="submit">Sign up</button>
</form>

{#if form?.sent}
	<p>Check your inbox.</p>
{:else if form?.error}
	<p>{form.error}</p>
{/if}

The endpoint version

src/routes/api/send/+server.ts, for callers that are not your form

The endpoint version
import { json, error } from '@sveltejs/kit';
import { Emailssh } from '@emails.sh/sdk';
import { EMAILSSH_API_KEY, EMAILSSH_FROM } from '$env/static/private';
import type { RequestHandler } from './$types';

const mail = new Emailssh({ apiKey: EMAILSSH_API_KEY });

export const POST: RequestHandler = async ({ request }) => {
	const body = (await request.json()) as { email?: string; subject?: string; html?: string };

	if (!body.email?.includes('@')) error(400, 'A valid email is required');

	const sent = await mail.send({
		from: EMAILSSH_FROM,
		to: [body.email],
		subject: body.subject ?? 'Welcome to Acme',
		html: body.html ?? '<p>Your Acme account is ready.</p>',
		text: 'Your Acme account is ready.'
	});

	return json({ id: sent.id, status: sent.status });
};

Worth knowing

01

$env/static/private cannot be imported into a component

SvelteKit fails the build with a clear error if a module reachable from the client imports it. That is the safety net, so use it rather than process.env, which has no such check.

02

Static versus dynamic env

$env/static/private is inlined at build time, which is what you want when the key is set before the build. If your key only exists at runtime (some container platforms), import from $env/dynamic/private instead and read env.EMAILSSH_API_KEY.

03

adapter-static has no server

Form actions and +server.ts endpoints do not exist in a fully prerendered build. If you are on adapter-static, switch to adapter-node, adapter-vercel, or adapter-cloudflare for the routes that send.

04

Construct the client once, at module scope

The module is evaluated once per server process, so a top level new Emailssh() avoids rebuilding it per request. It also means a bad key fails loudly on first import rather than on a user action.

Questions

How do I send an email from a SvelteKit form?

A form action in +page.server.ts. The action receives the FormData on the server, calls mail.send, and returns to the page, all without you writing a fetch.

Form action or +server.ts?

Form action when your own page submits. +server.ts when a webhook, a cron job, or a client you do not control needs to POST.

Can I use this on Cloudflare Pages?

Yes, with adapter-cloudflare. The SDK is fetch based, so it runs on workerd. Read the key from platform.env or $env/dynamic/private there rather than the static import.

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.