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/emailAdd 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
For newsletters or bulk sends, 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
scheduledAttosend(); omit it and the campaign goes out immediately. - A/B testing is on the subject line only.
subjectAandsubjectBare 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, usecampaigns.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
- User triggers action (signup, order, password reset)
- Server Action calls
tratto.emails.send() - Email queued in seconds
- Webhook confirms delivery
- You're done
No SMTP. No credentials. No infrastructure to babysit.
For more details, check out our full API documentation. Ready to try it? Join Tratto beta and start sending email the modern way.