Skip to content
Back to Guides

TanStack Start

Updated

Send emails from TanStack Start with Lumail

Lumail itself runs on TanStack Start. Use a server function when your own UI triggers the email, and a server route when something external calls in. Both run only on the server, so the API key never reaches the browser.

TL;DR

Run npm install lumail, set LUMAIL_API_KEY in .env (no VITE_ prefix), create a client module, then call lumail.emails.send inside createServerFn(...).handler() or a route's server.handlers.POST. Check the returned error before reading data.id.

Prerequisites

  • A TanStack Start app on a recent 1.x release.
  • A Lumail API token that starts with lum_.
  • A sending domain verified in the same Lumail organization as the token.

1. Install the packages

Terminal
npm install lumail zod

2. Set your environment variables

TanStack Start runs on Vite. Variables prefixed with VITE_ are inlined into the client bundle, so the token must not use that prefix. Read it with process.env on the server.

Set the same variable in your host's environment for production builds.

.env
# Server-side only. Create it in Lumail under Settings > API Tokens. LUMAIL_API_KEY=lum_your_api_token

3. Create the client module

Import this module only from server functions and server routes. The Start compiler removes server function bodies from the client build, so the import stays on the server.

src/lib/lumail.ts
import { Lumail } from "lumail"; const apiKey = process.env.LUMAIL_API_KEY; if (!apiKey) { throw new Error("LUMAIL_API_KEY is not set"); } export const lumail = new Lumail({ apiKey });

4. Send from a server function

createServerFn gives you a typed RPC: validate the input, send, return a serializable result. Throwing inside the handler rejects the promise on the client, which pairs well with TanStack Query mutations.

The validator here uses Zod 4. Any function that returns the parsed input works too.

src/features/welcome/send-welcome.ts
import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { lumail } from "@/lib/lumail"; export const sendWelcomeEmail = createServerFn({ method: "POST" }) .inputValidator(z.object({ email: z.email(), name: z.string().min(1) })) .handler(async ({ data }) => { const result = await lumail.emails.send({ from: "Acme <[email protected]>", // must be on a verified domain to: data.email, subject: "Welcome to Acme", markdown: `Hi ${data.name}, your account is **ready**.`, }); if (result.error) { console.error("Lumail send failed", result.error.name, result.error.message); throw new Error("We could not send the welcome email"); } return { id: result.data.id }; });
src/features/welcome/welcome-button.tsx
import { useServerFn } from "@tanstack/react-start"; import { sendWelcomeEmail } from "./send-welcome"; export function WelcomeButton({ email, name }: { email: string; name: string }) { const send = useServerFn(sendWelcomeEmail); return ( <button type="button" onClick={() => send({ data: { email, name } })}> Send welcome email </button> ); }

5. Or expose a server route

Server routes are plain HTTP handlers on a file route. Use them for webhooks, cron callers or other services. Handlers receive the standard Request and return a Response.

src/routes/api/welcome.ts
import { createFileRoute } from "@tanstack/react-router"; import { lumail } from "@/lib/lumail"; export const Route = createFileRoute("/api/welcome")({ server: { handlers: { POST: async ({ request }) => { const { email } = (await request.json()) as { email?: string }; if (!email) { return Response.json({ error: "email is required" }, { status: 400 }); } const { data, error } = await lumail.emails.send({ from: "Acme <[email protected]>", to: email, subject: "Welcome to Acme", html: "<p>Your account is ready.</p>", }); if (error) { return Response.json({ error: error.message }, { status: 502 }); } return Response.json({ id: data.id }); }, }, }, });

6. Test with a fixture address

Call the server function with [email protected]. Lumail runs the full send path, domain check included, and returns an id without delivering anything. Switch to your own inbox for one real send, then check it with the mail tester.

Handle errors

The SDK resolves to { data, error } and does not throw on API errors. In a server function, decide what the client should see: throw a generic error for the UI and log error.name and error.message on the server.

Never forward the raw Lumail error to the browser in a public form. It can mention your domain configuration.

Lumail send errors
Statuserror.nameWhat to do
401missing_api_keyMissing or invalid token. Check the env var on the server that sends.
403missing_permissionThe token lacks the emails permission. Mint one that has it.
402plan limitThe organization used its plan's email volume. Upgrade or wait for the next period.
4xxvalidation_errorMissing field, more than one body format, or a from domain that is not verified.
429RECIPIENT_RATE_LIMITEDThat mailbox hit 5 sends in 10 minutes or 20 in 24 hours. Honor Retry-After.
nonenetwork_error / timeoutThe request never got an answer. Safe to retry with the same idempotency key.

Make retries safe with idempotency

Mutations can be retried by TanStack Query, by a user double-clicking, or by your own code. Pass { idempotencyKey } as the second argument so a repeated call returns the already-queued email.

src/features/welcome/send-welcome.ts
const result = await lumail.emails.send( { from, to: data.email, subject: "Welcome to Acme", markdown }, { idempotencyKey: `welcome:${data.email}` }, );

Verify your sending domain

Lumail only sends from a domain you have verified. Add the domain in your organization's Domains settings, then publish the SPF, DKIM and DMARC records it shows at your DNS provider. Until the domain verifies, every send fails with an error saying the domain is not authorized or verified.

Use a subdomain such as mail.yourdomain.com if your root domain already sends from another provider. Start DMARC at p=none, then tighten it once reports look clean.

Production checklist

  • No environment variable holding the token starts with VITE_.
  • src/lib/lumail.ts is imported only from server functions and server routes.
  • The from address is on a domain verified in the same organization as the token, with SPF, DKIM and a DMARC record.
  • LUMAIL_API_KEY is set on every environment that sends, never committed, never in a public or client-side variable. Use one token per environment.
  • Every send that can be retried (webhooks, queues, jobs) passes an idempotency key derived from the triggering event.
  • One-time codes and magic links set tracking: { links: false, open: false } so links are not rewritten.
  • End-to-end tests send to fixture addresses such as [email protected], which return an id without sending real email.
  • A failed email is logged with error.name and never fails the user's request on its own.

Frequently asked questions

Server function or server route: which one should send the email?

Use a server function when your own components trigger the email, because you get typed input and output with no fetch boilerplate. Use a server route when an external system calls you, such as Stripe, a cron service or another backend.

Why not use a VITE_ environment variable for the API key?

Vite inlines every VITE_ variable into the client bundle, so anyone could read the key in the browser and send email from your domain. Keep the name LUMAIL_API_KEY and read it with process.env on the server.

Does the Lumail SDK work on every TanStack Start deployment target?

The SDK only uses fetch and standard Web APIs to send, so it runs on Node.js servers and serverless targets. Node.js 20 or later is the tested baseline.

Can I render React Email templates in a server function?

Yes. Call render from @react-email/render inside the handler and pass the HTML string to lumail.emails.send. The React Email guide shows the full template and plain-text setup.

Is Lumail built with TanStack Start?

Yes. The Lumail app runs on TanStack Start, which is why this guide follows the same server function and server route patterns used in production.

Keep building

Send your first email from code today.

3,000 emails a month free. Transactional and marketing email on one verified domain, with unlimited subscribers on every plan.