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 samvaCreate a server-only client helper:
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:
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { getSamva } from "$lib/server/samva";
const escapeHtml = (value: string): string =>
value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
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:
import { error, json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { getSamva } from "$lib/server/samva";
const escapeHtml = (value: string): string =>
value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
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.
Auth.js magic links
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.