I was building an inquiry form for a client's website when I needed to send email notifications to both the business owner and the customer after form submission. Initially, I thought Payload's afterChange hook would be the perfect place for this logic. After spending hours troubleshooting why emails weren't being sent, I discovered a crucial gotcha that led me to a much more reliable approach.
This guide shows you exactly how to implement email notifications in Payload CMS using the native email plugin, with a real visitor inquiry form as our example. By the end, you'll know how to set up reliable email delivery and avoid the common hook execution pitfall I encountered.
Setting Up Payload's Native Email System
Before we can send any emails, we need to configure Payload's email adapter. The beauty of Payload's approach is that it provides a unified email interface regardless of your SMTP provider.
First, install the nodemailer adapter:
bash
npm install @payloadcms/email-nodemailer
Then configure it in your Payload config. Here's how I set it up with Brevo SMTP:
This configuration creates a unified email interface that Payload can use throughout your application. The nodemailer adapter handles the underlying SMTP connection while providing Payload's consistent sendEmail API. You can easily switch between different email providers by just changing the transport options.
Creating the Order Collection
For our inquiry system, we need collections to store both customer data and orders. Here's the Orders collection that handles the inquiry submissions:
The beforeChange hook handles the business logic of generating order numbers and managing customer relationships. This hook works reliably because it's part of Payload's core document lifecycle, unlike the issues we'll encounter with afterChange hooks in server actions.
The afterChange Hook Gotcha
Initially, I thought the logical place for sending emails would be the afterChange hook. After all, we want to send notifications after the order is successfully created. Here's what I tried first:
typescript
// File: src/collections/Orders.ts (what I thought would work)exportconstOrders: CollectionConfig = {
// ... other confighooks: {
// ... beforeChange hookafterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
console.log('Sending email notifications...')
// Get product and customer detailsconst product = await req.payload.findByID({
collection: 'products',
id: doc.product,
})
const customer = await req.payload.findByID({
collection: 'customers',
id: doc.customer,
})
if (product && customer) {
await req.payload.sendEmail({
to: 'admin@yoursite.com',
subject: `New Inquiry - ${doc.orderNumber}`,
html: `<h2>New inquiry received...</h2>`,
})
}
}
},
],
},
}
This approach seems logical and follows Payload's hook documentation. However, when I tested it with server actions, the hook never executed. No console logs appeared, no emails were sent, and no errors were thrown. The order was created successfully, but the email logic was completely ignored.
The issue lies in how server actions interact with Payload's hook system. When you create documents through server actions, the execution context differs from standard Payload admin operations, causing afterChange hooks to behave unreliably or not execute at all.
The Server Action Solution
After discovering the hook limitation, I moved the email logic directly into the server action. This approach is more reliable and gives you complete control over the email sending process:
This server action approach provides several advantages over the hook method. The email sending happens in the same execution context as the order creation, ensuring reliable delivery. You have complete control over error handling, and you can see console output for debugging. The payload.sendEmail() method leverages your configured email adapter seamlessly.
Notice how I handle the customer ID extraction with typeof newOrder.customer === 'object' ? newOrder.customer.id : newOrder.customer. This accounts for Payload sometimes returning populated relationships as objects rather than just IDs.
Key Benefits of This Approach
Moving email logic to server actions instead of collection hooks solved multiple problems I encountered. The execution is predictable and reliable, making debugging much easier when issues arise. Error handling becomes straightforward since you can catch and respond to email failures without affecting the core order creation process.
The email templates can be as complex as needed, including styled HTML, multiple recipients, and dynamic content based on the order data. Using Payload's native sendEmail() method means you automatically get the benefits of your configured SMTP provider without managing transport connections directly.
Conclusion
When implementing email notifications in Payload CMS, the native email plugin combined with server actions provides the most reliable approach. While afterChange hooks seem like the obvious choice for post-creation tasks, they don't execute consistently in server action contexts, leading to frustrating silent failures.
By moving your email logic directly into server actions, you gain complete control over the notification process while still leveraging Payload's powerful email configuration system. Your inquiry forms will reliably send notifications to both administrators and customers, creating a seamless user experience.
The next time you need to send emails after user actions in Payload CMS, skip the hooks and implement the logic directly in your server actions. Your future self will thank you when the emails actually get delivered.
Let me know in the comments if you have questions, and subscribe for more practical development guides.