Integration
Send email from SvelteKit with SNDR.
Send transactional email from SvelteKit — form actions, +server.ts endpoints, and webhook receivers. Typed SDK, $env/static/private separation, deploys to Node, Vercel, Cloudflare Workers, and Netlify.
SvelteKit's split between +page.server.ts (form actions, server loads) and +server.ts (raw HTTP endpoints) maps cleanly to the two transactional-email patterns: respond to a form, or expose an API. Use a Node-compatible server adapter for the SDK; enable nodejs_compat on Cloudflare. SvelteKit's $env/static/private import path keeps the API key off the client bundle automatically — if you import it from a client-only file the build fails.
Setup
Install + configure
pnpm add @rkiza/sndr
# or
npm install @rkiza/sndr
# or
yarn add @rkiza/sndr# .env (loaded by SvelteKit's runtime)
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=... # only if you receive webhooksRecipes
The patterns you actually need.
Send from a form action
Form actions live in +page.server.ts. Parse the FormData, validate, send. Throwing fail() returns typed errors to the page; returning a value re-runs the load and re-renders.
// src/routes/contact/+page.server.ts import type { Actions } from './$types'; import { fail } from '@sveltejs/kit'; import { Sndr } from '@rkiza/sndr'; import { SNDR_API_KEY } from '$env/static/private'; import { z } from 'zod'; const sndr = new Sndr(SNDR_API_KEY); const contactSchema = z.object({ name: z.string().min(2).max(120), email: z.string().email(), message: z.string().min(10).max(8000), }); export const actions: Actions = { default: async ({ request }) => { const formData = await request.formData(); const parsed = contactSchema.safeParse({ name: formData.get('name'), email: formData.get('email'), message: formData.get('message'), }); if (!parsed.success) { return fail(400, { errors: parsed.error.flatten().fieldErrors }); } await sndr.emails.send({ from: 'hello@yourdomain.com', to: 'team@yourdomain.com', reply_to: parsed.data.email, subject: `New contact from ${parsed.data.name}`, text: parsed.data.message, }); return { ok: true }; }, };Send from a +server.ts endpoint
+server.ts files export HTTP-method functions. Use them for non-form requests — e.g. a POST from another service or a scheduled job. The SDK auto-generates an Idempotency-Key per logical call.
// src/routes/api/welcome/+server.ts import type { RequestHandler } from './$types'; import { json, error } from '@sveltejs/kit'; import { Sndr, SndrError } from '@rkiza/sndr'; import { SNDR_API_KEY } from '$env/static/private'; const sndr = new Sndr(SNDR_API_KEY); export const POST: RequestHandler = async ({ request }) => { const { email, name } = (await request.json()) as { email: string; name: string }; try { const { id } = await sndr.emails.send({ from: 'hello@yourdomain.com', to: email, subject: `Welcome, ${name}`, html: `<p>Thanks for joining us, ${name}.</p>`, }); return json({ id }); } catch (err) { if (err instanceof SndrError) { error(err.status, { message: err.message }); } throw err; } };Verify an incoming webhook
+server.ts endpoints expose the raw Web Request — call .arrayBuffer() to get the original bytes before they're consumed by .json().
// src/routes/api/webhooks/sndr/+server.ts import type { RequestHandler } from './$types'; import { verifyWebhookSignature } from '@rkiza/sndr'; import type { WebhookEventPayload } from '@rkiza/sndr'; import { SNDR_WEBHOOK_SECRET } from '$env/static/private'; export const POST: RequestHandler = async ({ request }) => { const raw = Buffer.from(await request.arrayBuffer()); const ok = verifyWebhookSignature( raw, request.headers.get('x-sndr-signature') ?? '', SNDR_WEBHOOK_SECRET, ); if (!ok) return new Response('bad signature', { status: 400 }); const event = JSON.parse(raw.toString('utf8')) as WebhookEventPayload; switch (event.type) { case 'email.delivered': // event.data.email_id, event.data.to: string[] break; case 'email.bounced': // event.data.email_id, event.data.error break; case 'email.unsubscribed': // event.data.addresses: string[] break; } return new Response('ok'); };Password-reset flow
A signed token is persisted alongside the user record; the click-through validates it and lets the user set a new password. Issue from +server.ts, validate from a load function.
// src/routes/api/password-reset/+server.ts import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { Sndr } from '@rkiza/sndr'; import { SNDR_API_KEY } from '$env/static/private'; import { randomBytes } from 'node:crypto'; const sndr = new Sndr(SNDR_API_KEY); export const POST: RequestHandler = async ({ request }) => { const { email } = (await request.json()) as { email: string }; const token = randomBytes(32).toString('hex'); // ...persist (token, email, expiresAt) with 30-min TTL... const link = `https://yourdomain.com/auth/reset?token=${token}`; await sndr.emails.send({ from: 'auth@yourdomain.com', to: email, subject: 'Reset your password', html: `<p><a href="${link}">Click here to set a new password</a>. Link expires in 30 minutes.</p>`, }); // Always return 200 even if the email is unknown — never leak account existence. return json({ ok: true }); };
SvelteKit FAQ
Why $env/static/private instead of process.env?
Will the SDK work on Cloudflare Pages?
How do I handle long-running sends?
How do I avoid double-sending on retries?
Send your first SvelteKit email.
3,000 emails a month on the Free plan. No credit card needed.