How to Send Email Notifications in Payload CMS Using the Native Plugin
Use Payload's native email plugin and server actions for reliable notifications

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:
npm install @payloadcms/email-nodemailer
Then configure it in your Payload config. Here's how I set it up with Brevo SMTP:
// File: payload.config.ts
import { buildConfig } from 'payload'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({
// ... other config
email: nodemailerAdapter({
defaultFromAddress: 'info@yoursite.com',
defaultFromName: 'Your Business Name',
transportOptions: {
host: process.env.BREVO_SMTP_HOST,
port: parseInt(process.env.BREVO_SMTP_PORT || '587', 10),
auth: {
user: process.env.BREVO_SMTP_LOGIN,
pass: process.env.BREVO_SMTP_KEY,
},
},
}),
// ... rest of config
})
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:
// File: src/collections/Orders.ts
import { CollectionConfig } from 'payload'
export const Orders: CollectionConfig = {
slug: 'orders',
labels: {
singular: 'Order',
plural: 'Orders',
},
hooks: {
beforeChange: [
async ({ data, operation, req }) => {
// Generate order number for new orders
if (operation === 'create' && !data.orderNumber) {
const latestOrder = await req.payload.find({
collection: 'orders',
sort: '-createdAt', // Important: descending order
limit: 1,
})
const latestOrderNumber = latestOrder.docs[0]?.orderNumber || 'ORDER-0'
const latestOrderNumberInt = parseInt(latestOrderNumber.replace('ORDER-', ''))
const newOrderNumber = `ORDER-${latestOrderNumberInt + 1}`
data.orderNumber = newOrderNumber
}
// Handle customer creation/lookup
if (operation === 'create' && data.customerData) {
const existingCustomer = await req.payload.find({
collection: 'customers',
where: { email: { equals: data.customerData.email } },
limit: 1,
})
if (existingCustomer.docs.length > 0) {
data.customer = existingCustomer.docs[0].id
} else {
const newCustomer = await req.payload.create({
collection: 'customers',
data: {
firstName: data.customerData.firstName,
lastName: data.customerData.lastName,
email: data.customerData.email,
phone: data.customerData.phone,
address: {
streetAddress: data.customerData.streetAddress,
postalCode: data.customerData.postalCode,
town: data.customerData.town,
country: 'Slovenia',
},
},
})
data.customer = newCustomer.id
}
delete data.customerData
}
return data
},
],
},
fields: [
{
name: 'orderNumber',
type: 'text',
required: true,
unique: true,
admin: { readOnly: true },
},
{
name: 'customer',
type: 'relationship',
relationTo: 'customers',
required: true,
},
{
name: 'product',
type: 'relationship',
relationTo: 'products',
required: true,
},
{
name: 'quantity',
type: 'number',
required: true,
min: 1,
},
{
name: 'customerMessage',
type: 'textarea',
},
// Hidden field for form submissions
{
name: 'customerData',
type: 'group',
admin: { hidden: true },
fields: [
{ name: 'firstName', type: 'text' },
{ name: 'lastName', type: 'text' },
{ name: 'email', type: 'email' },
{ name: 'phone', type: 'text' },
{ name: 'streetAddress', type: 'text' },
{ name: 'postalCode', type: 'text' },
{ name: 'town', type: 'text' },
{ name: 'message', type: 'textarea' },
],
},
],
timestamps: true,
}
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:
// File: src/collections/Orders.ts (what I thought would work)
export const Orders: CollectionConfig = {
// ... other config
hooks: {
// ... beforeChange hook
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
console.log('Sending email notifications...')
// Get product and customer details
const 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:
// File: src/actions/order.ts
'use server'
import { z } from 'zod'
import { getPayloadClient } from '@/lib/payload'
const OrderSchema = z.object({
productId: z.string().min(1),
quantity: z.coerce.number().int().min(1),
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
streetAddress: z.string().min(1),
postalCode: z.string().min(3).regex(/^\d{3,5}$/),
town: z.string().min(1),
message: z.string().optional(),
})
export async function submitOrder(prevState: any, formData: FormData): Promise<any> {
// Validate form data
const validatedFields = OrderSchema.safeParse({
productId: formData.get('productId'),
quantity: formData.get('quantity'),
firstName: formData.get('firstName'),
lastName: formData.get('lastName'),
email: formData.get('email'),
phone: formData.get('phone') || undefined,
streetAddress: formData.get('streetAddress'),
postalCode: formData.get('postalCode'),
town: formData.get('town'),
message: formData.get('message') || undefined,
})
if (!validatedFields.success) {
const fieldErrors: Record<string, string[]> = {}
validatedFields.error.issues.forEach((error) => {
const fieldName = error.path[0] as string
if (!fieldErrors[fieldName]) {
fieldErrors[fieldName] = []
}
fieldErrors[fieldName].push(error.message)
})
return {
success: false,
message: 'Please correct the errors in the form.',
errors: fieldErrors,
}
}
const {
productId,
quantity,
firstName,
lastName,
email,
phone,
streetAddress,
postalCode,
town,
message
} = validatedFields.data
try {
const payload = await getPayloadClient()
// Verify product exists
const product = await payload.findByID({
collection: 'products',
id: productId,
})
if (!product) {
return {
success: false,
message: 'Product not found.',
errors: {},
}
}
// Create the order
const newOrder = await payload.create({
collection: 'orders',
data: {
product: parseInt(productId, 10),
quantity: quantity,
customerData: {
firstName,
lastName,
email,
phone,
streetAddress,
postalCode,
town,
message,
},
customerMessage: message,
source: 'website',
status: 'pending',
} as any,
})
console.log('Order created successfully:', newOrder.orderNumber)
// Send email notifications
try {
// Get customer details for emails
const customerId = typeof newOrder.customer === 'object' ? newOrder.customer.id : newOrder.customer
const customer = await payload.findByID({
collection: 'customers',
id: customerId,
})
if (product && customer) {
console.log('Sending email notifications...')
// Send admin notification email
await payload.sendEmail({
to: 'admin@yoursite.com',
bcc: 'dev@yoursite.com',
replyTo: customer.email,
subject: `New Inquiry - ${newOrder.orderNumber}`,
html: `
<h2>New Product Inquiry</h2>
<p><strong>Order Number:</strong> ${newOrder.orderNumber}</p>
<h3>Customer Details:</h3>
<ul>
<li><strong>Name:</strong> ${customer.firstName} ${customer.lastName}</li>
<li><strong>Email:</strong> ${customer.email}</li>
<li><strong>Phone:</strong> ${customer.phone || 'Not provided'}</li>
<li><strong>Address:</strong> ${customer.address?.streetAddress}, ${customer.address?.postalCode} ${customer.address?.town}</li>
</ul>
<div style="margin: 20px 0;">
<a href="tel:${customer.phone}" style="display: inline-block; background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; font-weight: bold;">
📞 CALL CUSTOMER
</a>
</div>
<h3>Product Details:</h3>
<ul>
<li><strong>Product:</strong> ${product.title}</li>
<li><strong>SKU:</strong> ${product.sku || 'Not available'}</li>
<li><strong>Quantity:</strong> ${quantity}</li>
<li><strong>Unit Price:</strong> ${product.price ? `€${product.price}` : 'Not available'}</li>
<li><strong>Total:</strong> ${newOrder.total ? `€${newOrder.total}` : 'Not available'}</li>
<li><strong>Product URL:</strong> <a href="${process.env.NEXT_PUBLIC_SERVER_URL}/products/${product.slug}" target="_blank">${process.env.NEXT_PUBLIC_SERVER_URL}/products/${product.slug}</a></li>
</ul>
${message ? `
<h3>Customer Message:</h3>
<p>${message}</p>
` : ''}
<p>You can review this inquiry in the admin panel.</p>
`,
})
// Send customer confirmation email
await payload.sendEmail({
to: customer.email,
replyTo: 'admin@yoursite.com',
subject: `Inquiry Confirmation - ${newOrder.orderNumber}`,
html: `
<h2>Thank you for your inquiry!</h2>
<p>Your inquiry has been successfully received.</p>
<h3>Inquiry Details:</h3>
<ul>
<li><strong>Number:</strong> ${newOrder.orderNumber}</li>
<li><strong>Product:</strong> ${product.title}</li>
<li><strong>Quantity:</strong> ${quantity}</li>
</ul>
<p>We will contact you shortly with additional information and a quote.</p>
<p>For any questions, you can contact us at admin@yoursite.com or +1-234-567-8900.</p>
<p>Best regards,<br>Your Team</p>
`,
})
console.log('Email notifications sent successfully')
} else {
console.error('Missing product or customer data for emails')
}
} catch (emailError) {
console.error('Error sending email notifications:', emailError)
// Don't fail the entire operation if emails fail
}
return {
success: true,
message: 'Your inquiry has been successfully submitted!',
orderId: newOrder.id,
orderNumber: newOrder.orderNumber,
errors: {},
}
} catch (error) {
console.error('Error creating order:', error)
return {
success: false,
message: 'There was an error submitting your inquiry. Please try again.',
errors: {},
}
}
}
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.
Thanks, Matija