Next.js boilerplate with Resend
Transactional email with React Email templates you review in a pull request.
Transactional email through Resend, behind one provider-agnostic send function. React Email templates cover sign-in links, password resets, email checks, welcome and receipts. Every transactional send carries an idempotency key. A bounce webhook stops you mailing an address that no longer exists.
What Resend adds to the agent layer: 1 rule · 2 skills · 5 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Resend?
Pick it if
Product teams sending transactional mail: magic links, receipts, notifications. Templates are React components, reviewed in a pull request, not pasted into a vendor's WYSIWYG editor.
Watch out for
- Transactional first. There is a broadcast product, but if you need segmentation, drip campaigns and a marketing CRM, this is not that tool.
- Shared IPs by default. Dedicated IPs are a paid add-on on the Scale plan, so on the shared pool your own sending hygiene matters more.
Show 3 moreShow fewer
- Data is kept 30 days. If you need to prove what you sent someone six months ago, log the message id at send time. The repo does.
- React Email is the headline feature and a real one: templates are components, previewable locally, diffable in review. It also means your email templates are part of your build.
- Fewer knobs than a full ESP: no server-side template variables, no A/B testing, limited routing rules. For transactional mail that is a simpler model, not a gap.
What it costs
Free: 3,000 emails a month (100 a day) on up to 3 domains. Pro is $20/month for 50,000 emails and 10 domains. Every self-serve plan keeps 30 days of data. Dedicated IPs are a $30/month add-on on Scale.
Prices change. Check with Resend before you commit.
registry/tested.yaml
Tested with Resend
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Resend adds to the repo
Read straight from the resend manifest, so it is exactly what lands in your repo.
Environment variables
RESEND_API_KEYRequired
Server-side API key from the Resend dashboard. Create it with "Sending access" only: a full-access key can also read your audiences and delete domains, and nothing in this repo needs that. Never expose it to the browser. A sending-scoped key cannot list domains, so
bun run verifyreports the domain check as "not read" rather than failing; that is the intended trade and the dashboard is the place to confirm the domain is verified.- Where to get it
- https://resend.com/api-keys
- Placeholder
- re_replace_me
EMAIL_FROMRequired
The From address, in "Name <address@domain>" form. The domain must be verified in Resend first (SPF, DKIM and a DMARC record) or every send fails with a 403. Use a subdomain such as mail.yourdomain.com so that a deliverability problem with transactional mail never touches your primary domain's reputation.
- Where to get it
- https://resend.com/domains
- Placeholder
- my-app <hello@mail.example.com>
RESEND_WEBHOOK_SECRETOptional
Signing secret for the delivery-event endpoint at /api/webhooks/resend. Resend signs with Svix, and the route rejects any delivery it cannot verify. Optional locally; set it before launch, or bounces and spam complaints never reach your suppression list.
- Where to get it
- https://resend.com/webhooks
- Placeholder
- whsec_replace_me
EMAIL_OUTBOX_DIROptional
Development and tests only. When set,
sendEmailwrites each message as a JSON file in this folder instead of sending it, so sign-in and reset links work with no inbox and no provider account. The end-to-end tests read their links from it (.e2e/outbox). Ignored on a deployment: it works in development and in a production build served on localhost.- Placeholder
REPLY_TOOptional
Default Reply-To for every send. Falls back to EMAIL_FROM when unset. Point it at a mailbox a human actually reads: replies to a black-hole address hurt deliverability and infuriate the person who tried.
- Placeholder
- support@example.com
Dependencies
- @react-email/render^2.1.0
- react-email^6.11.0
- resend^6.30.0
- server-only^0.0.1
- svix^2.5.0
Scripts
- bun run email:dev
bunx email dev --dir src/lib/email/templates
- bun run email:send-test
bun --conditions=react-server scripts/email/send-test.ts
Files it writes
26 files, at these exact paths.
scripts/2 files
email/2 files
- render-samples.ts
- send-test.ts
src/14 files
app/1 file
api/1 file
webhooks/1 file
resend/1 file
- route.ts
lib/13 files
email/13 files
templates/6 files
- magic-link.tsx
- receipt.tsx
- reset-password.tsx
- theme.ts
- verify-email.tsx
- welcome.tsx
- address.ts
- index.ts
- outbox.ts
- resend.ts
- retry.ts
- suppression.ts
- tags.ts
tests/2 files
unit/2 files
- email-outbox.test.ts
- email.test.ts
variants/8 files
errors-none/1 file
src/1 file
lib/1 file
email/1 file
- observability.ts
errors-sentry/1 file
src/1 file
lib/1 file
email/1 file
- observability.ts
orm-drizzle/3 files
slots/1 file
- db-schema.ts
src/2 files
db/1 file
- email-schema.ts
lib/1 file
email/1 file
- store.ts
orm-none/1 file
src/1 file
lib/1 file
email/1 file
- store.ts
orm-prisma/2 files
slots/1 file
- prisma-models.prisma
src/1 file
lib/1 file
email/1 file
- store.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot env-required
- @slot legal-processors
- @slot verify-checks
The differentiator
What Resend teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (1)
Loaded when the agent opens a matching file.
Every email goes through sendEmail, from a verified domain, with a reply-to
Loads onsrc/lib/email/**src/app/api/webhooks/resend/**src/lib/auth/**src/lib/billing/**.claude/rules/email-sending-discipline.md
One surface
sendEmail() from @/lib/email is the only way this app sends mail. Nothing
else imports resend, constructs a Resend, or reads RESEND_API_KEY. That is
what keeps the provider swappable (the Mailgun battery exports sendEmail,
SendEmailOptions, SendEmailResult, EmailAttachment and
EmailSuppressedError with the same shapes, so a caller written against one
compiles against the other) and it is where the rules below are enforced
instead of being remembered.
The one field that is not shared is Mailgun's tracking, because Resend has no
per-message tracking switch: open and click tracking are a per-domain setting in
its dashboard. Everything else you can pass here, you can pass there.
import { sendEmail } from "@/lib/email";
import WelcomeEmail from "@/lib/email/templates/welcome";
await sendEmail({
to: user.email,
subject: "Welcome aboard",
react: WelcomeEmail({ name: user.name, ctaUrl: appUrl("/app") }),
idempotencyKey: `welcome:${user.id}`,
});
Never send from an unverified domain
from is not a parameter. It is EMAIL_FROM, and that domain must show
Verified in Resend with SPF, DKIM and DMARC in place. Resend rejects a send
from an unverified domain outright (this is not a spam-folder problem, it is a
- and a caller-supplied
fromwould let one feature bypass the check for all of them.
Use a subdomain (mail.example.com). Transactional mail then builds its own
reputation and a bad week cannot damage the domain your team emails from.
Never put a customer's address in from to make a message look like it came
from them. It fails DMARC, it lands in spam, and it is indistinguishable from
spoofing because that is what it is. Set replyTo instead.
Always set a reply-to
sendEmail fills replyTo from REPLY_TO, falling back to EMAIL_FROM.
Callers may override it; they may not remove it. A no-reply address is a
deliverability penalty and a support failure: people answer transactional mail,
and those answers have to land somewhere a human reads.
Never put a secret in an email body
Emails sit unencrypted in mailboxes, get forwarded, get indexed by clients, and pass through corporate scanners that click every link. Nothing in this repo may put the following into a message:
- API keys, database URLs, webhook signing secrets, or any environment value.
- A raw session token, a session cookie value, or anything that authenticates a request as an existing session.
- A password, including a temporary one you generated.
- A full card number, a masked one you assembled yourself, or a bank account.
A magic link is the one credential an email may legitimately carry, and only because it is single-use and short-lived. Pass the URL in as a prop, never log it, and state the expiry in the body. If a link cannot be made single-use and short-lived, it does not go in an email.
Use an idempotency key on transactional sends
Jobs retry, buttons get double-clicked, webhooks redeliver. idempotencyKey
scoped to the causing event (receipt:${paymentId}, welcome:${userId})
collapses those into one message for 24 hours.
Deliberately omit it where a repeat is the point: a user who requests a second sign-in link must receive a second link, and a key would lock them out.
Respect the suppression list
sendEmail refuses an address that hard-bounced or filed a spam complaint, and
the webhook route is what puts them there. Do not catch EmailSuppressedError
and retry through a different path, and never call unsuppress() in bulk, only
on an explicit request from the person who owns the mailbox.
Where the list is kept is src/lib/email/store.ts, generated to match the ORM
this repo selected: the email_suppressions table under Drizzle or Prisma, and
an in-process Map when there is no ORM. Only the Map version has a caveat, and
it is the important one, on serverless every instance starts empty, so a bounce
recorded by the webhook is invisible to the next send. If this repo has no ORM
and you are about to launch, add one.
setSuppressionStore() in src/lib/email/suppression.ts exists for the case the
generator cannot cover: a list you already keep elsewhere, or one shared across
several apps. Call it once, at startup, before the first send.
One message per recipient
to accepts an array, and an array produces one message per address. Nothing in
this app ever puts two customers in one To header, because the recipients would
see each other. SendEmailResult.ids carries one id per address in order; id
is the first of them, so single-recipient callers are unaffected.
An idempotencyKey is suffixed with the address when a send fans out. Do not
work around that: one key across several recipients would make Resend treat the
second message as a retry of the first and never send it.
Retries and rate limits
sendEmail retries a 429, a 5xx and a transport failure up to three attempts
with jittered backoff, and never retries anything else: an unverified domain or
a malformed address fails identically every time. The budget is deliberately
small because a request path is waiting on it. Bulk work that can afford minutes
belongs in a job, not in a handler.
Headers and attachments
headers is how List-Unsubscribe and List-Unsubscribe-Post get set, which
Google and Yahoo require of bulk senders. From, To, Subject and Reply-To
are dropped from that map: they are owned by sendEmail and a duplicate header
is a malformed message, not an override.
attachments takes { filename, content, contentType? }. Resend's ceiling is
40MB for the whole message after encoding, so anything large should be a signed
URL in the body instead.
Templates, not string concatenation
Mail HTML lives in src/lib/email/templates as React Email components with
PreviewProps, so it is reviewable in a diff and previewable with
bun run email:dev. Do not build HTML with template literals, and never
interpolate user input into markup by hand: a name containing <script> is a
stored XSS in every webmail client that renders it.
Always ship a text alternative. react produces one automatically; raw html
does not, and sendEmail warns when you skip it.
Templates take a locale prop, and the receipt uses it for both the currency and
the date. Pass the recipient's own locale rather than accepting the "en" default:
a EUR receipt formatted for en-US on a document that declares lang="en" is
wrong twice for a German customer.
Tags are labels, not data
tags is Record<string, string> on both providers. Resend allows ASCII
letters, digits, underscore and dash in a name and a value, and
src/lib/email/tags.ts rejects anything else by name rather than letting it come
back as an unexplained 422. Never put an address, a name or an id there: tags
are visible in the dashboard and in every exported log.
Skills (2)
Invoked by name.
- /add-email-template
Add a React Email template, preview it, wire it into a send, and check it renders and lands in a real inbox.
.claude/skills/add-email-template/SKILL.md
- /preview-and-test-email
Diagnose an email problem (not sending, landing in spam, rendering wrong) in the order that finds the cause fastest.
.claude/skills/preview-and-test-email/SKILL.md
Solution docs (5)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Sending a lot of email without hitting the rate limit or the spam folderResend allows a couple of requests a second by default. Use the batch endpoint, add backoff for 429s, keep one idempotency key per recipient, and never batch a magic link.docs/solutions/resend/batching-and-rate-limits.md
- Bounces and spam complaints: listen, or lose the inbox for everyoneA hard bounce means the mailbox is gone. Keep sending and mailbox providers downgrade every message from your domain. Wire the webhook, suppress permanently, and never suppress on a soft bounce.docs/solutions/resend/bounces-complaints-and-webhooks.md
- SPF, DKIM and DMARC: what each record does and why your mail needs all threeThree DNS records decide whether a mailbox provider treats your email as authentic. Here is what each one proves, how to set them up on a subdomain, and how to read a failure.docs/solutions/resend/domain-verification-spf-dkim-dmarc.md
- Magic links that actually arrive, and survive the scanner that clicks them firstA sign-in link is the highest-stakes email you send. It has to land in seconds, work once, and survive corporate mail scanners that follow every URL before the human does.docs/solutions/resend/magic-link-deliverability.md
- Previewing React Email templates locally, and what the preview cannot tell youReact Email's preview server renders your templates with realistic props and hot reload. Here is how to set it up, what to check, and the four failure modes only a real client will show you.docs/solutions/resend/previewing-templates-locally.md
How it fits
What Resend needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
Nothing. Resend stands on its own.
Pairs well with
- An ORM battery. Suggested, never added for you.
Cannot be combined with
Compared with the alternatives
Build a repo with Resend
Free and MIT. The builder opens with Resend picked. You download the zip right away, and we email you the link too.
Presets
Presets that already include Resend
A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.