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

Installbash
pnpm add @rkiza/sndr
# or
npm install @rkiza/sndr
# or
yarn add @rkiza/sndr
Environmentenv
# .env (loaded by SvelteKit's runtime)
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=...   # only if you receive webhooks

Recipes

The patterns you actually need.

  1. 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 };
      },
    };
  2. 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;
      }
    };
  3. 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');
    };
  4. 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?
SvelteKit refuses to import $env/static/private from any file that ends up in the client bundle — the build fails loudly. process.env works on Node adapters but loses that protection. Use $env/static/private and the build catches accidental client-side leaks for you.
Will the SDK work on Cloudflare Pages?
Enable nodejs_compat for the entire SDK on Cloudflare. Without Node compatibility, use the HTTP API directly.
How do I handle long-running sends?
Use waitUntil() from platform-specific APIs (Vercel/Cloudflare) or fire-and-forget patterns. For most transactional sends the call returns in < 200ms; if you need true async, queue the send via a job worker.
How do I avoid double-sending on retries?
The SDK auto-generates a UUID Idempotency-Key per logical send and reuses it across automatic retries. Override with a stable key from your domain (e.g. user ID + epoch hour) via RequestOptions.

Send your first SvelteKit email.

3,000 emails a month on the Free plan. No credit card needed.