Samva is in early access — self-serve signup is limited. Have a team invite? Sign up with that email. Contact us for access.

TanStack Start

Send email with TanStack Start server functions and server routes using the Samva SDK.

TanStack Start

Send transactional email from TanStack Start with the samva SDK. Use a server function for in-app flows, or a server route when an external system needs to POST raw HTTP to your app.

The same integration shape works on edge runtimes: server-only API keys, a fetch-based SDK, and React Email rendering when you need component templates.

Install

bun add samva zod

Set a server-only API key. Do not use a VITE_ prefix.

SAMVA_API_KEY=sk_sm_your_key_here
// src/lib/samva.ts
import "@tanstack/react-start/server-only";
import { createClient } from "samva";

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

  return createClient({ apiKey });
}

Build the client inside the handler. On Cloudflare Workers and other edge SSR runtimes, process.env is injected per request, so module-scope env reads can run too early.

Server function quickstart

// src/functions/send-email.ts
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";

import { getSamva } from "~/lib/samva";

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

export const sendEmail = createServerFn({ method: "POST" })
  .inputValidator(
    z.object({
      to: z.string().email(),
      subject: z.string().min(1),
      message: z.string().min(1),
    }),
  )
  .handler(async ({ data }) => {
    const html = `<p>${escapeHtml(data.message).replaceAll("\n", "<br />")}</p>`;

    await getSamva().messages.send({
      to: [{ email: data.to }],
      channel: "email",
      email: {
        subject: data.subject,
        html,
        text: data.message,
      },
    });

    return { ok: true };
  });

Call it from a component with useServerFn:

import { useServerFn } from "@tanstack/react-start";
import { useState } from "react";

import { sendEmail } from "~/functions/send-email";

export function ContactForm() {
  const send = useServerFn(sendEmail);
  const [pending, setPending] = useState(false);

  return (
    <form
      onSubmit={async (event) => {
        event.preventDefault();
        const form = new FormData(event.currentTarget);
        setPending(true);
        try {
          await send({
            data: {
              to: String(form.get("to") ?? ""),
              subject: String(form.get("subject") ?? ""),
              message: String(form.get("message") ?? ""),
            },
          });
        } finally {
          setPending(false);
        }
      }}
    >
      <input name="to" type="email" required />
      <input name="subject" required />
      <textarea name="message" required />
      <button disabled={pending}>{pending ? "Sending..." : "Send"}</button>
    </form>
  );
}

There is no from field. Samva sends from the verified sender configured on your account.

Server route quickstart

Use a server route for raw HTTP:

// src/routes/api/send.ts
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";

import { getSamva } from "~/lib/samva";

const sendRouteInput = z
  .object({
    to: z.string().email(),
    subject: z.string().min(1),
    html: z.string().min(1).optional(),
    text: z.string().min(1).optional(),
  })
  .refine((value) => value.html || value.text, {
    message: "html or text is required",
  });

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

export const Route = createFileRoute("/api/send")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const payload = await request.json().catch(() => null);
        const parsed = sendRouteInput.safeParse(payload);
        if (!parsed.success) {
          return Response.json({ error: "Invalid send payload" }, { status: 400 });
        }

        const body = parsed.data;
        const text = body.text;
        const html = body.html ?? (text ? `<p>${escapeHtml(text).replaceAll("\n", "<br />")}</p>` : undefined);

        await getSamva().messages.send({
          to: [{ email: body.to }],
          channel: "email",
          email: {
            subject: body.subject,
            ...(html ? { html } : {}),
            ...(text ? { text } : {}),
          },
        });

        return Response.json({ ok: true });
      },
    },
  },
});

Server functions are RPC endpoints too, so enforce auth and rate limits inside the server function, middleware, or server route handler.

React Email

Add React Email when you want component templates:

bun add react-email react react-dom
import { render, toPlainText } from "react-email";

import WelcomeEmail from "~/emails/welcome";
import { getSamva } from "~/lib/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 template structure, previewing, and edge rendering details.

Full cookbook and example

The cookbook and example links resolve after the companion samva-integrations PR lands.

On this page