Next.js boilerplate with Mailgun
The veteran sending API. EU data residency and suppression lists kept server-side.
Transactional email through Mailgun, behind one provider-agnostic send function. React Email templates are rendered to HTML before sending. Suppression lists are read straight from Mailgun's own API. Mail routes to the EU or US region, and the event webhook is signature-verified.
What Mailgun adds to the agent layer: 1 rule · 3 skills · 5 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Mailgun?
Pick it if
Teams that need EU data residency, or want suppression and analytics held by the provider instead of their own database. Also teams sending enough volume to care about dedicated IPs and warm-up support. And the pragmatic choice when an existing system already speaks Mailgun.
Watch out for
- Region is chosen when the domain is created and cannot change. An EU domain must use api.eu.mailgun.net. The US host returns a 401 that reads like a bad key.
- The API is form-encoded, not JSON, which is why the SDK needs form-data as a peer. It is an older API and it shows in places.
Show 3 moreShow fewer
- No React rendering on Mailgun's side. Templates are rendered to HTML in your app before sending. Mailgun's own templates are Handlebars stored in the dashboard, a different workflow this battery does not use.
- No built-in idempotency key. A retried job can send twice unless you deduplicate, so this battery attaches a deterministic Message-Id and a custom variable.
- Suppression lists live in Mailgun and are queryable through the API. No local table to maintain and one less thing to keep in sync.
What it costs
Free plan: 100 emails a day and 1 custom domain. Basic starts at $15/month for 10,000 emails. Foundation is $35/month for 50,000 and Scale $90/month for 100,000. Logs are kept 1 day on Free and Basic, 5 days on Foundation and 30 days on Scale.
Prices change. Check with Mailgun before you commit.
registry/tested.yaml
Tested with Mailgun
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Mailgun adds to the repo
Read straight from the mailgun manifest, so it is exactly what lands in your repo.
Environment variables
MAILGUN_API_KEYRequired
Private API key from Settings -> API Keys. It can send mail, read every event and delete domains, so it is server-side only. This is not the same value as the webhook signing key.
- Where to get it
- https://app.mailgun.com/settings/api_security
- Placeholder
- key-replace_me
MAILGUN_DOMAINRequired
The sending domain, exactly as it appears in the Mailgun dashboard. Use a subdomain such as mail.example.com so transactional mail builds its own reputation. A sandbox domain works for the first test but can only send to five addresses you have authorised.
- Where to get it
- https://app.mailgun.com/mg/sending/domains
- Placeholder
- mail.example.com
MAILGUN_REGIONOptional
"us" (default) or "eu". Chosen when the domain was created and fixed thereafter. An EU domain addressed at the US host returns 401 Unauthorized, which reads exactly like a wrong API key and costs people an afternoon.
- Placeholder
- us
EMAIL_FROMRequired
The From address, in "Name <address@domain>" form. Its domain must be MAILGUN_DOMAIN and must be verified (SPF, DKIM and DMARC in place) or the message is rejected rather than filtered.
- Placeholder
- my-app <hello@mail.example.com>
MAILGUN_WEBHOOK_SIGNING_KEYOptional
HTTP webhook signing key, a separate value from MAILGUN_API_KEY and found next to it in Settings. Mailgun signs events as HMAC-SHA256(timestamp + token) with this key. Optional locally; without it the event route refuses every delivery, which is the correct default.
- Where to get it
- https://app.mailgun.com/settings/api_security
- Placeholder
- whsk-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 reads: replies to a black hole hurt deliverability and infuriate the person who tried.
- Placeholder
- support@example.com
Dependencies
- @react-email/render^2.1.0
- form-data^4.0.6
- mailgun.js^14.0.1
- react-email^6.11.0
- server-only^0.0.1
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
- bun run email:suppressions
bun --conditions=react-server scripts/email/suppressions.ts
Files it writes
22 files, at these exact paths.
scripts/3 files
email/3 files
- render-samples.ts
- send-test.ts
- suppressions.ts
src/15 files
app/1 file
api/1 file
webhooks/1 file
mailgun/1 file
- route.ts
lib/14 files
email/14 files
templates/7 files
- layout.tsx
- magic-link.tsx
- receipt.tsx
- reset-password.tsx
- theme.ts
- verify-email.tsx
- welcome.tsx
- address.ts
- index.ts
- mailgun.ts
- outbox.ts
- retry.ts
- suppression.ts
- tags.ts
tests/2 files
unit/2 files
- email-outbox.test.ts
- email.test.ts
variants/2 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
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 Mailgun 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.
One send function, a verified domain, a reply-to, and no secrets in the body
Loads onsrc/lib/email/**src/app/api/webhooks/mailgun/**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 mailgun.js, constructs a client, or reads MAILGUN_API_KEY. The
Resend battery exports sendEmail, SendEmailOptions, SendEmailResult,
EmailAttachment and EmailSuppressedError with the same shapes, so a caller
written against one compiles against the other, and the rules below are enforced
in one file instead of being remembered in twenty.
The one field that is not shared is tracking below, 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 ReceiptEmail from "@/lib/email/templates/receipt";
await sendEmail({
to: user.email,
subject: `Receipt ${payment.reference}`,
react: ReceiptEmail({ reference: payment.reference, /* ... */ }),
idempotencyKey: `receipt:${payment.id}`,
});
Never send from an unverified domain
from is not a parameter. It is EMAIL_FROM, whose domain must equal
MAILGUN_DOMAIN and must show active in the dashboard with DKIM, SPF and a
DMARC record in place. Mailgun rejects a From address outside the sending domain
outright, and mailbox providers filter mail that fails authentication.
Use a subdomain (mail.example.com), never the root domain: transactional mail
then builds its own reputation and a bad campaign cannot take password resets
down with it.
Never put a customer's address in from to make a message look like it came
from them. It fails DMARC on their domain, lands in spam, and is technically
indistinguishable from spoofing. Set replyTo instead.
Be explicit about the region. MAILGUN_REGION decides the API host, a domain's
region is fixed when it is created, and the wrong host answers 401, which
reads exactly like a bad key.
Always set a reply-to
sendEmail fills h:Reply-To 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 the answer has to reach a human.
Never put a secret in an email body
Messages sit unencrypted in mailboxes, get forwarded, get indexed, and pass through scanners that click every link. None of the following may appear in a message this app sends:
- API keys, database URLs, signing keys, or any environment value.
- A raw session token or cookie value: anything that authenticates a request as an existing session.
- A password, including a temporary one you generated.
- A full or self-masked card number, or a payment token.
cardLast4, as supplied by the payment provider, is the limit.
A magic link is the one credential an email may 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.
Use an idempotency key on transactional sends
Mailgun has no server-side idempotency, so sendEmail derives a deterministic
Message-Id from the key and attaches it as a custom variable. That collapses
duplicates in most clients and makes the rest findable in the logs.
Scope the key to the causing event (receipt:${paymentId}, welcome:${userId}) never to a timestamp or a random value, which is the same as having no key.
tags is Record<string, string> here as well, flattened to Mailgun's
name:value tag form by src/lib/email/tags.ts, which also enforces the
three-tag limit by name instead of letting the send fail. Never put an address, a
name or an id in one.
Deliberately omit it where a repeat is the point: a user who requests a second sign-in link must receive one.
Let the suppression lists do their job
sendEmail checks Mailgun's bounce, complaint and unsubscribe lists before
sending and throws EmailSuppressedError. Do not catch that and retry through
another path.
That check is an optimisation, not the enforcement. Mailgun refuses a listed
address on its own, which is why a failed lookup (a 5xx, a timeout, a DNS blip) answers "not suppressed" and lets the send proceed. Never change that to
rethrow: it would turn a thirty-second provider hiccup into a total sign-in
outage, because a magic link is the one message a user cannot work around. A run
of suppression-unavailable incidents is the signal that the safety net is off.
unsuppress() is for an explicit request from the mailbox owner. Never clear
lists in bulk: those addresses bounced for a reason, and re-sending is how a
sending domain gets blocked.
suppress() writes into a list an operator reads next to entries Mailgun wrote
itself, so it records a sentence rather than the app's internal reason code. Keep
it that way: a row saying hard_bounce is indistinguishable from a real SMTP
failure, and the next person cannot tell whether the mailbox is dead or whether
support blocked it.
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, and Mailgun would need recipient-variables to fan out, which
this battery deliberately does not set. SendEmailResult.ids carries one id per
address in order; id is the first of them.
An idempotencyKey is suffixed with the address when a send fans out, because a
repeated Message-Id is treated as a duplicate by receiving servers and the
second recipient would silently never get the mail.
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: a 400 or a 401 fails
identically every time. The budget is deliberately small because a request path
is waiting on it. Bulk work belongs in a job.
Headers and attachments
headers is how List-Unsubscribe and List-Unsubscribe-Post get set, which
Google and Yahoo require of bulk senders; each entry becomes an h: field.
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? } and becomes Mailgun's
attachment field. The ceiling is 25MB for the whole message, lower than most
people expect, so anything large should be a signed URL in the body.
Tracking is off unless someone decided otherwise
tracking defaults to false. Open tracking is a 1x1 pixel that reports when and
roughly where a message was read; click tracking rewrites every link through a
Mailgun domain. Both are personal data in the EU and both have deliverability
costs: a rewritten link whose text and href disagree is a phishing signal.
Never enable tracking on a magic link, a password reset or a receipt. There is no product question those answer that is worth the rewritten URL.
Templates, not string concatenation
Mail HTML lives in src/lib/email/templates as React Email components with
PreviewProps, rendered to HTML and text by sendEmail. Do not build HTML with
template literals, and never interpolate user input into markup by hand: a name
containing <script> is stored XSS in every webmail client that renders 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.
Skills (3)
Invoked by name.
- /add-email-template
Add a React Email template on the shared layout, preview it, wire it into a sendEmail call, and confirm it renders as HTML and as text in a real inbox.
.claude/skills/add-email-template/SKILL.md
- /diagnose-email-delivery
Work out why a Mailgun message did not arrive (region, key, suppression, authentication or placement) in the order that finds it fastest.
.claude/skills/diagnose-email-delivery/SKILL.md
- /test-email
Prove a send actually works: check the region and domain, send one of each template, read the Mailgun logs, inspect the suppression lists and confirm placement in a real inbox.
.claude/skills/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.
- Mailgun returns 401 and your API key is fine: the EU/US region trapA Mailgun domain lives in the region it was created in. Calling the wrong regional host returns 401 Unauthorized, which reads exactly like a bad API key.docs/solutions/mailgun/eu-vs-us-region-endpoints.md
- Mailgun says the message was queued and nobody receives it: sandbox domain limitsThe sandbox domain accepts every send and delivers only to five addresses you authorised. Recognise it, use it deliberately, and know when to stop.docs/solutions/mailgun/sandbox-domain-limits.md
- Suppression lists in Mailgun: stop maintaining your own bounce tableMailgun stores bounces, complaints and unsubscribes per domain and refuses to send to them. Read that list instead of building a table you have to keep in sync.docs/solutions/mailgun/suppression-lists.md
- Mailgun templates and recipient variables versus rendering HTML in your appMailgun stores Handlebars templates in its dashboard and substitutes variables at send time. Rendering in your app instead keeps email in code review: here is when each one wins.docs/solutions/mailgun/template-variables-vs-rendered-html.md
- Open tracking, click tracking and why they are off by defaultMailgun's tracking adds a pixel and rewrites every link. Both are personal data under GDPR, both cost deliverability, and neither belongs on a magic link.docs/solutions/mailgun/tracking-pixels-and-privacy.md
How it fits
What Mailgun needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
Nothing. Mailgun stands on its own.
Pairs well with
Nothing extra. Add any tested battery alongside Mailgun.
Cannot be combined with
Compared with the alternatives
Build a repo with Mailgun
Free and MIT. The builder opens with Mailgun picked. You download the zip right away, and we email you the link too.