TanStack Start

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

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=samva_sk_live_your_api_key
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" })
  .validator(
    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 TanStack Start cookbook goes deeper: choosing a server function versus a server route, why VITE_SAMVA_API_KEY is unsafe, what should fail loudly versus fail soft, and how to queue bulk sends. The tanstack-start-transactional example is a runnable project with both send paths wired up.

On this page