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
- Upload product images to cloudinary.
- Store products in a database.
- Upload, Read and display products on the frontend.
- Add products to the cart.
- Check out the cart using stripe.
- 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.js23import { v2 as cloudinary } from "cloudinary";45// Initialize the cloudinary SDK using cloud name, api key and api secret6cloudinary.config({7 cloud_name: process.env.CLOUD_NAME,8 api_key: process.env.API_KEY,9 api_secret: process.env.API_SECRET,10});1112/**13 * Uploads a file to cloudinary14 * @param {File} file15 * @returns16 */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 videos23 resource_type: "auto",24 // Only allow these formats25 allowed_formats: ["jpg", "png"],26 },27 (error, result) => {28 if (error) {29 return reject(error);30 }3132 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_NAME2API_KEY=YOUR_API_KEY3API_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";34// Custom database class using nedb, a simple flat file database5class Database {6 constructor() {7 this.db = {8 // Create a new datastore for inventory9 inventory: new Datastore({10 filename: join("data", "inventory.db"),11 autoload: true,12 }),13 // Create a new datastore for orders14 orders: new Datastore({15 filename: join("data", "orders.db"),16 autoload: true,17 }),18 };19 }2021 // This method queries the database for inventory22 getInventory() {23 return new Promise((resolve, reject) => {24 this.db.inventory.find().exec((err, inventory) => {25 if (err) {26 reject(err);27 return;28 }2930 resolve(inventory);31 });32 });33 }3435 // This method adds a new item to the inventory datastore36 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 }4344 resolve(newDoc);45 });46 });47 }4849 // This method adds a new item to the orders datastore50 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 }5758 resolve(newDoc);59 });60 });61 }6263 // This method gets an order from the database using the checkout session id64 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 }7172 resolve(order);73 });74 });75 }7677 // This method queries the database for orders78 getOrders() {79 return new Promise((resolve, reject) => {80 this.db.orders.find().exec((err, orders) => {81 if (err) {82 reject(err);83 return;84 }8586 resolve(orders);87 });88 });89 }90}9192// Create a new instance of the database class and export it as a singleton93export 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:
- The inventory datastore. This will store our products.
- 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.js2import { IncomingForm, Fields, Files } from "formidable";3import { handleCloudinaryUpload } from "../../lib/cloudinary";4import { database } from "../../lib/database";56// Custome config for Next.js API route7export const config = {8 api: {9 bodyParser: false,10 },11};1213export default async function handler(req, res) {14 switch (req.method) {15 case "GET": {16 try {17 const result = await handleGetRequest();1819 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);2829 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 }3536 default: {37 return res.status(405).json({ message: "Method not allowed" });38 }39 }40}4142const handleGetRequest = async () => {43 // Get the inventory from the database44 return database.getInventory();45};4647const handlePostRequest = async (req) => {48 // Parse the form to get the fields and files49 const data = await parseForm(req);5051 // Item image52 const file = data?.files?.file;5354 // Item name55 const name = data?.fields?.["name"];5657 // Item price58 const price = parseInt(data?.fields?.["price"]);5960 // Items remaining in stock61 const remainingQuantity = parseInt(data?.fields?.["quantity"]);6263 // Upload the image to Cloudinary64 const result = await handleCloudinaryUpload(file);6566 // Save the item to the database67 return database.addNewInventory({68 name,69 price,70 remainingQuantity,71 currency: "USD",72 image: result.secure_url,73 });74};7576/**77 *78 * @param {*} req79 * @returns {Promise<{ fields:Fields; files:Files; }>}80 */81const parseForm = (req) => {82 return new Promise((resolve, reject) => {83 // Create a new form84 const form = new IncomingForm({ keepExtensions: true, multiples: true });8586 // Parse the incoming request form data87 form.parse(req, (error, fields, files) => {88 if (error) {89 return reject(error);90 }9192 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.js23import Head from "next/head";4import Link from "next/link";5import { useRecoilState } from "recoil";6import { cartState } from "../lib/items";78export default function Layout({ children }) {9 const [cart, setCart] = useRecoilState(cartState);1011 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";45import { useRecoilState } from "recoil";6import { cartState } from "../lib/items";7import useSWR from "swr";89export default function Home() {10 const [cart, setCart] = useRecoilState(cartState);1112 // Make a call to the /api/inventory endpoint to get existing items. This is run when the component renders13 const { data, error } = useSWR("/api/inventory", async (url) => {14 const res = await fetch(url);1516 // Check if the request was successful or not17 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 }2425 return await res.json();26 });2728 // Add a new item to the cart global state29 const addToCart = (item) => {30 setCart([...cart, item]);31 };3233 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?.length43 ? // Map through the items/products and return a div for each item44 data?.result?.map((item, itemIndex) => (45 <div key={itemIndex} className="item">46 <div className="item-image-wrapper">47 <Image48 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 stock59 </p>60 <p className="item-price">61 <small className="currency">{item.currency}</small>{" "}62 <b className="price">{item.price}</b>63 </p>64 <button65 onClick={() => {66 addToCart(item);67 }}68 >69 Add To Cart70 </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 // ... others6};
Let's now install the missing dependencies.
Recoil.js This is a state management library for React. It uses a hooks API to read and manipulate the global state.
1npm install recoilWe also need to create the cart state that we will be using in a future section. Create a new file under
lib/
calleditems.js
and add the following code1// lib/items.js23 import { atom } from "recoil";45 // Create a new recoil.js atom to keep track of the items in our cart6 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.js23 import { RecoilRoot } from "recoil";4 import "../styles/globals.css";56 function MyApp({ Component, pageProps }) {7 return (8 <RecoilRoot>9 <Component {...pageProps} />10 </RecoilRoot>11 );12 }1314 export default MyApp;We've wrapped our root component in a
RecoilRoot
component from recoil.js.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";56export default function Upload() {7 const router = useRouter();89 // Holds the file selected by the file input element10 const [file, setFile] = useState(null);1112 // Holds the loading state of the upload13 const [loading, setLoading] = useState(false);1415 const handleFormSubmit = async (e) => {16 // Stop the browser from submitting the form17 e.preventDefault();1819 setLoading(true);2021 try {22 const formData = new FormData(e.target);2324 // Upload the file25 const response = await fetch("/api/inventory", {26 method: "POST",27 body: formData,28 });2930 const data = await response.json();3132 if (response.ok) {33 // If the upload was successful, redirect to the home page34 return router.push("/");35 }3637 throw data;38 } catch (error) {39 // TODO: Show error message to user40 console.error(error);41 } finally {42 setLoading(false);43 }44 };4546 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 <input58 type="file"59 id="file"60 name="file"61 accept=".jpg,.png"62 multiple={false}63 required64 disabled={loading}65 onChange={(e) => {66 const file = e.target.files[0];6768 setFile(file);69 }}70 />71 </div>7273 <div className="input-group">74 <label htmlFor="itemName">Item Name</label>75 <input76 type="text"77 id="itemName"78 name="name"79 placeholder="Item Name"80 required81 disabled={loading}82 />83 </div>84 <div className="input-group">85 <label htmlFor="itemPrice">Item Price(USD)</label>86 <input87 type="number"88 id="itemPrice"89 name="price"90 placeholder="Item Price"91 required92 disabled={loading}93 />94 </div>95 <div className="input-group">96 <label htmlFor="quantity">Item Quantity</label>97 <input98 type="number"99 id="quantity"100 name="quantity"101 placeholder="Item Quantity"102 required103 disabled={loading}104 />105 </div>106 <button disabled={loading} type="submit">107 Add Item108 </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";56export default function Cart() {7 const [cart, setCart] = useRecoilState(cartState);89 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];1213 // Remove the item from the copied cart14 copiedCart.splice(copiedCart.indexOf(item), 1);1516 // Set the new cart as the copied cart17 setCart(copiedCart);18 };1920 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 });3233 const data = await response.json();3435 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 tab37 window.open(data.result.url, "_blank");38 return;39 }4041 throw data;42 } catch (error) {43 console.error(error);44 }45 };4647 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 <Image58 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 <button70 onClick={() => {71 console.log(item);72 removeFromCart(item);73 }}74 >75 Remove from cart76 </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 <button92 onClick={() => {93 handleCheckout();94 }}95 >96 {" "}97 CHECKOUT98 </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.js2import stripe from "stripe";3import { database } from "../../lib/database";45// Create a new stripe instance using your own secret key6const client = stripe(process.env.STRIPE_SECRET_KEY);78// Custom configuration for Next.js api route.9export const config = {10 api: {11 bodyParser: true,12 },13};1415export 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 cart20 const result = await handlePostRequest(req.body.items);2122 // Return a successful response with the stripe checkout result23 return res.status(200).json({ message: "Success", result });24 } catch (error) {25 console.log(error);26 // Return an unsuccessfull response with the error message27 return res.status(400).json({ message: "Error", error });28 }29 }3031 default: {32 return res.status(405).json({ message: "Method not allowed" });33 }34 }35}3637const 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 );4445 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 }5253 return groupedItems;54 }, []);5556 // Create a new Stripe payment checkout session57 const session = await client.checkout.sessions.create({58 // Stripe payment methods to accept59 payment_method_types: ["card"],60 // Map the line items to the checkout session61 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;6465 // Return a new line item66 return {67 price_data: {68 currency: "usd",69 product_data: {70 name: item.name,71 images: [item.image],72 },73 // Convert the price to cents74 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 });8384 // Add the checkout session to the database.85 await database.addNewOrder({86 session: session.id,87 items: lineItems,88 });8990 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";34export default async function handler(req, res) {5 switch (req.method) {6 case "POST": {7 try {8 const result = await handlePostRequest(req.body);910 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 }1617 default: {18 return res.status(405).json({ message: "Method not allowed" });19 }20 }21}2223const handlePostRequest = async (response) => {24 // Find the order that matches the checkout session id25 const order = await database.getOrder(response.data.object.id);2627 // Send email using nodemailer28 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";23// 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});1213// 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<html25 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 }4546 html {47 height: 100%;48 width: 100%;49 }5051 body {52 height: 100% !important;53 margin: 0 !important;54 padding: 0 !important;55 width: 100% !important;56 mso-line-height-rule: exactly;57 }5859 div[style*='margin: 16px 0'] {60 margin: 0 !important;61 }6263 table,64 td {65 mso-table-lspace: 0pt;66 mso-table-rspace: 0pt;67 }6869 img {70 border: 0;71 height: auto;72 line-height: 100%;73 outline: none;74 text-decoration: none;75 -ms-interpolation-mode: bicubic;76 }7778 .ReadMsgBody,79 .ExternalClass {80 width: 100%;81 }8283 .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 9104 ]><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 <div114 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.! ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌128 </div>129 <table130 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 <td142 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 <div154 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 <table162 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 <td172 class='container__cell'173 width='100%'174 align='left'175 valign='top'176 >177 <h1178 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 Successful187 </h1>188 <p189 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 following200 items201 </p>202 <ul203 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.items217 .map((itemGroup) => {218 const [item] = itemGroup;219220 return `<li221 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 <p230 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 <img243 src='${item.image}'244 alt='${item.name}'245 border='0'246 class='img__block'247 style='display: block; max-width: 100%'248 />249 <p250 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("")}265266 </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 <table273 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 <td284 class='hr__cell'285 width='100%'286 align='left'287 valign='top'288 style='border-top: 1px solid #9a9a9a'289 >290 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 <div309 style='310 display: none;311 white-space: nowrap;312 font-size: 15px;313 line-height: 0;314 '315 >316 317 318 319 </div>320 </body>321</html>322`,323 });324325 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_ADDRESS2MAIL_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 :