Plunk Email SaaS Tutorial: Set Up Transactional Email in TanStack Start

By Aziz Ali
plunkemailtanstack-starttutorialsaas

I've paid Resend invoices for two years. Then one month I sent a few onboarding sequences and the bill jumped to $40. For a bootstrapped SaaS with 300 users. That's when I took a serious look at Plunk — and I haven't gone back.

This is the exact setup I use in every BetterStarter project: Plunk open-source email wired into TanStack Start, covering transactional emails for auth, onboarding, and billing events. No monthly per-email bill. No vendor lock-in.

Why Plunk Over Resend or SendGrid

Plunk is open-source and self-hostable. That's the whole story for indie hackers. You can start on their hosted free tier (generous enough for early-stage), then migrate to self-hosted on a $5/month VPS when you're ready to cut costs to near-zero.

The numbers speak plainly:

Feature Resend SendGrid Plunk (self-hosted)
Free tier 100 emails/day 100 emails/day Unlimited
50k emails/mo $20/mo $19.95/mo ~$0.50 (SES backend)
Self-hostable
Open source
API simplicity ★★★★ ★★★ ★★★★

For a deeper breakdown of when to choose which, I covered this in Resend vs Plunk. Short version: if you care about long-term costs and owning your infrastructure, Plunk wins.

Setting Up Plunk in TanStack Start

Here's the complete setup. I'll cover the hosted service first — you can swap in your self-hosted URL later without touching any application code.

Step 1: Install the SDK

bun add @plunk/node

Step 2: Add Environment Variables

PLUNK_API_KEY=sk_live_your_key_here
PLUNK_FROM_EMAIL=hello@yourdomain.com
PLUNK_FROM_NAME=YourApp

Get your API key from the Plunk dashboard. If you're self-hosting, this is the API key from your Plunk instance.

Step 3: Create the Email Service Module

I put this in src/lib/email.ts. One file, all your email logic:

import Plunk from "@plunk/node";

const plunk = new Plunk(process.env.PLUNK_API_KEY!);

export interface EmailPayload {
  to: string;
  subject: string;
  body: string;
}

export async function sendEmail(payload: EmailPayload) {
  try {
    const result = await plunk.emails.send({
      to: payload.to,
      subject: payload.subject,
      body: payload.body,
    });
    return { success: true, data: result };
  } catch (err) {
    console.error("[email] send failed:", err);
    return { success: false, error: err };
  }
}

export async function sendWelcomeEmail(to: string, name: string) {
  return sendEmail({
    to,
    subject: `Welcome to YourApp, ${name} 👋`,
    body: `
      <h2>You're in.</h2>
      <p>Hey ${name} — glad you're here. Here's how to get started:</p>
      <ol>
        <li>Complete your profile setup</li>
        <li>Connect your first integration</li>
        <li>Invite a teammate (or don't — solo is fine)</li>
      </ol>
      <p>Reply to this email if you have questions. I read everything.</p>
      <p>— Aziz</p>
    `,
  });
}

export async function sendPasswordResetEmail(to: string, resetUrl: string) {
  return sendEmail({
    to,
    subject: "Reset your password",
    body: `
      <p>Someone requested a password reset for your account.</p>
      <p><a href="${resetUrl}">Click here to reset your password</a> — expires in 1 hour.</p>
      <p>If you didn't request this, you can safely ignore this email.</p>
    `,
  });
}

export async function sendInvoiceEmail(
  to: string,
  amount: string,
  invoiceUrl: string
) {
  return sendEmail({
    to,
    subject: `Your invoice for ${amount}`,
    body: `
      <p>Thanks for your payment of ${amount}.</p>
      <p><a href="${invoiceUrl}">View your invoice here</a></p>
    `,
  });
}

Step 4: Call It From Server Functions

TanStack Start server functions are where all side effects happen. Here's how you trigger emails after signup:

import { createServerFn } from "@tanstack/start";
import { sendWelcomeEmail } from "~/lib/email";

export const createUser = createServerFn({ method: "POST" })
  .validator((data: { email: string; name: string; password: string }) => data)
  .handler(async ({ data }) => {
    // 1. Insert user into DB with Drizzle
    const user = await db.insert(users).values({
      email: data.email,
      name: data.name,
      passwordHash: await hash(data.password),
    }).returning();

    // 2. Fire welcome email — non-blocking
    sendWelcomeEmail(data.email, data.name).catch(console.error);

    return { userId: user[0].id };
  });

Notice I'm not await-ing the email in the critical path. Signup completes instantly; the email fires in the background. If it fails, it logs — but it doesn't break the user experience.

Wiring Plunk Into Better-Auth

Better-Auth handles auth emails natively — password resets, email verification — but you need to supply it a send function. Here's the full integration:

import { betterAuth } from "better-auth";
import { sendEmail } from "~/lib/email";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "sqlite" }),
  emailAndPassword: {
    enabled: true,
    sendResetPassword: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: "Reset your password",
        body: `<p><a href="${url}">Reset your password</a> — this link expires in 1 hour.</p>`,
      });
    },
  },
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: "Verify your email address",
        body: `<p><a href="${url}">Click here to verify your email</a></p>`,
      });
    },
  },
});

Two callbacks. That's your entire auth email setup. No separate email template service, no competing SDK configuration, no extra dependencies.

How the Full Email Flow Looks

flowchart LR
    A[User Action] --> B[TanStack Start Server Fn]
    B --> C{Trigger Type}
    C -->|Signup| D[sendWelcomeEmail]
    C -->|Password Reset| E[Better-Auth callback]
    C -->|Stripe Payment| F[sendInvoiceEmail]
    C -->|Custom Event| G[sendEmail]
    D & E & F & G --> H[Plunk API]
    H --> I[User Inbox]

One email module. Four trigger types. All routes to Plunk. When you self-host, H points to your VPS instead of app.useplunk.com — nothing else changes in your application.

Self-Hosting Plunk (When You're Ready)

This is optional, but I recommend it once you're past ~500 active users. You'll need:

  • A VPS (Hetzner CX11 at €3.29/month works fine)
  • Docker Compose
  • An SMTP backend — AWS SES at $0.10/1,000 emails is the obvious choice

Once Plunk is running on your VPS, update the SDK initialization to point at your instance:

const plunk = new Plunk(process.env.PLUNK_API_KEY!, {
  baseUrl: process.env.PLUNK_BASE_URL, // your self-hosted Plunk URL
});

Your application code doesn't change at all. Your email costs drop to near-zero. I cover the full VPS infrastructure setup in Self-Hosted Email for SaaS if you want the step-by-step guide.

FAQ

Is Plunk production-ready? Yes. Plunk is actively maintained and running in production across multiple SaaS products. The hosted service handles deliverability and domain authentication (SPF/DKIM) out of the box. For self-hosted, your SMTP backend handles deliverability — AWS SES delivers at 99.9%+.

Can I use Plunk for marketing emails too, not just transactional? Absolutely. Plunk has a contacts system and broadcast feature for marketing campaigns alongside the transactional API. You manage both from one dashboard — no need for a separate Mailchimp or ConvertKit account at early stage.

What about HTML email templates? Plunk accepts raw HTML in the body field. I keep a src/emails/ folder with TypeScript functions that return HTML strings. If you want React Email or MJML templates, render them to a string first and pass the output to sendEmail. Works perfectly.

How does Plunk handle deliverability vs established providers like Resend? The hosted Plunk service has solid deliverability and manages domain authentication for you. Self-hosted deliverability is only as good as your SMTP backend — but AWS SES and Postmark both deliver at 99%+. You're trading managed convenience for full control, not quality.

What's the migration path if I start on hosted and want to self-host later? One environment variable change: point PLUNK_BASE_URL at your self-hosted instance. Export your contacts from the Plunk dashboard, import them into your instance, done. I've done this migration in under two hours.


If you want to skip wiring all of this from scratch, BetterStarter ships with Plunk pre-configured — email service module, Better-Auth integration, and environment variable documentation all included. $99 one-time, and you're building your actual product on day one.

Want to see how the full stack fits together? Check out how BetterStarter helps you ship your SaaS fast without spending weekends on infrastructure.