Resend and the first verified send
Wire up Resend, the course's transactional email provider, and send a first email through a verified domain.
Every mutation you have built so far stayed inside your own walls: a Server Action takes a form, validates it with Zod, and writes a row to Postgres. This lesson is the first time your app reaches out to a system you don’t control, to put a message in someone’s inbox.
By the end you will have four things: a verified sending domain, a RESEND_API_KEY wired into your typed env, a lib/email.ts wrapper that returns the same Result shape your actions already speak, and one real email delivered through it.
The send itself is three lines.
Most of the lesson is the decisions made before those lines, because in email they decide whether a message lands in the inbox or the spam folder.
What you own when you send email
Section titled “What you own when you send email”Start from what the product needs. An app that can’t send email can’t verify a new user owns the address they typed, can’t let someone reset a forgotten password, can’t send a magic-link sign-in, and can’t email a receipt after a charge. Verification, password reset, magic-link sign-in, and billing receipts are load-bearing parts of the auth and billing flows you’re about to build, and every one is blocked until email works.
So email is infrastructure, and the first question to ask of any infrastructure is where the responsibility sits: what does my code own, and what does the platform own? That boundary is where the bugs and outages live. Here is the path one email takes; watch where the responsibilities fall.
The vendor handles the part that used to be a full-time job: speaking SMTP , running a pool of sending IP addresses, and keeping those IPs in good standing with the mailbox providers . That is exactly the part you do not want to own. Everything before that final arrow is yours: the domain the mail claims to come from, the credential that authorizes it, and the key that makes a retry safe. Resend can hand you a clean SMTP pipe, but it can’t decide which domain you send from, whether that domain is authenticated, or whether you’ve ruined your reputation by emailing addresses that don’t exist. Deliverability is the app’s responsibility, not the vendor’s, and that idea runs through the entire chapter.
The wrapper in the middle, sendEmail(), returns the same Result discriminated union your Server Actions already return.
Once it exists, sending an email from inside an action looks exactly like a database write: call it, branch on ok, surface the message on failure.
The plumbing is familiar; the decisions are what’s new.
Choosing a transactional email provider
Section titled “Choosing a transactional email provider”The provider is the first decision, before any code: the wrong choice costs you days of setup or years of dragged-down sender reputation .
For transactional mail in a Node and Next.js app, weigh five things:
- React rendering — can it render a React component into an email, keeping templates in the same component model as the rest of the app?
- A typed SDK — does the Node SDK type the send call, so a bad field name is a compile error rather than a 4am bounce?
- A free tier — can a brand-new project send without signing a contract?
- Verified webhooks — does it expose webhooks with signature verification, so you can later react to real bounce and complaint events?
- Fast first send — can one developer verify a domain and send in an afternoon?
Resend clears all five for an early-stage app, which is why it’s this course’s default.
A default still has thresholds where something else wins:
- Amazon SES is the cheapest option at real scale, but setup is days, not minutes: you wire IAM permissions, SNS topics for events, and SES itself, and you own more of the operational surface afterward. Reach for it once your send volume makes the per-message savings outweigh that ops tax.
- Postmark has the same posture as Resend: transactional-focused, deliverability-obsessed, clean API. It’s a credible swap; choose between the two on pricing and team preference.
- SendGrid, Mailchimp, and other marketing-first platforms are the wrong tool here. Routing transactional mail through a campaign-shaped account mixes two reputations on one identity, so a campaign’s spam complaints drag down the deliverability of your password resets. The next two lessons go deep on why.
The durable rule is the shape of the decision: for transactional mail, reach for a transactional-focused provider, never a marketing platform. Walk it in order: purpose first, then scale, then existing infrastructure.
Early-stage volume with no special constraints. The course’s pick.
High volume and a team already fluent in AWS, enough to justify the days of IAM/SNS/SES setup.
Same posture as Resend; pick it on pricing or because your team already runs it.
Campaign mail, on its own sending identity, never mixed with your password resets.
Three terms recur here and in the docs: an ESP sends mail on your behalf, transactional email is mail a user action triggers, and DX is how quickly a developer gets productive with a tool.
Verifying your sending domain
Section titled “Verifying your sending domain”The Resend dashboard’s buttons will move over time, so learn the shape of the process and the one reflex that never changes.
-
Create a Resend account. Email and password, or a GitHub login. The free tier covers this chapter.
-
Add your sending domain. This is the one real decision. Don’t add your bare apex domain (
yourapp.com); add a dedicated transactional subdomain likesend.yourapp.com. A later lesson explains why, but choosing it now keeps today’sfromaddresses honest so you won’t rewrite them later. -
Copy the DNS records Resend generates. Adding the domain produces a small set of records. You don’t need to know what each does yet, only that you have to publish them.
-
Add the records at your registrar. Paste them wherever your domain’s DNS lives, whether the registrar you bought it from or a provider like Cloudflare. Every registrar’s UI differs, so this step ages fastest.
-
Wait for verification. Resend re-checks the records and flips the domain to Verified once they resolve. DNS propagation usually takes a few minutes, occasionally up to 24 hours.
One reflex matters more than any step.
Every new account shares a sandbox domain, onboarding@resend.dev, so you can fire a test send the instant you sign up.
For real mail it’s a trap.
No email to a real user ever goes out from onboarding@resend.dev.
The whole planet shares that domain, so its reputation is a coin flip: your message lands in spam, and you train your own users to look for your mail in the junk folder from day one.
A verified domain is the price of admission to the inbox.
The Resend API key: sending-only, one per environment
Section titled “The Resend API key: sending-only, one per environment”Resend authenticates your sends with an API key, and how you hold that key tests two senior instincts.
The first is shape. Resend issues keys at two permission levels. A full-access key can do anything your account can: create domains, manage webhooks, read everything. A sending-only key can call the send endpoint and nothing else. Least privilege decides which to use: your running application gets a sending-only key, because sending is all it does, so a leak can’t delete your domain or hijack your webhooks. Full-access keys belong to one-off setup scripts you run by hand, never in your app’s environment.
The second is scope: one key per environment.
Give dev, preview, and production each their own, so a compromise is contained to one and you can rotate it without touching the rest.
# .env in dev, preview, AND production — the same secret reusedRESEND_API_KEY=re_shared_key_used_in_all_three_environmentsA staging leak takes production down with it. One key authorizes every environment, so a credential leaked from a throwaway preview deploy is also your production sending key. You can’t rotate it without breaking production.
# production env storeRESEND_API_KEY=re_live_production_only
# preview env storeRESEND_API_KEY=re_preview_only
# local .env (gitignored)RESEND_API_KEY=re_dev_onlyRotate any one in isolation. A leaked preview key is revoked and reissued without production ever noticing — key rotation scoped to a single environment.
Set up the split on day one; retrofitting it after a leak is the worst time to learn the lesson.
Each task touches Resend. Sort it under the key shape it should use. Drag each item into the bucket it belongs to, then press Check.
The env key and the lib/email.ts wrapper
Section titled “The env key and the lib/email.ts wrapper”Two additions: the env entry that validates the key, and the wrapper module every send flows through.
The env key
Section titled “The env key”RESEND_API_KEY is a server secret, and you already have a place for it.
In the data layer chapters you built lib/env.ts with @t3-oss/env-nextjs and Zod: a typed schema that validates every environment variable at build time and gives you one import that’s guaranteed populated.
Adding the email key is one line in the server block.
export const env = createEnv({ server: { DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), RESEND_API_KEY: z.string().min(1), }, // …client block, runtimeEnv mapping});With the key in the schema, a production build fails if RESEND_API_KEY is missing or empty.
The alternative is shipping the email feature with no credential and learning about it when the first verification email silently never arrives.
Installing the SDK
Section titled “Installing the SDK”You need two packages:
pnpm add resend react-emailresend is the SDK you call.
Resend renders email content from a React component, and react-email is what you build that component with.
The template work is the next chapter; today a one-line placeholder stands in so the wiring is honest.
The wrapper
Section titled “The wrapper”Every email your app sends goes through lib/email.ts. First, one architectural decision that’s easy to get wrong.
Seeing a third-party SDK, a careful engineer is tempted to hide it behind a generic EmailProvider interface so you could swap Resend out later.
Resist that.
This wrapper is a thin convenience layer, not an adapter: Resend is one of a few sanctioned SDK carve-outs that live in lib/ and call their vendor directly, alongside the background-job runner and the object store.
The course’s fifth architectural principle covers this case: the cost of the abstraction outweighs a swap you will almost certainly never make.
The wrapper exists for three concrete jobs: a default from address, the canonical Result return shape, and a reserved spot for the suppression check three lessons from now.
Here is the whole module, one part at a time.
import 'server-only';import type { ReactNode } from 'react';import { Resend } from 'resend';import { env } from '@/env';import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = { to: string; subject: string; react: ReactNode; replyTo?: string; idempotencyKey?: string;};
export async function sendEmail( input: SendEmailInput,): Promise<Result<{ id: string }>> { // The suppression check lands here — see the suppression-list lesson. const { data, error } = await resend.emails.send( { from: DEFAULT_FROM, to: [input.to], subject: input.subject, react: input.react, replyTo: input.replyTo, }, input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined, ); if (error || !data) return err('internal', 'Could not send email.'); return ok({ id: data.id });}This module holds your secret API key, so the first line makes it a build error for any client-side code to import it; the key can never reach a browser bundle. The client is constructed once at module load, a shared singleton reused across every send.
import 'server-only';import type { ReactNode } from 'react';import { Resend } from 'resend';import { env } from '@/env';import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = { to: string; subject: string; react: ReactNode; replyTo?: string; idempotencyKey?: string;};
export async function sendEmail( input: SendEmailInput,): Promise<Result<{ id: string }>> { // The suppression check lands here — see the suppression-list lesson. const { data, error } = await resend.emails.send( { from: DEFAULT_FROM, to: [input.to], subject: input.subject, react: input.react, replyTo: input.replyTo, }, input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined, ); if (error || !data) return err('internal', 'Could not send email.'); return ok({ id: data.id });}The default sender identity, in one place. Every send uses it unless a caller overrides it.
import 'server-only';import type { ReactNode } from 'react';import { Resend } from 'resend';import { env } from '@/env';import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = { to: string; subject: string; react: ReactNode; replyTo?: string; idempotencyKey?: string;};
export async function sendEmail( input: SendEmailInput,): Promise<Result<{ id: string }>> { // The suppression check lands here — see the suppression-list lesson. const { data, error } = await resend.emails.send( { from: DEFAULT_FROM, to: [input.to], subject: input.subject, react: input.react, replyTo: input.replyTo, }, input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined, ); if (error || !data) return err('internal', 'Could not send email.'); return ok({ id: data.id });}The wrapper takes a single options object, not positional arguments, so call sites stay self-documenting. react is a ReactNode because the email body is a component; the subject is a plain string, and replyTo and idempotencyKey are optional.
import 'server-only';import type { ReactNode } from 'react';import { Resend } from 'resend';import { env } from '@/env';import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = { to: string; subject: string; react: ReactNode; replyTo?: string; idempotencyKey?: string;};
export async function sendEmail( input: SendEmailInput,): Promise<Result<{ id: string }>> { // The suppression check lands here — see the suppression-list lesson. const { data, error } = await resend.emails.send( { from: DEFAULT_FROM, to: [input.to], subject: input.subject, react: input.react, replyTo: input.replyTo, }, input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined, ); if (error || !data) return err('internal', 'Could not send email.'); return ok({ id: data.id });}A deliberate seam. Three lessons from now this comment becomes a check for whether the address has bounced or complained before any send goes out. It lives in the one wrapper every send flows through, so no caller can forget it.
import 'server-only';import type { ReactNode } from 'react';import { Resend } from 'resend';import { env } from '@/env';import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = { to: string; subject: string; react: ReactNode; replyTo?: string; idempotencyKey?: string;};
export async function sendEmail( input: SendEmailInput,): Promise<Result<{ id: string }>> { // The suppression check lands here — see the suppression-list lesson. const { data, error } = await resend.emails.send( { from: DEFAULT_FROM, to: [input.to], subject: input.subject, react: input.react, replyTo: input.replyTo, }, input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined, ); if (error || !data) return err('internal', 'Could not send email.'); return ok({ id: data.id });}The send itself. to is an array because Resend accepts multiple recipients. The second argument passes only the idempotencyKey, conditionally, so a retried send never produces two emails. That key is this lesson’s last section.
import 'server-only';import type { ReactNode } from 'react';import { Resend } from 'resend';import { env } from '@/env';import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = { to: string; subject: string; react: ReactNode; replyTo?: string; idempotencyKey?: string;};
export async function sendEmail( input: SendEmailInput,): Promise<Result<{ id: string }>> { // The suppression check lands here — see the suppression-list lesson. const { data, error } = await resend.emails.send( { from: DEFAULT_FROM, to: [input.to], subject: input.subject, react: input.react, replyTo: input.replyTo, }, input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined, ); if (error || !data) return err('internal', 'Could not send email.'); return ok({ id: data.id });}Resend returns { data, error } and does not throw on a failed send. A validation error, a rate-limit, or an unverified from all come back as a populated error beside a null data, so code that reads only data treats a silent failure as success. Check error first, map it into the Result your action layer understands, and trust data only once you’ve ruled the error out. The wrapper does this once so no caller has to.
Sending your first email
Section titled “Sending your first email”First, the bare SDK call, with none of your wrapper around it.
const { data, error } = await resend.emails.send( { from: 'YourApp <noreply@send.yourapp.com>', to: ['delivered@resend.dev'], subject: 'Welcome to YourApp', react: <WelcomeEmail name="Ada" />, }, { idempotencyKey: 'welcome-user/usr_123' },);The recipient, delivered@resend.dev, is one of Resend’s test addresses: it has no real mailbox and always reports a successful delivery, which is exactly what a first verify needs.
Its siblings bounced@ and complained@ simulate failures you’ll use when you build the webhook handler.
The react prop needs a component. Until the next chapter builds real templates, a one-line placeholder keeps the call honest:
// Placeholder — the next chapter replaces this with a real React Email template.const WelcomeEmail = ({ name }: { name: string }) => <p>Welcome, {name}!</p>;Now wire it through your wrapper.
From a small Server Action, or a one-off script if you’d rather not build a form yet, the call uses the familiar Result shape:
const result = await sendEmail({ to: 'delivered@resend.dev', subject: 'Welcome to YourApp', react: <WelcomeEmail name="Ada" />, idempotencyKey: 'welcome-user/usr_123',});
if (!result.ok) { // surface result.error.userMessage to the caller}Run it.
The lesson’s done condition is three matching signals: the call returns ok with a data.id, and the send shows as delivered in both your Resend dashboard and delivered@resend.dev — a real email, through your own verified domain and wrapper, returning your own Result.
The from line and the replyTo reflex
Section titled “The from line and the replyTo reflex”You’ve sent twice from YourApp <noreply@send.yourapp.com>.
Two audiences read that string, the human recipient and the receiving mailbox provider, and a careless one costs you with both.
It has three parts, each a decision:
The display name and verified subdomain are forced: your product’s name so the inbox shows something human, and a verified domain or Resend rejects the send.
The local part, before the @, is where you have room to be deliberate.
Name the local part for the intent the user reads off the line, not the system that sends the mail.
noreply@, billing@, and security@ are signals: a user who sees security@ knows to pay attention, and a filter can route billing@ to a folder.
An address like auth-service-prod@ leaks your internal architecture and signals nothing — a code smell.
The second reflex is replies.
A from: noreply@… that swallows every reply disappoints the user who hits reply on a receipt or a security alert, and it’s a faint negative signal to providers, which reward senders who behave like they can be reached.
The fix is the reply-to header: keep the bot identity in from, but point replyTo at a mailbox a human monitors.
await sendEmail({ to: 'delivered@resend.dev', replyTo: 'support@yourapp.com', // …from defaults to the noreply identity in the wrapper subject: 'Your receipt', react: <Receipt /* … */ />,});The inbox still shows the automated noreply@, but the reply lands in support@, where someone reads it.
Idempotency, rate limits, and the three environments
Section titled “Idempotency, rate limits, and the three environments”Three send-time disciplines remain, all about a send that gets retried, duplicated, or fired in the wrong environment.
The idempotency key
Section titled “The idempotency key”Distributed systems retry. A Server Action times out and the framework retries it, a user clicks “resend” before the first attempt finished, or a webhook gets redelivered. Each retry is another email unless you stop it: two welcome messages, two receipts for one charge. The fix is an idempotency key, a stable identifier you attach to the send so Resend recognizes a repeat and sends only once.
Resend takes it as the second argument’s idempotencyKey option (max 256 characters, remembered for 24 hours).
A fresh random value per attempt defeats the purpose: every retry would look like a new event.
Build the key from the stable id the event already has, in Resend’s recommended <event-type>/<entity-id> form:
const welcomeKey = `welcome-user/${userId}`;const resetKey = `password-reset/${requestId}`;const receiptKey = `invoice-receipt/${invoiceId}`;Every retry of the welcome email for user 123 now carries the identical key welcome-user/123, and Resend collapses them to one send.
The <event-type>/ prefix namespaces the key so a user id and an invoice id can never collide.
You’ll meet this pattern again in webhooks and background jobs.
Rate limits
Section titled “Rate limits”Resend’s default limit is 5 requests per second per team, shared across the team’s API keys and raisable on request. A Server Action that sends one email per request never comes near it. The limit only bites on a bulk path, such as inviting a whole team or sending a digest to thousands of users. For those, Resend’s batch endpoint sends up to 100 emails in a single call that counts as one request, paired with the same idempotency discipline.
The three environments
Section titled “The three environments”Where you send depends on where you’re running.
- Dev. Send to your own inbox or the
*@resend.devtest addresses, never to real users from your laptop. - Preview. Every pull-request preview deploy can run your Server Actions, so a careless email action could reach real users from a throwaway branch. Gate sending behind a flag or a recipient allowlist.
- Production. The verified domain is required, and thanks to the env key you wired earlier, the build won’t boot without
RESEND_API_KEY.
One scenario pulls these threads together. Think it through before you check the answer.
A password-reset Server Action takes too long and times out. The user, seeing nothing happen, clicks “resend” — and meanwhile the framework retries the original call on its own. Three send attempts are now in flight for one reset request. What stops the user from getting three reset emails?
idempotencyKey, so Resend collapses the duplicates into one sendpassword-reset/req_abc — built from the one id the reset request already has. Resend sees the repeats and sends a single email. A higher rate limit doesn’t dedupe anything; disabling retries throws away a useful safety mechanism and doesn’t stop the user’s manual click; and the suppression list is real but solves a different problem — it stops sends to addresses that bounced or complained, the subject of the suppression-list lesson later in this chapter, not duplicate sends.External resources
Section titled “External resources”Resend’s own documentation is the source of truth for the API surface, and it stays current as the dashboard moves. These cover everything this lesson touched.
The SDK quickstart and the emails.send reference, including the options this lesson used.
The domain-verification ceremony and the records Resend asks for.
Key permission levels and the per-environment discipline.
The retry-safety reflex from this lesson, with the exact <event-type>/<entity-id> key shape.
The component model behind the react prop — the templating tool the next chapter builds on.
The next lesson covers the DNS records you copied without reading, SPF, DKIM, and DMARC, and why an email that fails all three is essentially undeliverable.