Boring Docs
Emails — Resend Delivery & Magic Links
Zero-dependency Resend REST client, NextAuth magic links, and verified domains.
In this guide
Emails
Two kinds of email live in your app, and they fail in completely different ways: the magic-link auth mail (must arrive in seconds, or login feels broken) and everything else you send (receipts, notifications, newsletters). This doc covers both pipes. Grab your Resend key — we're setting up delivery once, properly.
Pipe 1: magic links (auth mail that can't be late)
When a user types their email on /auth, NextAuth calls our custom sendVerificationRequest in libs/next-auth.js. It builds a branded HTML email (stone palette, big tap-friendly link button, plain-text twin for strict inboxes) and sends it through a nodemailer transport pointed at EMAIL_SERVER. The link is single-use, 24 hours, and the page tells the user exactly that — set expectations in the UI and half your "didn't work" tickets vanish.
Now the trap I warned you about in 04-auth: the from-address. Until you verify a domain in Resend, only [email protected] delivers. So day one, set RESEND_FROM="Boring <[email protected]>" and everything works. The day you switch to [email protected], stop and verify the domain first (Resend dashboard → Domains → add the DNS records → wait for green). Flip the from-address before verification and your auth mail silently dies — users stare at "check your inbox" while Resend drops every send. I've seen founders debug code for an hour that was actually DNS. Don't be them: change from-address and domain verification in the same sitting, test immediately.
Local dev needs nothing: no key means the link prints to your terminal and sendEmail returns { skipped: true }. Click it from the terminal, you're in. Only add a key locally when you're specifically testing deliverability.
Verify. node scripts/test/test-email.js [email protected] — prints the Resend message ID on success. Then check the Resend dashboard delivery log: "delivered" with no inbox arrival means spam folder or corporate filtering, not your code.
Pipe 2: everything else via sendEmail()
libs/resend.js exports sendEmail({ to, subject, text, html, replyTo, from }) — a zero-dependency fetch call to Resend's REST API. No SDK, no 80MB mailer bloat. The from-address falls back through from → RESEND_FROM → admin default → [email protected], so explicit beats implicit: always pass from in code you care about.
Key behavior to internalize: without a key in dev it warns once and returns { id: "dev-mock-id", skipped: true } instead of throwing. Your notification code paths run end-to-end locally without delivering anything — new-feature demos don't spam real inboxes. In production, missing key means the fetch fails loudly. That's the deal: silent in dev, loud in prod. Exactly backwards from how most starters do it, and exactly right.
Leads: the capture form that can't double-submit
<ButtonLead /> posts to /api/lead/route.js, which lowercases + trims the email, validates shape (contains @, under 320 chars), and — here's the part to copy — returns 200 { success: true } whether the address is new or already subscribed. Duplicate submit returns success, not 409. Why? Double-clicks, impatient re-submits, and password-manager autofills all fire twice; a 409 turns those into visible errors for an action that actually succeeded. Idempotent capture: same input, same happy response, one row. Your leads table stays clean and your users never see an error for succeeding.
When you outgrow the built-in table and move lists to Resend audiences or a newsletter tool, keep this route as the front door (validation + dedupe live here) and fan out server-side. The button never changes.
One domain, two reputations (read this before newsletters)
Here's the mistake that tanks deliverability for months: sending your launch announcement blast from the same stream as your magic links, getting flagged, and discovering your auth emails now land in spam too. Providers score reputation per sending identity — pollute one stream and everything sharing it suffers.
The fix is boring and free: separate by subdomain early. Transactional mail (magic links, receipts, password stuff) goes from [email protected] or the root domain; bulk mail (announcements, newsletters) goes from [email protected]. Same Resend account, same DNS verification afternoon, two reputations that can't poison each other. When your launch blast inevitably gets a few spam complaints (it will — someone always forgets they subscribed), your login flow keeps working while you sort it out.
Two more habits while I'm lecturing. First, set replyTo to an inbox a human reads, and actually read it — replies to transactional mail are pure gold: confused users telling you exactly what's broken, in their own words, before they churn. An unmonitored no-reply address throws that away. Second, watch bounces, not just deliveries: a hard bounce means a dead address — remove it. Mailing the dead repeatedly is the fastest way to look like a spammer to the filters. Resend's dashboard shows both; make it a weekly five-minute habit during growth.
Your future self, staring at a 98% inbox rate while competitors complain about spam folders, thanks you.
The deliverability checklist (before launch week)
- Verify your domain in Resend and set
RESEND_FROMto it. Test with a real Gmail, Outlook, and one corporate inbox — corporate filters eat more launch emails than spam folders do. - Send yourself every template: magic link, receipt, notification. Read each on a phone. Half your users are on phones; if the button needs pinching, fix the template now.
- Warm up gently: day-one blasts to a fresh list from a fresh domain trigger throttles. Transactional mail first (it has the best reputation signal), bulk later, ramping volume over days.
- Set up the failure path: what does your UI say when Resend is down?
sendEmailthrows — catch it at the call site and tell the user something true ("email delayed, your account is ready") instead of a 500. Test this by unsetting the key locally and clicking through; the dev-skip makes this drill free.
Email done right is invisible — links arrive, receipts land, nobody thinks about it. That's the goal. Next: 09-seo-analytics-blog, where we make Google notice you exist.