React: Server Components and Server Actions
Send email from React using @tratto/email on the server. There is no browser SDK.
There is no @tratto/react package, and there will not be one. React
applications send email from the server, using
@tratto/email inside a Server Component, a Server Action
or a Route Handler.
This is not a limitation of Tratto. No transactional email provider ships a browser SDK, because an API key grants full access to the account: send as your verified domains, read your contacts, mint new keys, delete the workspace. A key in the browser bundle is a key you have published.
React is unusually well suited to this. Server Components give you a real compiler boundary (code in a server file never reaches the client bundle), so the correct pattern is also the natural one.
Install
npm install @tratto/emailKeep the key in the environment, without the NEXT_PUBLIC_ prefix. That prefix
is exactly what inlines a value into the browser bundle.
# .env.local (gitignored)
TRATTO_API_KEY=tratto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxUse tratto_test_... in development so a mistake cannot send real mail.
Guard the module
Install the server-only package and import it at the top of any file that
touches the SDK. If that module is ever pulled into a Client Component, the build
fails instead of shipping your key.
npm install server-only// lib/tratto.ts
import 'server-only';
import { Tratto } from '@tratto/email';
export const tratto = new Tratto(process.env.TRATTO_API_KEY!);This one import is the difference between a mistake caught at build time and a key leaked to production. Add it before you write the first call.
Server Action
A form that submits to a Server Action never exposes the key: the function body stays on the server and the browser only sends the form data.
// app/contact/actions.ts
'use server';
import { tratto } from '@/lib/tratto';
export async function sendContactMessage(formData: FormData) {
const email = formData.get('email');
const message = formData.get('message');
// Validate on the server. Never trust the submitted values.
if (typeof email !== 'string' || typeof message !== 'string') {
return { ok: false, error: 'Invalid submission' };
}
try {
await tratto.emails.send({
// `from` is a fixed verified address, never built from user input.
from: 'Acme <[email protected]>',
to: '[email protected]',
replyTo: email,
subject: 'New contact form submission',
text: message,
});
return { ok: true };
} catch (error) {
// Log the detail, return something generic.
console.error('tratto.emails.send failed', error);
return { ok: false, error: 'Could not send your message' };
}
}// app/contact/page.tsx
import { sendContactMessage } from './actions';
export default function ContactPage() {
return (
<form action={sendContactMessage}>
<input type="email" name="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}Server Component
Read data during render. The component runs on the server, so the SDK call never reaches the client.
// app/dashboard/page.tsx
import { tratto } from '@/lib/tratto';
export default async function DashboardPage() {
const summary = await tratto.analytics.getSummary({ period: '30d' });
return (
<dl>
<dt>Delivered</dt>
<dd>{summary.delivered}</dd>
<dt>Opened</dt>
<dd>{summary.opened}</dd>
</dl>
);
}Pass only the values the page needs into Client Components. Anything you hand
across that boundary is serialised into the HTML payload and is readable by the
visitor: send summary.delivered, not the whole API response.
Typing data in Client Components
There is no separate types package, and you do not need one. @tratto/email
exports every request and response type, and import type is erased at compile
time, so nothing is bundled:
// components/status-badge.tsx
'use client';
import type { EmailEvent } from '@tratto/email';
export function StatusBadge({ event }: { event: EmailEvent }) {
return <span>{event.type}</span>;
}Write import type, not import. Under verbatimModuleSyntax TypeScript emits
a plain import verbatim, which pulls the whole SDK (and its API key handling)
into the client bundle. Enable
@typescript-eslint/consistent-type-imports so the linter enforces it.
Reach for these types when your server forwards Tratto's own shapes, which is
mostly webhook events. For everything else, prefer returning a narrowed object
from the server and typing that: the browser has no reason to receive a full
EmailDetail when it renders a status string.
Route Handler
Use a Route Handler when something other than a form calls you: a webhook
receiver, a client-side fetch, or a third-party service.
// app/api/subscribe/route.ts
import { NextResponse } from 'next/server';
import { tratto } from '@/lib/tratto';
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
if (!body || typeof body.email !== 'string') {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
try {
const contact = await tratto.contacts.create({
email: body.email,
audienceId: 'aud_abc123',
});
return NextResponse.json({ id: contact.id });
} catch (error) {
console.error('tratto.contacts.create failed', error);
return NextResponse.json({ error: 'Could not subscribe' }, { status: 500 });
}
}Rate-limit any endpoint reachable from the public internet. Without it, a form that sends mail is a way for anyone to spend your sending quota and damage your domain reputation. See Rate Limits.
What not to do
// ❌ NEVER: 'use client' puts this in the browser bundle
'use client';
import { Tratto } from '@tratto/email';
// Published to every visitor.
const tratto = new Tratto(process.env.NEXT_PUBLIC_TRATTO_API_KEY!);Two separate mistakes, either of which is enough on its own: the SDK is
instantiated in a Client Component, and the key uses NEXT_PUBLIC_, which
inlines it as a string literal at build time.
The server-only import shown above turns the first mistake into a build error.
For the second, grep your build output.
Composing emails with React
React is genuinely useful for email in one place: writing the templates. Libraries such as React Email let you build a message as React components and render it to HTML on the server, then hand the result to Tratto:
// app/emails/welcome.tsx
export function WelcomeEmail({ name }: { name: string }) {
return (
<div>
<h1>Welcome, {name}</h1>
<p>Thanks for signing up.</p>
</div>
);
}import { render } from '@react-email/render';
import { WelcomeEmail } from '@/app/emails/welcome';
import { tratto } from '@/lib/tratto';
const html = await render(<WelcomeEmail name="Alice" />);
await tratto.emails.send({
from: 'Acme <[email protected]>',
to: '[email protected]',
subject: 'Welcome',
html,
});This is rendering, not sending: it runs on the server and involves no API key in the browser. Tratto Templates cover the same need if you prefer to manage content outside your codebase.
Checklist
-
import 'server-only'at the top of the module that creates the client - The key is
TRATTO_API_KEY, neverNEXT_PUBLIC_TRATTO_API_KEY - No
'use client'file imports@tratto/email - Public endpoints validate input and are rate-limited
-
fromis a fixed, verified address, never built from user input - Errors are logged on the server and returned as generic messages
-
grep -r "tratto_live" .next/staticreturns nothing after a production build
Run that last check in CI.
Related: Node.js SDK · Authentication · Angular
Edit this page on GitHub
Last updated on