Stripe-Cloudinary Transaction Receipts Generator

Eugene Musebe

Introduction

We will be creating a simple eCommerce sample application that uses Cloudinary to store our product images, Stripe for checkout, and Nodemailer to send transaction receipts

Getting started

Set up cloudinary

The first thing we need to do is get Cloudinary credentials that will enable us to make calls to their API. We need the cloud name, api key, and api secret. You can get started for free by creating an account. Head over to Cloudinary and create a free account. Once that's done, navigate to the console. You will notice your cloudinary account details at the top. Take note of those keys, we will be using them later.

Set up Stripe

Stripe offers payment solutions for different platforms including the web. Go ahead and create a free account. After creating an account, head over to the API keys page on your dashboard. You will need to take note of your Secret key. We will come back to it later.

Create a new Next.js project

You can get started with Next.js easily. Before starting a new project, ensure you have Node.js installed. Take a look at the Next.js Getting Started docs to learn how to get up and running with Next.js. Open up your terminal and run the following in your desired project location.

1npx create-next-app

Give your project an appropriate name. I named mine nextjs-stripe. You can then proceed to navigate to your project.

1cd nextjs-stripe

Getting our hands dirty

Here comes the fun part. Let's quickly go over what we need to accomplish

  1. Upload product images to cloudinary.
  2. Store products in a database.
  3. Upload, Read and display products on the frontend.
  4. Add products to the cart.
  5. Check out the cart using stripe.
  6. Send an email on successful payment and redirect the user back to the shopping home page.

Upload product images to cloudinary

We first need to install the cloudinary SDK.

1npm install cloudinary

Next, create a new folder called lib at the root of your project. Proceed to create a new file under the lib folder and name it cloudinary.js. Paste the following code inside.

1// lib/cloudinary.js
2
3import { v2 as cloudinary } from "cloudinary";
4
5// Initialize the cloudinary SDK using cloud name, api key and api secret
6cloudinary.config({
7 cloud_name: process.env.CLOUD_NAME,
8 api_key: process.env.API_KEY,
9 api_secret: process.env.API_SECRET,
10});
11
12/**
13 * Uploads a file to cloudinary
14 * @param {File} file
15 * @returns
16 */
17export const handleCloudinaryUpload = (file) => {
18 return new Promise((resolve, reject) => {
19 cloudinary.uploader.upload(
20 file.path,
21 {
22 // Type of resource. We leave it to cloudinary to determine but on the front end we only allow images and videos
23 resource_type: "auto",
24 // Only allow these formats
25 allowed_formats: ["jpg", "png"],
26 },
27 (error, result) => {
28 if (error) {
29 return reject(error);
30 }
31
32 return resolve(result);
33 }
34 );
35 });
36};

At the top, we import the cloudinary package and initialize it using the cloud name, api key, and api secret. We have defined those as environment variables. We will define those momentarily. We also have a function that will handle the actual upload. Read more about that here. The function returns a Promise that will either resolve with a successful result or reject with an error.

Let's define those environment variables we referenced. Create a new file at the root of your project and name it .env.local. Paste the following inside

1CLOUD_NAME=YOUR_CLOUD_NAME
2API_KEY=YOUR_API_KEY
3API_SECRET=YOUR_API_SECRET

Make sure to replace YOUR_CLOUD_NAME, YOUR_API_KEY, and YOUR_API_SECRET using the appropriate values from the cloudinary setup section above. To learn more about how Next.js handles environment variables, have a look at the docs.

We now have the logic in place to upload an image to cloudinary. Before we do that, however, we also need a way to keep track of our products and their images. To achieve this we need some sort of a database. When we add a new product to our store, we will upload its image to cloudinary and then store the product along with the cloudinary image url to a database.

Store products in a database

In a production scale app, you would need to use a fully-fledged database such as MySQL, PostgresSQL, MongoDB e.t.c., but for the sake of brevity and simplicity of this tutorial, we opt for a simple flat file database. These databases are not suitable for large amounts of data and are usually not optimized for large-scale use. Please keep in mind that we're only using this for demonstration purposes. Our choice of database will be nedb, an embedded persistent or in-memory database for Node.js, nw.js, Electron, and browsers. It's written entirely in Javascript and has a MongoDB-style syntax. Let's go ahead and install that.

1npm install nedb --save

We also need to create a class that will help us easily read and write the database. Create a new file called database.js under lib. Paste the following code inside.

1import { join } from "path";
2import Datastore from "nedb";
3
4// Custom database class using nedb, a simple flat file database
5class Database {
6 constructor() {
7 this.db = {
8 // Create a new datastore for inventory
9 inventory: new Datastore({
10 filename: join("data", "inventory.db"),
11 autoload: true,
12 }),
13 // Create a new datastore for orders
14 orders: new Datastore({
15 filename: join("data", "orders.db"),
16 autoload: true,
17 }),
18 };
19 }
20
21 // This method queries the database for inventory
22 getInventory() {
23 return new Promise((resolve, reject) => {
24 this.db.inventory.find().exec((err, inventory) => {
25 if (err) {
26 reject(err);
27 return;
28 }
29
30 resolve(inventory);
31 });
32 });
33 }
34
35 // This method adds a new item to the inventory datastore
36 addNewInventory(item) {
37 return new Promise((resolve, reject) => {
38 this.db.inventory.insert(item, (err, newDoc) => {
39 if (err) {
40 reject(err);
41 return;
42 }
43
44 resolve(newDoc);
45 });
46 });
47 }
48
49 // This method adds a new item to the orders datastore
50 addNewOrder(order) {
51 return new Promise((resolve, reject) => {
52 this.db.orders.insert(order, (err, newDoc) => {
53 if (err) {
54 reject(err);
55 return;
56 }
57
58 resolve(newDoc);
59 });
60 });
61 }
62
63 // This method gets an order from the database using the checkout session id
64 getOrder(sessionId) {
65 return new Promise((resolve, reject) => {
66 this.db.orders.findOne({ session: sessionId }, (err, order) => {
67 if (err) {
68 reject(err);
69 return;
70 }
71
72 resolve(order);
73 });
74 });
75 }
76
77 // This method queries the database for orders
78 getOrders() {
79 return new Promise((resolve, reject) => {
80 this.db.orders.find().exec((err, orders) => {
81 if (err) {
82 reject(err);
83 return;
84 }
85
86 resolve(orders);
87 });
88 });
89 }
90}
91
92// Create a new instance of the database class and export it as a singleton
93export const database = new Database();

Let's go over this. We create a new class called Database. In the constructor of the class, we create two Datastores/Collections:

  1. The inventory datastore. This will store our products.
  2. The orders datastore. This will store stripe checkout sessions along with the items ordered.

Each datastore will be its own file under the data folder at the root of your project. Go ahead and create that folder. After we have initialized the database in the constructor, we proceed to define a few methods that will enable us to read and write the two datastores. The syntax is very similar to that of MongoDB. Here's some documentation on inserting documents and finding documents. Finally, we create a singleton instance of the Database class and export that.

Upload, Read and display products on the frontend

At this point, we're ready to implement logic to upload the products to our backend. Let's do that. Starting with the backend. Create a new file called inventory.js inside pages/api/ folder. This will handle API calls to the api/inventory endpoint. Paste the following code inside.

1// pages/api/inventory.js
2import { IncomingForm, Fields, Files } from "formidable";
3import { handleCloudinaryUpload } from "../../lib/cloudinary";
4import { database } from "../../lib/database";
5
6// Custome config for Next.js API route
7export const config = {
8 api: {
9 bodyParser: false,
10 },
11};
12
13export default async function handler(req, res) {
14 switch (req.method) {
15 case "GET": {
16 try {
17 const result = await handleGetRequest();
18
19 return res.status(200).json({ message: "Success", result });
20 } catch (error) {
21 console.error(error);
22 return res.status(400).json({ message: "Error", error });
23 }
24 }
25 case "POST": {
26 try {
27 const result = await handlePostRequest(req);
28
29 return res.status(200).json({ message: "Success", result });
30 } catch (error) {
31 console.error(error);
32 return res.status(400).json({ message: "Error", error });
33 }
34 }
35
36 default: {
37 return res.status(405).json({ message: "Method not allowed" });
38 }
39 }
40}
41
42const handleGetRequest = async () => {
43 // Get the inventory from the database
44 return database.getInventory();
45};
46
47const handlePostRequest = async (req) => {
48 // Parse the form to get the fields and files
49 const data = await parseForm(req);
50
51 // Item image
52 const file = data?.files?.file;
53
54 // Item name
55 const name = data?.fields?.["name"];
56
57 // Item price
58 const price = parseInt(data?.fields?.["price"]);
59
60 // Items remaining in stock
61 const remainingQuantity = parseInt(data?.fields?.["quantity"]);
62
63 // Upload the image to Cloudinary
64 const result = await handleCloudinaryUpload(file);
65
66 // Save the item to the database
67 return database.addNewInventory({
68 name,
69 price,
70 remainingQuantity,
71 currency: "USD",
72 image: result.secure_url,
73 });
74};
75
76/**
77 *
78 * @param {*} req
79 * @returns {Promise<{ fields:Fields; files:Files; }>}
80 */
81const parseForm = (req) => {
82 return new Promise((resolve, reject) => {
83 // Create a new form
84 const form = new IncomingForm({ keepExtensions: true, multiples: true });
85
86 // Parse the incoming request form data
87 form.parse(req, (error, fields, files) => {
88 if (error) {
89 return reject(error);
90 }
91
92 return resolve({ fields, files });
93 });
94 });
95};

At the top of the file, we have a custom configuration for our API route. Read more about this here. We then have a function that will handle the HTTP request. We only handle the GET and POST requests. For the GET request, we query the database we created earlier for existing products. For the POST request, we use a library called Formidable, which we will be installing shortly, to parse the form data so that we can extract the product image and details. Once we have those, we proceed to upload the image to cloudinary and then store the product details along with the image url from cloudinary to our database. Let's install the missing package.

1npm install formidable

Let's move on to the front end. We first need a component that will wrap our pages so that we can have a consistent layout with the navigation at the top. Let's create that. Create a new folder at the root and call it components. Inside the folder create a new component called Layout.js and paste the following code inside.

1// components/Layout.js
2
3import Head from "next/head";
4import Link from "next/link";
5import { useRecoilState } from "recoil";
6import { cartState } from "../lib/items";
7
8export default function Layout({ children }) {
9 const [cart, setCart] = useRecoilState(cartState);
10
11 return (
12 <div>
13 <Head>
14 <title>Next.js Stripe</title>
15 <meta name="description" content="Generated by create next app" />
16 <link rel="icon" href="/favicon.ico" />
17 </Head>
18 <header>
19 <h1>Next.js Stripe</h1>
20 <nav>
21 <Link href="/" passHref>
22 <a>Home</a>
23 </Link>
24 <Link href="/cart" passHref>
25 <a>Cart {cart.length ? `(${cart.length})` : ""} </a>
26 </Link>
27 </nav>
28 </header>
29 <main>{children}</main>
30 <style>{`
31 header {
32 background-color:#FFFFFF;
33 height: 100px;
34 display: flex;
35 flex-flow: row no-wrap;
36 box-shadow: 0 2px 1px #dedede;
37 }
38 header h1 {
39 margin: auto 40px;
40 color: #6F00FF;
41 }
42 header nav {
43 height: 100%;
44 background-color: transparent;
45 flex: 1 0 auto;
46 display: flex;
47 flex-flow: row no-wrap;
48 justify-content: flex-end;
49 align-items: center;
50 }
51 header nav a {
52 margin: 10px 10px;
53 padding: 10px 20px;
54 background-color: #6F00FF;
55 font-weight: bold;
56 color: #ffffff;
57 }
58 header nav a:hover{
59 background-color: #FFFFFF;
60 font-weight: bold;
61 color: #000000;
62 }
63 `}</style>
64 </div>
65 );
66}

This is just a simple component with a few styles. You will also notice that we're importing a package called recoil that we haven't installed yet. We will install it and go over it in the next section. Let's first create our home page. Open pages/index.js and paste the following code inside.

1import Layout from "../components/Layout";
2import Image from "next/image";
3import Link from "next/link";
4
5import { useRecoilState } from "recoil";
6import { cartState } from "../lib/items";
7import useSWR from "swr";
8
9export default function Home() {
10 const [cart, setCart] = useRecoilState(cartState);
11
12 // Make a call to the /api/inventory endpoint to get existing items. This is run when the component renders
13 const { data, error } = useSWR("/api/inventory", async (url) => {
14 const res = await fetch(url);
15
16 // Check if the request was successful or not
17 if (!res.ok) {
18 const error = new Error("An error occurred while fetching the data.");
19 // Attach extra info to the error object.
20 error.info = await res.json();
21 error.status = res.status;
22 throw error;
23 }
24
25 return await res.json();
26 });
27
28 // Add a new item to the cart global state
29 const addToCart = (item) => {
30 setCart([...cart, item]);
31 };
32
33 return (
34 <Layout>
35 <div className="actions">
36 <Link href="/upload" passHref>
37 <button>Add new Item</button>
38 </Link>
39 </div>
40 <div className="items-wrapper">
41 {/* Check if the API call was successful and contains items */}
42 {data?.result && data?.result?.length
43 ? // Map through the items/products and return a div for each item
44 data?.result?.map((item, itemIndex) => (
45 <div key={itemIndex} className="item">
46 <div className="item-image-wrapper">
47 <Image
48 src={item.image}
49 alt={item.name}
50 layout="fill"
51 objectFit="cover"
52 className="item-image"
53 ></Image>
54 </div>
55 <div className="item-details">
56 <h2 className="item-name">{item.name}</h2>
57 <p className="item-stock">
58 <b>{item.remainingQuantity}</b> Items in stock
59 </p>
60 <p className="item-price">
61 <small className="currency">{item.currency}</small>{" "}
62 <b className="price">{item.price}</b>
63 </p>
64 <button
65 onClick={() => {
66 addToCart(item);
67 }}
68 >
69 Add To Cart
70 </button>
71 </div>
72 </div>
73 ))
74 : "No Items yet. Get started by adding one above"}
75 </div>
76 <style jsx>{`
77 div.actions {
78 padding: 20px;
79 }
80 div.items-wrapper {
81 display: flex;
82 flex-flow: row wrap;
83 gap: 12px;
84 padding: 20px;
85 }
86 div.items-wrapper div.item {
87 background-color: #ffffff;
88 flex: 1 0 400px;
89 min-height: 500px;
90 box-shadow: 0 1px #ffffff inset, 0 1px 3px rgba(34, 25, 25, 0.4);
91 padding-bottom: 50px;
92 }
93 div.items-wrapper div.item div.item-image-wrapper {
94 width: 100%;
95 height: 60%;
96 position: relative;
97 }
98 div.items-wrapper div.item div.item-details {
99 height: 40%;
100 padding: 20px;
101 }
102 div.items-wrapper div.item div.item-details .item-name {
103 margin: 0;
104 }
105 button {
106 padding: 20px 50px;
107 background-color: #6f00ff;
108 font-weight: bold;
109 color: #ffffff;
110 border: solid 2px #6f00ff;
111 }
112 button:hover {
113 background-color: #ffffff;
114 font-weight: bold;
115 color: #000000;
116 border: solid 2px #000000;
117 }
118 `}</style>
119 </Layout>
120 );
121}

Let's take a moment to go over that. When our component renders, we make a call to the /api/inventory endpoint using a package called swr. The call to the endpoint will get existing products from our database. We also use recoil.js to manage our cart state. We will install both these packages in the next section just below. We wrap our page in the Layout component we created earlier. We then map through our products and return a div for each product. The div contains the product image, name, price, quantity remaining, and a button to add the product to the cart.

Since we're using the Next.js Image component, we might run into a few issues when viewing images from an external domain. Read about image optimization and domains. To fix this, we need to modify the next.config.js file. Make sure you have the following in your module.exports in next.config.js. This just adds the cloudinary domain to the whitelist.

1module.exports = {
2 images: {
3 domains: ["res.cloudinary.com"],
4 },
5 // ... others
6};

Let's now install the missing dependencies.

  1. Recoil.js This is a state management library for React. It uses a hooks API to read and manipulate the global state.

    1npm install recoil

    We also need to create the cart state that we will be using in a future section. Create a new file under lib/ called items.js and add the following code

    1// lib/items.js
    2
    3 import { atom } from "recoil";
    4
    5 // Create a new recoil.js atom to keep track of the items in our cart
    6 export const cartState = atom({
    7 key: "cartState",
    8 default: [],
    9 });

    I will not go too much into using atoms and creating state objects. An atom is basically a piece of state. Read the docs for more info.

    To use atoms in our application we also need to modify our app's root component. Open pages/_app.js and change the code to the following.

    1// pages/_app.js
    2
    3 import { RecoilRoot } from "recoil";
    4 import "../styles/globals.css";
    5
    6 function MyApp({ Component, pageProps }) {
    7 return (
    8 <RecoilRoot>
    9 <Component {...pageProps} />
    10 </RecoilRoot>
    11 );
    12 }
    13
    14 export default MyApp;

    We've wrapped our root component in a RecoilRoot component from recoil.js.

  2. SWR SWR(stale-while-revalidate), is a data fetching library made specifically for react. You've probably used it if you have worked on a large-scale application.

    1npm install swr

The next thing we need before moving to our cart is the upload page. We will be adding new products to our application via this page. Create upload.js under pages/ and add the following code.

1/* eslint-disable @next/next/no-img-element */
2import { useState } from "react";
3import Layout from "../components/Layout";
4import { useRouter } from "next/router";
5
6export default function Upload() {
7 const router = useRouter();
8
9 // Holds the file selected by the file input element
10 const [file, setFile] = useState(null);
11
12 // Holds the loading state of the upload
13 const [loading, setLoading] = useState(false);
14
15 const handleFormSubmit = async (e) => {
16 // Stop the browser from submitting the form
17 e.preventDefault();
18
19 setLoading(true);
20
21 try {
22 const formData = new FormData(e.target);
23
24 // Upload the file
25 const response = await fetch("/api/inventory", {
26 method: "POST",
27 body: formData,
28 });
29
30 const data = await response.json();
31
32 if (response.ok) {
33 // If the upload was successful, redirect to the home page
34 return router.push("/");
35 }
36
37 throw data;
38 } catch (error) {
39 // TODO: Show error message to user
40 console.error(error);
41 } finally {
42 setLoading(false);
43 }
44 };
45
46 return (
47 <Layout>
48 <div className="wrapper">
49 <form onSubmit={handleFormSubmit}>
50 {file && (
51 <div className="image-preview">
52 <img src={URL.createObjectURL(file)} alt="Selected Image"></img>
53 </div>
54 )}
55 <div className="input-group">
56 <label htmlFor="file">Item Image</label>
57 <input
58 type="file"
59 id="file"
60 name="file"
61 accept=".jpg,.png"
62 multiple={false}
63 required
64 disabled={loading}
65 onChange={(e) => {
66 const file = e.target.files[0];
67
68 setFile(file);
69 }}
70 />
71 </div>
72
73 <div className="input-group">
74 <label htmlFor="itemName">Item Name</label>
75 <input
76 type="text"
77 id="itemName"
78 name="name"
79 placeholder="Item Name"
80 required
81 disabled={loading}
82 />
83 </div>
84 <div className="input-group">
85 <label htmlFor="itemPrice">Item Price(USD)</label>
86 <input
87 type="number"
88 id="itemPrice"
89 name="price"
90 placeholder="Item Price"
91 required
92 disabled={loading}
93 />
94 </div>
95 <div className="input-group">
96 <label htmlFor="quantity">Item Quantity</label>
97 <input
98 type="number"
99 id="quantity"
100 name="quantity"
101 placeholder="Item Quantity"
102 required
103 disabled={loading}
104 />
105 </div>
106 <button disabled={loading} type="submit">
107 Add Item
108 </button>
109 </form>
110 </div>
111 <style jsx>{`
112 div.wrapper {
113 background-color: #f8fbfd;
114 min-height: 100vh;
115 }
116 form {
117 background-color: transparent;
118 max-width: 500px;
119 margin: 0 auto;
120 display: flex;
121 flex-flow: column;
122 margin: 10px auto;
123 }
124 form div.image-preview img {
125 width: 100%;
126 }
127 form div.input-group {
128 display: flex;
129 flex-flow: column;
130 background-color: #ffffff;
131 margin: 10px 0;
132 padding: 10px;
133 border-radius: 5px;
134 }
135 form div.input-group label {
136 font-weight: bold;
137 margin: 5px 0;
138 }
139 form div.input-group input {
140 height: 50px;
141 border: solid 2px #000000;
142 padding: 0 10px;
143 }
144 form div.input-group input:focus {
145 border: solid 2px #6f00ff;
146 }
147 form button {
148 margin: 10px 10px;
149 height: 50px;
150 background-color: #6f00ff;
151 font-weight: bold;
152 color: #ffffff;
153 border: solid 2px #6f00ff;
154 }
155 form button:disabled {
156 background-color: gray;
157 border: solid 2px gray;
158 }
159 form button:hover {
160 background-color: #ffffff;
161 font-weight: bold;
162 color: #000000;
163 border: solid 2px #000000;
164 }
165 `}</style>
166 </Layout>
167 );
168}

Again we wrap our page in the Layout component. We then have a form with a file input that accepts images, and a few inputs for the item name, price, and quantity in stock. When the user selects an image, we set the file state to the selected file and display a preview of the image. We then proceed to upload the file to the /api/inventory endpoint as form data. If the file is uploaded successfully, it gets stored in the database we created earlier and then we navigate back to the home page using Next's useRouter hook. With that, we have the products and upload pages ready. Our next objective is the cart page. Let's get started on that.

Add products to cart

We already created the cart global state using Recoil.js in the previous section. All we need now is the actual cart page. Create a file called cart.js under pages/ folder and add the following code.

1import Image from "next/image";
2import { useRecoilState } from "recoil";
3import Layout from "../components/Layout";
4import { cartState } from "../lib/items";
5
6export default function Cart() {
7 const [cart, setCart] = useRecoilState(cartState);
8
9 const removeFromCart = (item) => {
10 // This is a hack to get around the fact that the cart contains read-only items. So we need to clone it so we can modify it.
11 const copiedCart = [...cart];
12
13 // Remove the item from the copied cart
14 copiedCart.splice(copiedCart.indexOf(item), 1);
15
16 // Set the new cart as the copied cart
17 setCart(copiedCart);
18 };
19
20 const handleCheckout = async () => {
21 try {
22 // Make a request to the server to checkout the cart. We pass the items in the cart in the body of the request.
23 const response = await fetch("/api/pay", {
24 method: "POST",
25 body: JSON.stringify({
26 items: cart,
27 }),
28 headers: {
29 "Content-Type": "application/json",
30 },
31 });
32
33 const data = await response.json();
34
35 if (response.ok) {
36 // If the server returns a 200 OK, then we can assume that the checkout was successful. We redirect to the stripe payment page in a new tab
37 window.open(data.result.url, "_blank");
38 return;
39 }
40
41 throw data;
42 } catch (error) {
43 console.error(error);
44 }
45 };
46
47 return (
48 <Layout>
49 <div className="wrapper">
50 <div className="cart">
51 <h2>Your cart has {cart.length} items</h2>
52 <hr />
53 <div className="cart-items-wrapper">
54 {cart.map((item, itemIndex) => (
55 <div key={itemIndex} className="cart-item">
56 <div className="item-image-wrapper">
57 <Image
58 src={item.image}
59 alt={item.name}
60 layout="fill"
61 objectFit="cover"
62 ></Image>
63 </div>
64 <div className="item-details">
65 <b>{item.name}</b>
66 <p>
67 {item.currency} {item.price}
68 </p>
69 <button
70 onClick={() => {
71 console.log(item);
72 removeFromCart(item);
73 }}
74 >
75 Remove from cart
76 </button>
77 </div>
78 </div>
79 ))}
80 </div>
81 <div className="cart-total">
82 <p>
83 Total: <small>USD</small>{" "}
84 <b>
85 {cart.reduce((total, item) => {
86 return total + item.price;
87 }, 0)}
88 </b>
89 </p>
90 </div>
91 <button
92 onClick={() => {
93 handleCheckout();
94 }}
95 >
96 {" "}
97 CHECKOUT
98 </button>
99 </div>
100 </div>
101 <style jsx>{`
102 div.wrapper {
103 background-color: #f8fbfd;
104 }
105 div.cart {
106 background-color: transparent;
107 margin: 20px auto;
108 max-width: 800px;
109 min-height: calc(100vh - 100px);
110 display: flex;
111 flex-flow: column;
112 }
113 div.cart > h2,
114 div.cart > p {
115 margin: 10px 0;
116 text-align: center;
117 }
118 div.cart > button {
119 padding: 20px 0;
120 background-color: #6f00ff;
121 font-weight: bold;
122 color: #ffffff;
123 border: none;
124 }
125 div.cart > button:hover {
126 background-color: #ffffff;
127 font-weight: bold;
128 color: #000000;
129 border: solid 2px #000000;
130 }
131 div.cart-items-wrapper {
132 display: flex;
133 flex-flow: column;
134 flex: 1 0 auto;
135 overflow-y: auto;
136 gap: 10px;
137 }
138 div.cart-items-wrapper div.cart-item {
139 display: flex;
140 flex-flow: row no-wrap;
141 height: 150px;
142 background-color: #ffffff;
143 }
144 div.cart-items-wrapper div.cart-item div.item-image-wrapper {
145 position: relative;
146 width: 20%;
147 }
148 div.cart-items-wrapper div.cart-item div.item-details {
149 width: 80%;
150 padding: 10px;
151 display: flex;
152 flex-flow: column;
153 }
154 div.cart-items-wrapper div.cart-item div.item-details button {
155 width: max-content;
156 }
157 `}</style>
158 </Layout>
159 );
160}

Let's have a recap of that. We keep track of our cart state using Recoil's useRecoilState hook. Read more about this hook here. We wrap our page in the Layout component. We then map through the items in our cart state and return a div for each item. Each div has the item image, name, price, and a button to remove the item from the cart. At the bottom of the page, we have the total price and a button to checkout. The remove from cart and checkout buttons trigger removeFromCart and handleCheckout functions respectively. The handleCheckout method makes an HTTP call to the /api/pay with the cart items. We haven't created this endpoint yet. We will be doing that next. If the response is successful, we open a new tab to the stripe checkout page.

Checkout cart using stripe

We first install the stripe package

1npm install stripe

There's also a Stripe React package which you can directly integrate into the frontend but we wanted a bit more control and that is why we opt for the Node.js package instead.

Let's create the /api/pay endpoint. Create a new file called pay.js under pages/api. Paste the following code inside.

1// pages/api/pay.js
2import stripe from "stripe";
3import { database } from "../../lib/database";
4
5// Create a new stripe instance using your own secret key
6const client = stripe(process.env.STRIPE_SECRET_KEY);
7
8// Custom configuration for Next.js api route.
9export const config = {
10 api: {
11 bodyParser: true,
12 },
13};
14
15export default async function handler(req, res) {
16 switch (req.method) {
17 case "POST": {
18 try {
19 // Handle the pay request and pass the items from the cart
20 const result = await handlePostRequest(req.body.items);
21
22 // Return a successful response with the stripe checkout result
23 return res.status(200).json({ message: "Success", result });
24 } catch (error) {
25 console.log(error);
26 // Return an unsuccessfull response with the error message
27 return res.status(400).json({ message: "Error", error });
28 }
29 }
30
31 default: {
32 return res.status(405).json({ message: "Method not allowed" });
33 }
34 }
35}
36
37const handlePostRequest = async (items) => {
38 // Reduce the items so as to group similar items together. Similar items will be appended to the same array. So we will have an array of arrays.
39 const lineItems = items.reduce((groupedItems, item) => {
40 // Check if there's an existing array for the same item.
41 const existingInGroup = groupedItems.find((a) =>
42 a.some((i) => i._id === item._id)
43 );
44
45 if (existingInGroup) {
46 // If an array for the same item already exists, append the new item to the array.
47 existingInGroup.push(item);
48 } else {
49 // If no array for the same item exists, create a new array and push the item to the array. Then push the array to the groupedItems array.
50 groupedItems.push([item]);
51 }
52
53 return groupedItems;
54 }, []);
55
56 // Create a new Stripe payment checkout session
57 const session = await client.checkout.sessions.create({
58 // Stripe payment methods to accept
59 payment_method_types: ["card"],
60 // Map the line items to the checkout session
61 line_items: lineItems.map((items) => {
62 // Since each item is an array of similar items, we just need the first item in the array.
63 const [item] = items;
64
65 // Return a new line item
66 return {
67 price_data: {
68 currency: "usd",
69 product_data: {
70 name: item.name,
71 images: [item.image],
72 },
73 // Convert the price to cents
74 unit_amount: item.price * 100,
75 },
76 quantity: items.length,
77 };
78 }),
79 mode: "payment",
80 success_url: `${process.env.BASE_URL}/`,
81 cancel_url: `${process.env.BASE_URL}/`,
82 });
83
84 // Add the checkout session to the database.
85 await database.addNewOrder({
86 session: session.id,
87 items: lineItems,
88 });
89
90 return session;
91};

We import the stripe SDK and initialize it using the secret key we obtained in the Setup stripe section above. The secret key is defined as an environment variable. Go ahead and add the following to your .env.local file replacing YOUR_STRIPE_SECRET_KEY with the appropriate value.

1STRIPE_SECRET_KEY=YOUR_STRIPE_SECRET_KEY

Focusing on the handlePostRequest function, we first reduce our cart items so that we can group similar items into individual arrays. We will end up with an array of items where each item is also an array of similar products. Another simpler approach would have been to add a quantity field to each product and then increment that field for every similar product. We then create a new stripe checkout session. We tell stripe to only accept card as a payment method. For the line_items, we map through our grouped products and return an object that matches the stripe line items schema for every product. Please note that stripe accepts the unit_amount in cents and so we have to convert our amount to cents. We then pass success and cancel URLs. For this use case, we just want to redirect to our homepage. We set that to the BASE_URL environment variable. This will be the server url where your application is running. The localhost, for example, will be http://localhost:3000. Remember to add this to your .env.local file, replacing YOUR_BASE_URL with the appropriate value.

1BASE_URL=YOUR_BASE_URL

Read more about stripe checkout session options in the official docs. After a successful checkout session is created, we save the whole order in our database. We make sure to store the session id as well so that we can be able to retrieve our order later.

We're almost done.

Send email on successful payment

To be able to send our own custom email, we need to know when the payment is successful. Luckily, on Stripe you can add webhooks that will be called whenever certain events happen. In our case, we need a webhook that will be called when the payment is successful. Let's do that now. Create a new file called confirm-payment.js under pages/api. Paste the following code inside.

1import { database } from "../../lib/database";
2import { sendEmail } from "../../lib/mail";
3
4export default async function handler(req, res) {
5 switch (req.method) {
6 case "POST": {
7 try {
8 const result = await handlePostRequest(req.body);
9
10 return res.status(200).json({ message: "Success", result: "" });
11 } catch (error) {
12 console.log(error);
13 return res.status(400).json({ message: "Error", error });
14 }
15 }
16
17 default: {
18 return res.status(405).json({ message: "Method not allowed" });
19 }
20 }
21}
22
23const handlePostRequest = async (response) => {
24 // Find the order that matches the checkout session id
25 const order = await database.getOrder(response.data.object.id);
26
27 // Send email using nodemailer
28 await sendEmail(response.data.object.customer_details.email, order);
29};

This endpoint is called by Stripe and receives the payment information. We then query our database for the order with a session-id matching that we saved when creating a checkout session earlier. Along with the payment information and session id, we also receive the customer information, more importantly, the customer's email. We will use a combination of this data to send a transaction email. All that is missing now is the logic to send a custom email. Before we do that, we need to register our webhook with Stripe. Navigate to the Stripe webhooks dashboard and click on Add endpoint. For the url, add your BASE_URL + /api/confirm-payment, for the events to send, make sure you select the checkout.session.completed event. Go ahead and save that. Please note that the endpoint needs to be publicly accessible so using localhost will not work. You can use solutions such as ngrok and localtunnel to create public URLs which tunnel to your localhost port. You will need to update this in the stripe dashboard every time it changes. Another thing to note that we didn't cover in this tutorial is to ensure that the request actually came from stripe. With the endpoint being publicly accessible, it means anyone can make a call to it and fake a successful payment. Have a look at the Stripe webhook signatures documentation to find out how you can secure your webhook.

Let's now send the email. We will be using Nodemailer. Let's install that.

1npm install nodemailer

If you have ever written HTML for sending in an email, you know how painful it can be. Email clients just don't work like browsers and there's a lot of caveats. If you're going to write your own, I suggest you take a look at best practices online before getting started. Another solution would be to use existing email templates. This might also not fit into your use case. My approach is to always use free online tools. Some of the best ones I have used are HEML and MJML. You can write your own template in a custom syntax that will then be converted to HTML. Once you have your HTML ready we can proceed. For this tutorial, I have included the HTML that I used.

Create a new file called mail.js under lib/ and paste the following code inside.

1import { createTransport } from "nodemailer";
2
3// Create a new nodemailer transporter. This takes in a config object containing the service, host and auth details.
4const transporter = createTransport({
5 service: "gmail",
6 host: "smtp.gmail.com",
7 auth: {
8 user: process.env.MAIL_USERNAME,
9 pass: process.env.MAIL_PASSWORD,
10 },
11});
12
13// Send an email to customer.
14export const sendEmail = async (customerEmail, order) => {
15 // Use the transporter to send an email.
16 const mailResult = await transporter.sendMail({
17 from: process.env.MAIL_USERNAME,
18 to: customerEmail,
19 headers: {
20 priority: "medium",
21 },
22 subject: "Payment Successful",
23 html: `<!DOCTYPE html PUBLIC '-//W3C//DTD XHTML 1.0 Transitional //EN' 'http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd' />
24<html
25 lang='en'
26 xmlns='http://www.w3.org/1999/xhtml'
27 xmlns:v='urn:schemas-microsoft-com:vml'
28 xmlns:o='urn:schemas-microsoft-com:office:office'
29>
30 <head> </head>
31 <head>
32 <meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
33 <meta name='viewport' content='width=device-width, initial-scale=1.0' />
34 <meta name='x-apple-disable-message-reformatting' />
35 <!--[if !mso]><!-->
36 <meta http-equiv='X-UA-Compatible' content='IE=edge' />
37 <!--<![endif]-->
38 <style type='text/css'>
39 * {
40 text-size-adjust: 100%;
41 -ms-text-size-adjust: 100%;
42 -moz-text-size-adjust: 100%;
43 -webkit-text-size-adjust: 100%;
44 }
45
46 html {
47 height: 100%;
48 width: 100%;
49 }
50
51 body {
52 height: 100% !important;
53 margin: 0 !important;
54 padding: 0 !important;
55 width: 100% !important;
56 mso-line-height-rule: exactly;
57 }
58
59 div[style*='margin: 16px 0'] {
60 margin: 0 !important;
61 }
62
63 table,
64 td {
65 mso-table-lspace: 0pt;
66 mso-table-rspace: 0pt;
67 }
68
69 img {
70 border: 0;
71 height: auto;
72 line-height: 100%;
73 outline: none;
74 text-decoration: none;
75 -ms-interpolation-mode: bicubic;
76 }
77
78 .ReadMsgBody,
79 .ExternalClass {
80 width: 100%;
81 }
82
83 .ExternalClass,
84 .ExternalClass p,
85 .ExternalClass span,
86 .ExternalClass td,
87 .ExternalClass div {
88 line-height: 100%;
89 }
90 </style>
91 <!--[if gte mso 9]>
92 <style type='text/css'>
93 li {
94 text-indent: -1em;
95 }
96 table td {
97 border-collapse: collapse;
98 }
99 </style>
100 <![endif]-->
101 <title>Payment Successful</title>
102 <!-- content -->
103 <!--[if gte mso 9
104 ]><xml>
105 <o:OfficeDocumentSettings>
106 <o:AllowPNG />
107 <o:PixelsPerInch>96</o:PixelsPerInch>
108 </o:OfficeDocumentSettings>
109 </xml><!
110 [endif]-->
111 </head>
112 <body class='body' style='background-color: #ffffff; margin: 0; width: 100%'>
113 <div
114 class='preview'
115 style='
116 color: #ffffff;
117 display: none;
118 font-size: 1px;
119 line-height: 1px;
120 max-height: 0px;
121 max-width: 0px;
122 opacity: 0;
123 overflow: hidden;
124 mso-hide: all;
125 '
126 >
127 Your payment was successful.!&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
128 </div>
129 <table
130 class='bodyTable'
131 role='presentation'
132 width='100%'
133 align='left'
134 border='0'
135 cellpadding='0'
136 cellspacing='0'
137 style='width: 100%; background-color: #ffffff; margin: 0'
138 bgcolor='#FFFFFF'
139 >
140 <tr>
141 <td
142 class='body__content'
143 align='left'
144 width='100%'
145 valign='top'
146 style='
147 color: #000000;
148 font-family: Helvetica, Arial, sans-serif;
149 font-size: 16px;
150 line-height: 20px;
151 '
152 >
153 <div
154 class='container'
155 style='margin: 0 auto; max-width: 600px; width: 100%'
156 >
157 <!--[if mso | IE]>
158 <table class='container__table__ie' role='presentation' border='0' cellpadding='0' cellspacing='0' style='margin-right: auto; margin-left: auto;width: 600px' width='600' align='center'>
159 <tr>
160 <td> <![endif]-->
161 <table
162 class='container__table'
163 role='presentation'
164 border='0'
165 align='center'
166 cellpadding='0'
167 cellspacing='0'
168 width='100%'
169 >
170 <tr class='container__row'>
171 <td
172 class='container__cell'
173 width='100%'
174 align='left'
175 valign='top'
176 >
177 <h1
178 class='header h1'
179 style='
180 margin: 20px 0;
181 line-height: 40px;
182 font-family: Helvetica, Arial, sans-serif;
183 color: #6f00ff;
184 '
185 >
186 Hello,Your Payment Was Successful
187 </h1>
188 <p
189 class='text p'
190 style='
191 display: block;
192 margin: 14px 0;
193 color: #000000;
194 font-family: Helvetica, Arial, sans-serif;
195 font-size: 16px;
196 line-height: 20px;
197 '
198 >
199 Your payment was processed successfuly for the following
200 items
201 </p>
202 <ul
203 class='text ul'
204 style='
205 margin-left: 20px;
206 margin-top: 16px;
207 margin-bottom: 16px;
208 padding: 0;
209 list-style-type: disc;
210 color: #000000;
211 font-family: Helvetica, Arial, sans-serif;
212 font-size: 16px;
213 line-height: 20px;
214 '
215 >
216 ${order.items
217 .map((itemGroup) => {
218 const [item] = itemGroup;
219
220 return `<li
221 class='text li'
222 style='
223 color: #000000;
224 font-family: Helvetica, Arial, sans-serif;
225 font-size: 16px;
226 line-height: 20px;
227 '
228 >
229 <p
230 class='text p'
231 style='
232 display: block;
233 margin: 14px 0;
234 color: #000000;
235 font-family: Helvetica, Arial, sans-serif;
236 font-size: 16px;
237 line-height: 20px;
238 '
239 >
240 ${item.name}
241 </p>
242 <img
243 src='${item.image}'
244 alt='${item.name}'
245 border='0'
246 class='img__block'
247 style='display: block; max-width: 100%'
248 />
249 <p
250 class='text p'
251 style='
252 display: block;
253 margin: 14px 0;
254 color: #000000;
255 font-family: Helvetica, Arial, sans-serif;
256 font-size: 16px;
257 line-height: 20px;
258 '
259 >
260 ${itemGroup.length} units each at <small>USD</small> <b>${item.price}</b>
261 </p>
262 </li>`;
263 })
264 .join("")}
265
266 </ul>
267 <div class='hr' style='margin: 0 auto; width: 100%'>
268 <!--[if mso | IE]>
269 <table class='hr__table__ie' role='presentation' border='0' cellpadding='0' cellspacing='0' style='margin-right: auto; margin-left: auto; width: 100%;' width='100%' align='center'>
270 <tr>
271 <td> <![endif]-->
272 <table
273 class='hr__table'
274 role='presentation'
275 border='0'
276 align='center'
277 cellpadding='0'
278 cellspacing='0'
279 width='100%'
280 style='table-layout: fixed'
281 >
282 <tr class='hr__row'>
283 <td
284 class='hr__cell'
285 width='100%'
286 align='left'
287 valign='top'
288 style='border-top: 1px solid #9a9a9a'
289 >
290 &nbsp;
291 </td>
292 </tr>
293 </table>
294 <!--[if mso | IE]> </td>
295 </tr>
296 </table> <![endif]-->
297 </div>
298 </td>
299 </tr>
300 </table>
301 <!--[if mso | IE]> </td>
302 </tr>
303 </table> <![endif]-->
304 </div>
305 </td>
306 </tr>
307 </table>
308 <div
309 style='
310 display: none;
311 white-space: nowrap;
312 font-size: 15px;
313 line-height: 0;
314 '
315 >
316 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
317 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
318 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
319 </div>
320 </body>
321</html>
322`,
323 });
324
325 return mailResult;
326};

We're using Nodemailer with the Gmail transport service. Remember to add the referenced environment variables to your .env.local file. Don't forget to replace YOUR_GMAIL_ADDRESS and YOUR_GMAIL_PASSWORD

1MAIL_USERNAME=YOUR_GMAIL_ADDRESS
2MAIL_PASSWORD=YOUR_GMAIL_PASSWORD

This is just one approach/transport service you can use. Read the Nodemailer docs to discover more options and configurations. The documentation also includes the different options that we passed to transporter.sendMail.

Once the payment is successful and the transaction receipt is sent to the respective email of the user, the user will be redirected back to the application's homepage.

And that is it for this tutorial. Here's a link to some test cards that you can use to test the payment.

You can find the full source on my Github

The final projects Codesandbox can also be viewed below :

Further Reading

Stripe API Reference

Cloudinary API Documentation

Getting Started with Nextjs

Eugene Musebe

Software Developer

I’m a full-stack software developer, content creator, and tech community builder based in Nairobi, Kenya. I am addicted to learning new technologies and loves working with like-minded people.