SvelteKit

Send email with SvelteKit form actions and +server.ts endpoints using the Samva SDK.

Use the samva SDK directly from SvelteKit server code. Form actions work well for in-app forms, +server.ts endpoints work well for JSON clients, and both keep your API key server-side.

Samva sends from the verified sender configured on your account, so the payload has no from field.

Install

bun add samva

Create a server-only client helper:

src/lib/server/samva.ts
import { env } from "$env/dynamic/private";
import { createClient } from "samva";

export function getSamva() {
  const apiKey = env.SAMVA_API_KEY;
  if (!apiKey) {
    throw new Error("SAMVA_API_KEY is not set.");
  }

  return createClient({ apiKey });
}

$env/*/private and src/lib/server are server-only. Do not expose SAMVA_API_KEY through a public env prefix.

Form action quickstart

Form actions let a SvelteKit page submit directly to server code:

src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";

import { getSamva } from "$lib/server/samva";

const escapeHtml = (value: string): string =>
  value
    .replaceAll("&", "&")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");

const field = (formData: FormData, name: string): string =>
  String(formData.get(name) ?? "").trim();

export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const email = field(formData, "email");
    const message = field(formData, "message");

    if (!email || !message) {
      return fail(400, { error: "Email and message are required." });
    }

    await getSamva().messages.send({
      to: [{ email }],
      channel: "email",
      email: {
        subject: "Thanks for contacting us",
        html: `<p>${escapeHtml(message).replaceAll("\n", "<br />")}</p>`,
        text: message,
      },
    });

    return { success: true };
  },
} satisfies Actions;

In +page.svelte, render the action result from the form prop and add use:enhance when you want pending state.

Endpoint quickstart

Use a +server.ts endpoint for raw HTTP:

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

import { getSamva } from "$lib/server/samva";

const escapeHtml = (value: string): string =>
  value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const readString = (value: unknown): string => (typeof value === "string" ? value.trim() : "");

export const POST: RequestHandler = async ({ request }) => {
  const body: unknown = await request.json().catch(() => null);
  if (!isRecord(body)) {
    error(400, "Expected a JSON object.");
  }

  const to = readString(body.to);
  const subject = readString(body.subject);
  const message = readString(body.message);

  if (!to || !subject || !message) {
    error(400, "to, subject, and message are required.");
  }

  await getSamva().messages.send({
    to: [{ email: to }],
    channel: "email",
    email: {
      subject,
      html: `<p>${escapeHtml(message).replaceAll("\n", "<br />")}</p>`,
      text: message,
    },
  });

  return json({ ok: true });
};

The endpoint awaits the send. Invalid payloads fail loudly with 400; missing credentials throw from the server-only client module.

Cloudflare Workers

The SDK uses fetch, so the same send call works with @sveltejs/adapter-cloudflare. Prefer SvelteKit's $env/dynamic/private module for runtime secrets, or $env/static/private when the key is available during build/typecheck. If the key is only available as a Worker binding, construct the client per request from platform.env:

import { error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { createClient } from "samva";

export const POST: RequestHandler = async ({ platform }) => {
  const apiKey = platform?.env.SAMVA_API_KEY;
  if (!apiKey) {
    error(500, "SAMVA_API_KEY is not configured for this Worker.");
  }

  const samva = createClient({ apiKey });
  // Read and validate request data, then call samva.messages.send().
};

The runtime remains edge-safe as long as the code around the send avoids Node-only database drivers, filesystem access, SMTP sockets, and Node built-ins.

React Email

Render React Email to HTML, then send the strings with Samva:

import { render, toPlainText } from "react-email";
import WelcomeEmail from "$lib/emails/welcome";
import { getSamva } from "$lib/server/samva";

const html = await render(<WelcomeEmail name="Ada" />);

await getSamva().messages.send({
  to: [{ email: "ada@example.com" }],
  channel: "email",
  email: { subject: "Welcome", html, text: toPlainText(html) },
});

See the React Email integration for the full templating workflow.

For @auth/sveltekit, replace the email provider's sendVerificationRequest({ identifier, url }) body with a Samva send. Auth.js still owns the verification token and requires a database adapter for email sign-in.

async sendVerificationRequest({ identifier, url }) {
  const { host } = new URL(url);

  await getSamva().messages.send({
    to: [{ email: identifier }],
    channel: "email",
    email: {
      subject: `Sign in to ${host}`,
      html: `<p><a href="${url}">Sign in to ${host}</a></p>`,
      text: `Sign in to ${host}\n${url}\n`,
    },
  });
}

Drop provider.from when porting from Resend or Nodemailer; Samva has no from field.

Full cookbook and example

The SvelteKit cookbook goes deeper: choosing a form action versus a +server.ts endpoint, $env/static/private versus $env/dynamic/private, why sends should never run from load, and Auth.js magic links. The sveltekit-transactional example is a runnable project with both send paths wired up.

On this page