Skip to content

Email

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 more
  • 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.

Database
NeonSupabase
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 verify reports 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.

    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.

    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.

    Placeholder
    whsec_replace_me
  • EMAIL_OUTBOX_DIROptional

    Development and tests only. When set, sendEmail writes 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

  1. and a caller-supplied from would 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.

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.

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.