Medusa v2 has a notification module. It’s designed exactly for transactional email — you register a provider, configure templates, and the module fires on order events. There’s one problem: there’s no official Resend provider.
The community providers that exist are hit-and-miss on maintenance. The official SendGrid provider exists, but its setup felt heavier than the problem warranted. Resend, on the other hand, seemed easy enough to implement directly — clean API, good TypeScript types, and 3,000 emails a month free and a dollar per thousand beyond that.
This is what I did instead: skip the notification module entirely.
The approach
Medusa’s event system still works without the notification module. Any subscriber can listen to order.placed and do whatever it wants. So the setup is:
- An
order.placedsubscriber that calls the Resend SDK directly. - The email template inlined in the subscriber file.
- A dev-redirect pattern so order emails in staging don’t land in real inboxes.
No notification module configuration. No provider abstraction. Just a function that runs when an order is placed and sends an email.
The ESM trap: why the template lives inline
The natural instinct is to put the HTML template in a separate file and import it:
// ❌ This fails at runtime
import { orderConfirmationTemplate } from '../templates/order-confirmation'
Medusa’s API uses "module": "Node16". Cross-file imports for subscriber dependencies work in dev and fail in production with ERR_MODULE_NOT_FOUND. I didn’t fully diagnose which resolver did what — inlining everything sidesteps the question entirely.
The fix is to keep subscriber files completely self-contained. Everything the subscriber needs — template, helpers, types — lives in the same file.
The same rule applies to import type. With "module": "Node16", plain import { SomeType } for TypeScript types isn’t guaranteed to be erased from the compiled output. Always use import type { ... } for type-only imports in subscriber files.
The subscriber
// api/src/subscribers/order-placed.ts
import type { SubscriberArgs, SubscriberConfig } from '@medusajs/framework'
import { Modules } from '@medusajs/framework/utils'
import { Resend } from 'resend'
export default async function orderPlaced({ event, container }: SubscriberArgs<{ id: string }>) {
const orderModule = container.resolve(Modules.ORDER)
const order = await orderModule.retrieveOrder(event.data.id)
const isDev = process.env.PUBLIC_ENV === 'development'
const toAddress = isDev
? process.env.EMAIL_DEV_REDIRECT!
: order.email
const resend = new Resend(process.env.RESEND_API_KEY)
await resend.emails.send({
from: 'Nadia Poe ',
to: toAddress,
subject: `Order confirmed — ${order.display_id}`,
html: buildOrderEmail(order),
})
}
export const config: SubscriberConfig = {
event: 'order.placed',
}
function buildOrderEmail(order: any): string {
// template inlined here — plain HTML string, no JSX, no template engine
return `...`
}
The EMAIL_DEV_REDIRECT pattern is worth keeping. In development, every order confirmation — regardless of who placed the order — goes to a +dev alias on your own email address. You see the real email, the real order data, without polluting a customer inbox. In production, toAddress is the customer’s email. The guard is a single ternary.
The contact form: no SDK needed
The storefront has a contact form that also sends via Resend. For a one-off form POST, installing the Resend SDK client-side isn’t worth it — the API is just an HTTP endpoint:
// client/src/pages/api/contact.ts
import { env } from 'cloudflare:workers'
export const POST: APIRoute = async ({ request }) => {
const { name, email, message } = await request.json()
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RESEND_CONTACT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'nadiapoe.co.uk ',
to: env.EMAIL_CONTACT_TO,
reply_to: email,
subject: `Message from ${name}`,
text: message,
}),
})
return new Response(null, { status: 204 })
}
env comes from cloudflare:workers — the live runtime binding, not import.meta.env. The reply_to is set to the sender’s address so replying in Gmail works naturally.
Four API keys, not one
One API key for everything is convenient until you need to rotate it, audit usage, or debug which part of the system sent a bad email. The setup uses four keys:
| Key | Used by | Environment |
|---|---|---|
medusa-notifications-prod |
order subscriber | production |
medusa-notifications-dev |
order subscriber | staging |
storefront-contact-prod |
contact form | production |
storefront-contact-dev |
contact form | staging |
Resend shows per-key send history. When something goes wrong you can see exactly which key sent what, without digging through logs.
DNS: the boring bit that breaks everything if you skip it
Resend’s Cloudflare integration auto-configures DKIM and SPF. The setup that works:
-
SPF:
v=spf1 include:_spf.resend.com ~allon the root domain - DKIM: Resend generates the records; Cloudflare auto-applies them via the integration
-
DMARC:
v=DMARC1; p=reject; rua=mailto:hello@nadiapoe.co.uk—p=rejectmeans unauthenticated mail claiming to be fromnadiapoe.co.ukgets dropped, not delivered -
MX + inbound: Cloudflare Email Routing with a catch-all rule forwarding to Gmail.
hello@nadiapoe.co.ukworks as a real inbox without running a mail server.
The Resend SMTP credentials (smtp.resend.com:587, username resend, password = the API key) let Gmail send as hello@nadiapoe.co.uk via “Send mail as” — so replies from the shop’s inbox come from the right address.
What you give up
The notification module’s abstraction is useful if you ever want to swap providers or add multiple notification channels (email + SMS + push). Bypassing it means that flexibility lives in your subscriber code instead. For a shop that will always send via Resend, that’s a fine trade. If the requirements change, the migration is straightforward: add the official provider when it ships, move the template, delete the subscriber.
Live shop: nadiapoe.co.uk.