← Back to blog

How to Send Transactional Email in Next.js

A practical guide to sending password resets, welcome emails, and order confirmations from Next.js server actions, without wrestling with SMTP.

Sending email from Next.js is still overly complicated.

Most tutorials tell you to set up SMTP, rotate credentials, manage bounce rates. That's infrastructure work, not application work.

Here's how to do it right in 2026.

The Setup

Install the SDK:

npm install @tratto/email

Add your API key to .env.local:

TRATTO_API_KEY=tratto_live_...

Do not prefix it with NEXT_PUBLIC_. Next.js inlines the value of every NEXT_PUBLIC_* variable into the client bundle at build time, so a NEXT_PUBLIC_API_KEY is shipped to every visitor's browser, readable in devtools, and effectively published. An API key belongs in an unprefixed variable, read only from server code: a Server Action or a Route Handler, never a component that runs in the browser.

The Pattern: Server Action

Use a Next.js Server Action to send email:

"use server"
 
import { Tratto } from '@tratto/email'
 
const tratto = new Tratto(process.env.TRATTO_API_KEY!)
 
export async function sendWelcomeEmail(email: string, name: string) {
  try {
    await tratto.emails.send({
      from: '[email protected]',
      to: email,
      subject: `Welcome, ${name}!`,
      html: `<h1>Hi ${name}</h1><p>Great to have you here.</p>`,
    })
  } catch (err) {
    console.error('Email failed:', err)
    throw new Error('Failed to send welcome email')
  }
}

Call it from a Client Component:

"use client"
 
import { sendWelcomeEmail } from '@/app/actions'
import { useState } from 'react'
 
export function SignupForm() {
  const [loading, setLoading] = useState(false)
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    setLoading(true)
 
    const formData = new FormData(e.currentTarget)
    const email = String(formData.get('email'))
    const name = String(formData.get('name'))
 
    try {
      await sendWelcomeEmail(email, name)
      alert('Check your inbox!')
    } catch (err) {
      alert('Something went wrong')
    } finally {
      setLoading(false)
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <input name="name" type="text" required />
      <button disabled={loading}>{loading ? 'Sending...' : 'Sign up'}</button>
    </form>
  )
}

That's it. No SMTP. No credentials to rotate. No bounce list to manage.

Real-World Patterns

Password Reset

export async function sendPasswordReset(email: string, resetUrl: string) {
  await tratto.emails.send({
    from: '[email protected]',
    to: email,
    subject: 'Reset your password',
    html: `
      <h2>Password Reset</h2>
      <p><a href="${resetUrl}">Click here to reset your password</a></p>
      <p>This link expires in 1 hour.</p>
    `,
  })
}

Order Confirmation

export async function sendOrderConfirmation(
  email: string,
  orderId: string,
  total: number
) {
  await tratto.emails.send({
    from: '[email protected]',
    to: email,
    subject: `Order #${orderId} confirmed`,
    html: `
      <h2>Thanks for your order!</h2>
      <p>Order ID: ${orderId}</p>
      <p>Total: $${total.toFixed(2)}</p>
      <p><a href="https://yourdomain.com/orders/${orderId}">View order</a></p>
    `,
  })
}

Batch Emails

To send many different emails in one request (a reminder per user, a receipt per order), use emails.sendBatch(), which calls POST /v1/emails/batch. It requires @tratto/email 1.6.0 or later.

export async function sendTrialReminders(users: { email: string; name: string }[]) {
  const result = await tratto.emails.sendBatch(
    users.map((user) => ({
      from: '[email protected]',
      to: user.email,
      subject: 'Your trial ends tomorrow',
      html: `<p>Hi ${user.name}, your trial ends tomorrow.</p>`,
    }))
  )
 
  // A batch can be accepted in part: check each email.
  const failed = result.emails.filter((email) => email.status === 'failed')
  if (failed.length > 0) {
    // Retry only these, in a new batch.
  }
}

A few things to know about batches:

  • Up to 100 emails per call, each with the same fields as emails.send() except scheduledAt: a batch is sent right away. To schedule, use emails.send().
  • Validation is all or nothing. One invalid email rejects the whole batch, and the error names which one. Nothing is sent and nothing counts against your quota.
  • Quota works as in emails.send(): each distinct recipient counts as one email, so the same address in two emails counts twice. If fewer remain this month than the batch needs, none of it is sent.
  • Idempotency covers the whole batch. Pass a key as the second argument, sendBatch(emails, key). To retry the failed emails, send them in a new batch with a new key.

Campaigns

For newsletters to an audience, use the campaigns API. It works differently from emails.send(): a campaign never takes an inline list of recipients. It points at an audience you've already built, and at a template, so the content and the list both live in Tratto rather than in your request body.

export async function sendNewsletter(templateId: string, audienceId: string) {
  const campaign = await tratto.campaigns.create({
    name: 'Weekly Newsletter',
    templateId,
    audienceId,
    fromName: 'Acme',
    fromEmail: '[email protected]',
    subjectA: 'This week in tech',
    subjectB: 'The 5 things you missed this week', // optional: A/B on the subject
  })
 
  // Send now...
  await tratto.campaigns.send(campaign.id)
}
 
// ...or schedule it, with the same call. These are two alternatives, not two
// steps: the first send() moves the campaign out of `draft`, and only draft or
// paused campaigns can be sent, so calling both in a row fails with CONFLICT.
export async function scheduleNewsletter(campaignId: string) {
  await tratto.campaigns.send(campaignId, {
    scheduledAt: new Date('2026-03-01T09:00:00Z'),
  })
}

Three things worth knowing before you wire this up:

  • Scheduling is not a separate endpoint. You schedule by passing scheduledAt to send(); omit it and the campaign goes out immediately.
  • A/B testing is on the subject line only. subjectA and subjectB are the two variants; there's no body-level split.
  • Campaign sending is livemode only. A test key can create a campaign, but it can't send one — and campaigns.testSend() is livemode-only too, so there is no test-key route to a campaign send at all. To put one in your own inbox first, use campaigns.testSend(id, '[email protected]') with a live key.

The rest of the resource is what you'd expect: create, list, get, getStats, send, pause, testSend.

Error Handling

Always wrap email sends in try-catch. Email failures shouldn't crash your app:

try {
  await tratto.emails.send({ /* ... */ })
} catch (err) {
  // Log to error tracking (Sentry, etc)
  captureException(err)
 
  // Optionally retry
  if (shouldRetry(err)) {
    // Queue for retry
  }
 
  // Return gracefully
  return { success: false, message: 'Email delivery queued' }
}

Observability

Tratto webhooks let you track delivery:

// Handle webhooks from Tratto
export async function POST(req: Request) {
  const event = await req.json()
 
  if (event.type === 'delivered') {
    console.log(`Email to ${event.recipient} was delivered`)
  }
 
  if (event.type === 'bounced') {
    console.log(`Email to ${event.recipient} bounced`)
  }
 
  return new Response('OK')
}

Subscribe to webhooks in your Tratto dashboard, then point the webhook URL to /api/webhooks/tratto.

That's the Flow

  1. User triggers action (signup, order, password reset)
  2. Server Action calls tratto.emails.send()
  3. Email queued in seconds
  4. Webhook confirms delivery
  5. You're done

No SMTP. No credentials. No infrastructure to babysit.


For more details, check out our full API documentation, or see what the Tratto email API covers beyond transactional sends.