Send Abandoned Cart Notifications in Medusa

In this tutorial, you will learn how to send notifications to customers who have abandoned their carts.

When you install a Medusa application, you get a fully-fledged commerce platform with a framework for customization. The Medusa application's commerce features are built around commerce modules, which are available out-of-the-box. These features include cart-management capabilities.

Medusa's Notification Module allows you to send notifications to users or customers, such as password reset emails, order confirmation SMS, or other types of notifications.

In this tutorial, you will use the Notification Module to send an email to customers who have abandoned their carts. The email will contain a link to recover the customer's cart, encouraging them to complete their purchase. You will use SendGrid to send the emails, but you can also use other email providers.

Summary#

By following this tutorial, you will:

  • Install and set up Medusa.
  • Create the logic to send an email to customers who have abandoned their carts.
  • Run the above logic once a day.
  • Add a route to the storefront to recover the cart.

Diagram illustrating the flow of the abandoned-cart functionalities

View on Github
Find the full code for this tutorial.

Step 1: Install a Medusa Application#

Start by installing the Medusa application on your machine with the following command:

Terminal
npx create-medusa-app@latest

You will first be asked for the project's name. Then, when asked whether you want to install the Next.js starter storefront, choose "Yes."

Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name and the Next.js Starter Storefront in a separate directory with the {project-name}-storefront name.

Why is the storefront installed separatelyThe Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called API routes. Learn more in Medusa's Architecture documentation.

Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterwards, you can log in with the new user and explore the dashboard.

Ran into ErrorsCheck out the troubleshooting guides for help.

Step 2: Set up SendGrid#

Medusa's Notification Module provides the general functionality to send notifications, but the sending logic is implemented in a module provider. This allows you to integrate the email provider of your choice.

To send the cart-abandonment emails, you will use SendGrid. Medusa provides a SendGrid Notification Module Provider that you can use to send emails.

Alternatively, you can use other Notification Module Providers or create a custom provider.

To set up SendGrid, add the SendGrid Notification Module Provider to medusa-config.ts:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  modules: [4    {5      resolve: "@medusajs/medusa/notification",6      options: {7        providers: [8          {9            resolve: "@medusajs/medusa/notification-sendgrid",10            id: "sendgrid",11            options: {12              channels: ["email"],13              api_key: process.env.SENDGRID_API_KEY,14              from: process.env.SENDGRID_FROM,15            },16          },17        ],18      },19    },20  ],21})

In the modules configuration, you pass the Notification Provider and add SendGrid as a provider. You also pass to the SendGrid Module Provider the following options:

  • channels: The channels that the provider supports. In this case, it is only email.
  • api_key: Your SendGrid API key.
  • from: The email address that the emails will be sent from.

Then, set the SendGrid API key and "from" email as environment variables, such as in the .env file at the root of your project:

Code
1SENDGRID_API_KEY=your-sendgrid-api-key2SENDGRID_FROM=test@gmail.com

You can now use SendGrid to send emails in Medusa.


Step 3: Send Abandoned Cart Notification Flow#

You will now implement the sending logic for the abandoned cart notifications.

To build custom commerce features in Medusa, you create a workflow. A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow like you construct a function, but it is a special function that allows you to track its executions' progress, define roll-back logic, and configure other advanced features. Then, you execute the workflow from other customizations, such as in a scheduled job.

In this step, you will create the workflow that sends the abandoned cart notifications. Later, you will learn how to execute it once a day.

The workflow will receive the list of abandoned carts as an input. The workflow has the following steps:

Medusa provides the second step in its @medusajs/medusa/core-flows package. So, you only need to implement the first one.

sendAbandonedNotificationsStep#

The first step of the workflow sends a notification to the owners of the abandoned carts that are passed as an input.

To implement the step, create the file src/workflows/steps/send-abandoned-notifications.ts with the following content:

src/workflows/steps/send-abandoned-notifications.ts
1import { 2  createStep,3  StepResponse, 4} from "@medusajs/framework/workflows-sdk"5import { Modules } from "@medusajs/framework/utils"6import { CartDTO, CustomerDTO } from "@medusajs/framework/types"7
8type SendAbandonedNotificationsStepInput = {9  carts: (CartDTO & {10    customer: CustomerDTO11  })[]12}13
14export const sendAbandonedNotificationsStep = createStep(15  "send-abandoned-notifications",16  async (input: SendAbandonedNotificationsStepInput, { container }) => {17    const notificationModuleService = container.resolve(18      Modules.NOTIFICATION19    )20
21    const notificationData = input.carts.map((cart) => ({22      to: cart.email!,23      channel: "email", 24      template: process.env.ABANDONED_CART_TEMPLATE_ID || "",25      data: {26        customer: {27          first_name: cart.customer?.first_name || cart.shipping_address?.first_name,28          last_name: cart.customer?.last_name || cart.shipping_address?.last_name,29        },30        cart_id: cart.id,31        items: cart.items?.map((item) => ({32          product_title: item.title,33          quantity: item.quantity,34          unit_price: item.unit_price,35          thumbnail: item.thumbnail,36        })),37      },38    }))39
40    const notifications = await notificationModuleService.createNotifications(41      notificationData42    )43
44    return new StepResponse({45      notifications,46    })47  }48)

You create a step with createStep from the Workflows SDK. It accepts two parameters:

  1. The step's unique name, which is create-review.
  2. An async function that receives two parameters:
    • The step's input, which is in this case an object with the review's properties.
    • An object that has properties including the Medusa container, which is a registry of framework and commerce tools that you can access in the step.

In the step function, you first resolve the Notification Module's service, which has methods to manage notifications. Then, you prepare the data of each notification, and create the notifications with the createNotifications method.

Notice that each notification is an object with the following properties:

  • to: The email address of the customer.
  • channel: The channel that the notification will be sent through. The Notification Module uses the provider registered for the channel.
  • template: The ID or name of the email template in the third-party provider. Make sure to set it as an environment variable once you have it.
  • data: The data to pass to the template to render the email's dynamic content.

Based on the dynamic template you create in SendGrid or another provider, you can pass different data in the data object.

A step function must return a StepResponse instance. The StepResponse constructor accepts the step's output as a parameter, which is the created notifications.

Create Workflow#

You can now create the workflow that uses the step you just created to send the abandoned cart notifications.

Create the file src/workflows/send-abandoned-carts.ts with the following content:

src/workflows/send-abandoned-carts.ts
1import {2  createWorkflow,3  WorkflowResponse,4  transform,5} from "@medusajs/framework/workflows-sdk"6import { 7  sendAbandonedNotificationsStep,8} from "./steps/send-abandoned-notifications"9import { updateCartsStep } from "@medusajs/medusa/core-flows"10import { CartDTO } from "@medusajs/framework/types"11import { CustomerDTO } from "@medusajs/framework/types"12
13export type SendAbandonedCartsWorkflowInput = {14  carts: (CartDTO & {15    customer: CustomerDTO16  })[]17}18
19export const sendAbandonedCartsWorkflow = createWorkflow(20  "send-abandoned-carts",21  function (input: SendAbandonedCartsWorkflowInput) {22    sendAbandonedNotificationsStep(input)23
24    const updateCartsData = transform(25      input,26      (data) => {27        return data.carts.map((cart) => ({28          id: cart.id,29          metadata: {30            ...cart.metadata,31            abandoned_notification: new Date().toISOString(),32          },33        }))34      }35    )36
37    const updatedCarts = updateCartsStep(updateCartsData)38
39    return new WorkflowResponse(updatedCarts)40  }41)

You create a workflow using createWorkflow from the Workflows SDK. It accepts the workflow's unique name as a first parameter.

It accepts as a second parameter a constructor function, which is the workflow's implementation. The function can accept input, which in this case is an arra of carts.

In the workflow's constructor function, you:

  • Use the sendAbandonedNotificationsStep to send the notifications to the carts' customers.
  • Use the updateCartsStep from Medusa's core flows to update the carts' metadata with the last notification date.

Notice that you use the transform function to prepare the updateCartsStep's input. Medusa does not support direct data manipulation in a workflow's constructor function. You can learn more about it in the Data Manipulation in Workflows documentation.

Your workflow is now ready for use. You will learn how to execute it in the next section.

Setup Email Template#

Before you can test the workflow, you need to set up an email template in SendGrid. The template should contain the dynamic content that you pass in the workflow's step.

To create an email template in SendGrid:

  • Go to Dynamic Templates in the SendGrid dashboard.
  • Click on the "Create Dynamic Template" button.

Button is at the top right of the page

  • In the side window that opens, enter a name for the template, then click on the Create button.
  • The template will be added to the middle of the page. When you click on it, a new section will show with an "Add Version" button. Click on it.

The template is a collapsible in the middle of the page,with the "Add Version" button shown in the middle

In the form that opens, you can either choose to start with a blank template or from an existing design. You can then use the drag-and-drop or code editor to design the email template.

You can also use the following template as an example:

Abandoned Cart Email Template
1<!DOCTYPE html>2<html>3<head>4    <meta charset="UTF-8">5    <meta name="viewport" content="width=device-width, initial-scale=1">6    <title>Complete Your Purchase</title>7    <style>8        body {9            font-family: Arial, sans-serif;10            background-color: #f8f9fa;11            margin: 0;12            padding: 20px;13        }14        .container {15            max-width: 600px;16            background: #ffffff;17            padding: 20px;18            border-radius: 10px;19            box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1);20            text-align: center;21        }22        .header {23            font-size: 26px;24            font-weight: bold;25            color: #333;26            margin-bottom: 20px;27        }28        .message {29            font-size: 16px;30            color: #555;31            margin-bottom: 20px;32        }33        .item {34            display: flex;35            align-items: center;36            background: #f9f9f9;37            padding: 10px;38            border-radius: 8px;39            margin-bottom: 15px;40        }41        .item img {42            width: 80px;43            height: auto;44            margin-right: 15px;45            border-radius: 5px;46        }47        .item-details {48            text-align: left;49            flex-grow: 1;50        }51        .item-details strong {52            font-size: 18px;53            color: #333;54        }55        .item-details p {56            font-size: 14px;57            color: #777;58            margin: 5px 0;59        }60        .button {61            display: inline-block;62            background-color: #007bff;63            color: #ffffff;64            text-decoration: none;65            font-size: 18px;66            padding: 12px 20px;67            border-radius: 5px;68            margin-top: 20px;69            transition: background 0.3s ease;70        }71        .button:hover {72            background-color: #0056b3;73        }74        .footer {75            font-size: 12px;76            color: #888;77            margin-top: 20px;78        }79    </style>80</head>81<body>82    <div class="container">83        <div class="header">Hi {{customer.first_name}}, your cart is waiting! 🛍️</div>84        <p class="message">You left some great items in your cart. Complete your purchase before they're gone!</p>85        86        {{#each items}}87        <div class="item">88            <img src="{{thumbnail}}" alt="{{product_title}}">89            <div class="item-details">90                <strong>{{product_title}}</strong>91                <p>{{subtitle}}</p>92                <p>Quantity: <strong>{{quantity}}</strong></p>93                <p>Price: <strong>$ {{unit_price}}</strong></p>94            </div>95        </div>96        {{/each}}97        98        <a href="https://yourstore.com/cart/recover/{{cart_id}}" class="button">Return to Cart & Checkout</a>99        <p class="footer">Need help? <a href="mailto:support@yourstore.com">Contact us</a></p>100    </div>101</body>102</html>

This template will show each item's image, title, quantity, and price in the cart. It will also show a button to return to the cart and checkout.

You can replace https://yourstore.com with your storefront's URL. You'll later implement the /cart/recover/:cart_id route in the storefront to recover the cart.

Once you are done, copy the template ID from SendGrid and set it as an environment variable in your Medusa project:

Code
ABANDONED_CART_TEMPLATE_ID=your-sendgrid-template-id

Step 4: Schedule Cart Abandonment Notifications#

The next step is to automate sending the abandoned cart notifications. You need a task that runs once a day to find the carts that have been abandoned for a certain period and send the notifications to the customers.

To run a task at a scheduled interval, you can use a scheduled job. A scheduled job is an asynchronous function that the Medusa application runs at the interval you specify during the Medusa application's runtime.

You can create a scheduled job in a TypeScript or JavaScript file under the src/jobs directory. So, to create the scheduled job that sends the abandoned cart notifications, create the file src/jobs/send-abandoned-cart-notification.ts with the following content:

src/jobs/send-abandoned-cart-notification.ts
1import { MedusaContainer } from "@medusajs/framework/types"2import { 3  sendAbandonedCartsWorkflow, 4  SendAbandonedCartsWorkflowInput,5} from "../workflows/send-abandoned-carts"6
7export default async function abandonedCartJob(8  container: MedusaContainer9) {10  const logger = container.resolve("logger")11  const query = container.resolve("query")12
13  const oneDayAgo = new Date()14  oneDayAgo.setDate(oneDayAgo.getDate() - 1)15  const limit = 10016  const offset = 017  const totalCount = 018  const abandonedCartsCount = 019
20  do {21    // TODO retrieve paginated abandoned carts22  } while (offset < totalCount)23
24  logger.info(`Sent ${abandonedCartsCount} abandoned cart notifications`)25}26
27export const config = {28  name: "abandoned-cart-notification",29  schedule: "0 0 * * *", // Run at midnight every day30}

In a scheduled job's file, you must export:

  1. An asynchronous function that holds the job's logic. The function receives the Medusa container as a parameter.
  2. A config object that specifies the job's name and schedule. The schedule is a cron expression that defines the interval at which the job runs.

In the scheduled job function, so far you resolve the Logger to log messages, and Query to retrieve data across modules.

You also define a oneDayAgo date, which is the date that you will use as the condition of an abandoned cart. In addition, you define variables to paginate the carts.

Next, you will retrieve the abandoned carts using Query. Replace the TODO with the following:

src/jobs/send-abandoned-cart-notification.ts
1const { 2  data: abandonedCarts, 3  metadata,4} = await query.graph({5  entity: "cart",6  fields: [7    "id",8    "email",9    "items.*",10    "metadata",11    "customer.*",12  ],13  filters: {14    updated_at: {15      $lt: oneDayAgo,16    },17    // @ts-ignore18    email: {19      $ne: null,20    },21    // @ts-ignore22    completed_at: null,23  },24  pagination: {25    skip: offset,26    take: limit,27  },28})29
30totalCount = metadata?.count ?? 031const cartsWithItems = abandonedCarts.filter((cart) => 32  cart.items?.length > 0 && !cart.metadata?.abandoned_notification33)34
35try {36  await sendAbandonedCartsWorkflow(container).run({37    input: {38      carts: cartsWithItems,39    } as unknown as SendAbandonedCartsWorkflowInput,40  })41  abandonedCartsCount += cartsWithItems.length42
43} catch (error) {44  logger.error(45    `Failed to send abandoned cart notification: ${error.message}`46  )47}48
49offset += limit

In the do-while loop, you use Query to retrieve carts matching the following criteria:

  • The cart was last updated more than a day ago.
  • The cart has an email address.
  • The cart has not been completed.

You also filter the retrieved carts to only include carts with items and customers that have not received an abandoned cart notification.

Finally, you execute the sendAbandonedCartsWorkflow passing it the abandoned carts as an input. You will execute the workflow for each paginated batch of carts.

Test it Out#

To test out the scheduled job and workflow, it is recommended to change the oneDayAgo date to a minute before now for easy testing:

src/jobs/send-abandoned-cart-notification.ts
oneDayAgo.setMinutes(oneDayAgo.getMinutes() - 1) // For testing

And to change the job's schedule in config to run every minute:

src/jobs/send-abandoned-cart-notification.ts
1export const config = {2  // ...3  schedule: "* * * * *", // Run every minute for testing4}

Finally, start the Medusa application with the following command:

And in the Next.js Starter Storefront's directory (that you installed in the first step), start the storefront with the following command:

Open the storefront at localhost:8000. You can either:

  • Create an account and add items to the cart, then leave the cart for a minute.
  • Add an item to the cart as a guest. Then, start the checkout process, but only enter the shipping and email addresses, and leave the cart for a minute.

Afterwards, wait for the job to execute. Once it is executed, you will see the following message in the terminal:

Terminal
info:    Sent 1 abandoned cart notifications

Once you're done testing, make sure to revert the changes to the oneDayAgo date and the job's schedule.


Step 5: Recover Cart in Storefront#

In the storefront, you need to add a route that recovers the cart when the customer clicks on the link in the email. The route should receive the cart ID, set the cart ID in the cookie, and redirect the customer to the cart page.

To implement the route, in the Next.js Starter Storefront create the file src/app/[countryCode]/(main)/cart/recover/[id]/route.tsx with the following content:

Storefront
src/app/[countryCode]/(main)/cart/recover/[id]/route.tsx
1import { NextRequest } from "next/server"2import { retrieveCart } from "../../../../../../lib/data/cart"3import { setCartId } from "../../../../../../lib/data/cookies"4import { notFound, redirect } from "next/navigation"5type Params = Promise<{6  id: string7}>8
9export async function GET(req: NextRequest, { params }: { params: Params }) {10  const { id } = await params11  const cart = await retrieveCart(id)12
13  if (!cart) {14    return notFound()15  }16
17  setCartId(id)18
19  const countryCode = cart.shipping_address?.country_code || 20    cart.region?.countries?.[0]?.iso_221
22  redirect(23    `/${countryCode ? `${countryCode}/` : ""}cart`24  )25}

You add a GET route handler that receives the cart ID as a path parameter. In the route handler, you:

  • Try to retrieve the cart from the Medusa application. The retrieveCart function is already available in the Next.js storefront. If the cart is not found, you return a 404 response.
  • Set the cart ID in a cookie using the setCartId function. This is also a function that is already available in the storefront.
  • Redirect the customer to the cart page. You set the country code in the URL based on the cart's shipping address or region.

Test it Out#

To test it out, start the Medusa application:

And in the Next.js Starter Storefront's directory, start the storefront:

Then, either open the link in an abandoned cart email or navigate to localhost:8000/cart/recover/:cart_id in your browser. You will be redirected to the cart page with the recovered cart.

Cart page in the storefront


Next Steps#

You have now implemented the logic to send abandoned cart notifications in Medusa. You can implement other customizations with Medusa, such as:

If you are new to Medusa, check out the main documentation, where you will get a more in-depth learning of all the concepts you have used in this guide and more.

To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.

Was this page helpful?
Ask Anything
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break