Build a Shopping Cart with Next.js 14 Server Actions

Creating a user-linked shopping cart using server actions with Vercel KV and Next-Auth.

Published on

We all know that making our web application have the cart functionality is more difficult than it seems and there are many ways to do it, but there is only one really fast and that allows us to save carts linked to users and that is what I am going to show you now.

We are going to create a user-linked shopping cart using server actions with Vercel KV and Next-Auth.

Check out this ecommerce where I implement this functionality in a complex project and see how fast it is.

GitHub - MarcosCamara01/ecommerce-template

I am looking for a part time job, if you are interested in working with me contact me!

The code for this article:

GitHub - MarcosCamara01/server-cart


0. Setting Up Project & Install Dependencies

Let’s begin by setting up your Next.js project. Follow these steps to create the folder structure:

Open your terminal and run the following command:

npx create-next-app@latest

When prompted, enter your project name and answer the following questions:

  • Would you like to use TypeScript? (Yes)
  • Would you like to use ESLint? (Yes)
  • Would you like to use Tailwind CSS? (Yes)
  • Would you like to use the src/ directory? (Yes)
  • Would you like to use App Router? (Yes, recommended)
  • Would you like to customize the default import alias (@/*)? (Yes)

Before we proceed, let’s install some dependencies for your application:

npm install @vercel/kv next-auth

1. User Authentication Setup

In this section we are going to use Next-Auth to link users with their shopping cart, I already have an article where I talk more in depth about how to use this library, but now we are going to allow only Google login.

Authentication in Next.js 14 Using NextAuth + MongoDB

1.1 Environment Variables Setup

We add the necessary environment variables for this section:

.env.local

GOOGLE_CLIENT_ID="YOUR_CLIENT_ID"
GOOGLE_CLIENT_SECRET="YOUR_SECRET_ID"
NEXTAUTH_SECRET="NEXTAUTH_SECRET"

If you have doubts about how to obtain Google Auth keys click here.

To get the Next-Auth secret key simply run this on your terminal and it will generate a random key for you.

npx auth secret

1.2 Implementing Authentication Logic

Next, we’ll set up the authentication logic by creating the required folders and files.

1.2.1 Setting Up NextAuth Configuration

Create a file named route.ts inside app/api/auth/[...nextauth] folder:

app/api/auth/[…nextauth]/route.ts

import NextAuth from "next-auth";
import type { NextApiRequest, NextApiResponse } from "next";
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";

const options: NextAuthOptions = {
  // Configure Google as the authentication provider
  providers: [
    GoogleProvider({
      // Set Google OAuth client ID obtained from environment variables
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      // Set Google OAuth client secret obtained from environment variables
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string
    }),
  ],
  // Customize authentication pages, such as the sign-in page
  pages: {
    signIn: "/login",
  },
};

// Define handler function to process authentication requests
const handler = (req: NextApiRequest, res: NextApiResponse) => NextAuth(req, res, options);

// Export handler function for both GET and POST requests
export { handler as GET, handler as POST };
1.2.2 Creating Login Page

Inside the app/login folder, create a file named page.tsx:

app/login/page.tsx

import React from 'react';
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/libs/auth";
import { Session } from "next-auth";
import { redirect } from 'next/navigation';
import Signin from '@/components/Signin';

const Login = async () => {
  // Fetches the user's session information from the server
  const session: Session | null = await getServerSession(authOptions);

  // Redirects the user to the add-to-cart page if already logged in, otherwise displays the Signin component
  if (session) {
    redirect('/add-to-cart');
  } else {
    return (
      <Signin />
    )
  }
}

export default Login;

Create a component named Signin inside src/componentsfolder:

_src/components/auth/_Signin.tsx

"use client"; // client component

import React, { useEffect } from "react";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useSession } from 'next-auth/react';

const Signin = () => {
  // Using the useSession hook to retrieve the user session information
  const { data: session } = useSession();

  // useEffect hook to reload the page when a user session is detected
  useEffect(() => {
    if (session?.user) {
      window.location.reload();
    }
  }, [session]);

  return (
    <section className="flex items-center justify-center w-full h-screen px-4">
      <form
        className="p-6 xs:p-10 w-full max-w-[350px] flex flex-col justify-between items-center gap-2.5 bg-white rounded text-black"
      >
        <h1 className="w-full my-5 text-2xl font-bold">Welcome back</h1>

        {/* Button to sign in with Google */}
        <button
          className="w-full h-10 justify-center flex py-1.5 px-4 text-sm align-middle items-center rounded text-999 bg-[#F4F4F5] transition duration-150 ease hover:bg-gray-200 gap-3"
          onClick={(e) => {
            e.preventDefault();
            signIn("google"); // Calls the signIn function with "google" provider
          }}>
          <svg
            data-testid="geist-icon"
            height="24"
            strokeLinejoin="round"
            viewBox="0 0 16 16"
            width="24"
            style={{ color: 'currentColor' }}
          >
            <path
              d="M8.15991 6.54543V9.64362H12.4654C12.2763 10.64 11.709 11.4837 10.8581 12.0509L13.4544 14.0655C14.9671 12.6692 15.8399 10.6182 15.8399 8.18188C15.8399 7.61461 15.789 7.06911 15.6944 6.54552L8.15991 6.54543Z"
              fill="#4285F4"
            ></path>
            <path
              d="M3.6764 9.52268L3.09083 9.97093L1.01807 11.5855C2.33443 14.1963 5.03241 16 8.15966 16C10.3196 16 12.1305 15.2873 13.4542 14.0655L10.8578 12.0509C10.1451 12.5309 9.23598 12.8219 8.15966 12.8219C6.07967 12.8219 4.31245 11.4182 3.67967 9.5273L3.6764 9.52268Z"
              fill="#34A853"
            ></path>
            <path
              d="M1.01803 4.41455C0.472607 5.49087 0.159912 6.70543 0.159912 7.99995C0.159912 9.29447 0.472607 10.509 1.01803 11.5854C1.01803 11.5926 3.6799 9.51991 3.6799 9.51991C3.5199 9.03991 3.42532 8.53085 3.42532 7.99987C3.42532 7.46889 3.5199 6.95983 3.6799 6.47983L1.01803 4.41455Z"
              fill="#FBBC05"
            ></path>
            <path
              d="M8.15982 3.18545C9.33802 3.18545 10.3853 3.59271 11.2216 4.37818L13.5125 2.0873C12.1234 0.792777 10.3199 0 8.15982 0C5.03257 0 2.33443 1.79636 1.01807 4.41455L3.67985 6.48001C4.31254 4.58908 6.07983 3.18545 8.15982 3.18545Z"
              fill="#EA4335"
            ></path>
          </svg>
          Sign in with Google
        </button>
        <Link href="/register" className="text-sm text-gray-500 transition duration-150 ease hover:text-black">
          Don&apos;t have an account?
        </Link>
      </form>
    </section>
  );
}

export default Signin;

In this section, we’ve set up the infrastructure for user authentication using NextAuth with Google login.

2. Integration of the User-Linked Shopping Cart

In this section, we’ll connect the shopping cart functionality with user authentication. Let’s start by configuring the necessary environment variables:

2.1 Environment Variables Setup

Create a .env.local file and add the following variables:

.env.local

KV_URL="YOUR_KV_URL"
KV_REST_API_URL="YOUR_KV_API_URL"
KV_REST_API_TOKEN="YOUR_API_TOKEN"
KV_REST_API_READ_ONLY_TOKEN="YOUR_READ_ONLY_TOKEN"

If you have doubts about how to obtain the environment variables, click here.

2.2.1 Cart Page Setup

Once we are done with the logic for authenticating users, let’s start with the Cart logic, we will create a path called add-to-cart and create a page.tsx file where we will put this unfinished code.

app/add-to-cart/page.tsx

import { getServerSession } from "next-auth/next";
import { authOptions } from "@/libs/auth";
import { Session } from "next-auth";
import { redirect } from "next/navigation";

// Defining the type for Product objects
export type Product = {
    id: number,
    name: string,
    price: number
}

// Array of products
export const products: Product[] = [
    {
        id: 1,
        name: "Americano",
        price: 40
    },
    {
        id: 2,
        name: "Expresso",
        price: 20
    },
    {
        id: 3,
        name: "Arabica",
        price: 10
    }
];

export default async function AddToCart() {
    // Getting the server session
    const session: Session | null = await getServerSession(authOptions);

    // Redirecting to login if session doesn't exist
    if (!session) {
        redirect('/login');
    }

    return (
        <main className="flex flex-col items-center min-h-screen p-24">

        </main>
    )
}

2.2.2 Shopping Cart Action Logic

Inside the add-to-cartfolder we will also create a file called action.ts, this is where all the shopping cart logic will be located.

app/add-to-cart/action.ts

'use server'

import { kv } from "@vercel/kv";
import { revalidatePath } from "next/cache";
import { products, type Product } from "./page";

// Defining the type for Cart objects
export type Cart = {
    userId: string;
    items: Array<{
        id: number,
        name: string,
        price: number,
        quantity: number
    }>
}

// Function to add an item to the cart
export async function addItem(userId: string, productId: number) {
    // Retrieving the cart based on the user ID
    let cart: Cart | null = await kv.get(`testcart-${userId}`);

    // Finding the selected product from the products array
    const selectedProduct: Product | undefined = products.find(product => product.id === productId);

    // Handling if the selected product is not found
    if (!selectedProduct) {
        console.error(`Product with id ${productId} not found.`);
        return;
    }

    // Creating a new cart object if the cart is empty or doesn't exist
    let myCart = {} as Cart;

    if (!cart || !cart.items) {
        myCart = {
            userId: userId,
            items: [
                {
                    ...selectedProduct,
                    quantity: 1
                }
            ]
        };
    } else {
        // Checking if the item is already in the cart
        let itemFound = false;

        // Updating the quantity of the existing item or adding a new item to the cart
        myCart.items = cart.items.map(item => {
            if (item.id === productId) {
                itemFound = true;
                item.quantity += 1;
            }
            return item;
        }) as Cart['items'];

        if (!itemFound) {
            console.log('Adding new item to the cart.');
            myCart.items.push({
                ...selectedProduct,
                quantity: 1,
            });
        }
    }

    // Logging the updated cart
    console.log('Updated Cart:', myCart);

    // Saving the updated cart to the KV storage
    await kv.set(`testcart-${userId}`, myCart);

    // Triggering revalidation of the '/add-to-cart' page
    revalidatePath('/add-to-cart');
}

// Function to delete an item from the cart
export async function delItem(userId: string, productId: number) {
    // Retrieving the cart based on the user ID
    let cart: Cart | null = await kv.get(`testcart-${userId}`);

    // Checking if the cart and its items exist
    if (cart && cart.items) {
        // Filtering out the item to be deleted from the cart
        const updatedCart = {
            userId: userId,
            items: cart.items.filter(item => item.id !== productId),
        };

        // Saving the updated cart to the KV storage
        await kv.set(`testcart-${userId}`, updatedCart);

        // Triggering revalidation of the '/add-to-cart' page
        revalidatePath('/add-to-cart');
    }
}

// Function to delete one quantity of an item from the cart
export async function delOneItem(userId: string, productId: number) {
    // Retrieving the cart based on the user ID
    let cart: Cart | null = await kv.get(`testcart-${userId}`);

    // Checking if the cart and its items exist
    if (cart && cart.items) {
        // Updating the quantity of the item or removing it if quantity becomes zero
        const updatedCart = {
            userId: userId,
            items: cart.items.map(item => {
                if (item.id === productId) {
                    if (item.quantity > 1) {
                        item.quantity -= 1;
                    } else {
                        return null;
                    }
                }
                return item;
            }).filter(Boolean) as Cart['items'],
        };

        // Saving the updated cart to the KV storage
        await kv.set(`testcart-${userId}`, updatedCart);

        // Triggering revalidation of the '/add-to-cart' page
        revalidatePath('/add-to-cart');
    }
}

2.3 Component Creation

Create two new components inside src/components folder ProductCard.tsx and CartItem.tsx.

src/components/ProductCard.tsx

'use client' // client component

import Image from "next/image";
import { formatNumber } from "@/utils/format";
import { useTransition } from "react";
import { addItem } from "@/app/add-to-cart/action";
import CoffeeImage from "../../public/coffe.jpg";

// Defining the type for props passed to ProductCard component
type ProductCartProps = {
    id: number,
    userId: string;
    name: string
    price: number,
}

// ProductCard component definition
export default function ProductCard({
    id, userId, name, price
}: ProductCartProps) {
    // Declaring state for pending transition
    let [isPending, startTransition] = useTransition()

    return (
        <div className="p-3 border rounded-xl border-slate-700">
            <div className="mb-2 bg-gray-300 rounded-md">
                <Image src={CoffeeImage} alt="coffee" width={180} height={180} className="w-[180px] h-[180px] rounded object-cover" />
            </div>
            <h2 className="text-slate-900">{name}</h2>
            <h2 className="font-semibold text-green-500">$ {formatNumber(price)}</h2>
            <button
                className="w-full px-2 h-[30px] mt-4 text-sm font-semibold text-center rounded-md bg-slate-100 text-slate-900"
                onClick={() => {
                    // Initiating a transition when the button is clicked
                    startTransition(() => addItem(userId, id));
                }}
            >
                {/* Conditional rendering based on the pending state */}
                {isPending ?
                    <div className="grid w-full overflow-x-scroll rounded-lg place-items-center lg:overflow-visible">
                        <svg className="text-gray-300 animate-spin" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"
                            width="18" height="18">
                            <path
                                d="M32 3C35.8083 3 39.5794 3.75011 43.0978 5.20749C46.6163 6.66488 49.8132 8.80101 52.5061 11.4939C55.199 14.1868 57.3351 17.3837 58.7925 20.9022C60.2499 24.4206 61 28.1917 61 32C61 35.8083 60.2499 39.5794 58.7925 43.0978C57.3351 46.6163 55.199 49.8132 52.5061 52.5061C49.8132 55.199 46.6163 57.3351 43.0978 58.7925C39.5794 60.2499 35.8083 61 32 61C28.1917 61 24.4206 60.2499 20.9022 58.7925C17.3837 57.3351 14.1868 55.199 11.4939 52.5061C8.801 49.8132 6.66487 46.6163 5.20749 43.0978C3.7501 39.5794 3 35.8083 3 32C3 28.1917 3.75011 24.4206 5.2075 20.9022C6.66489 17.3837 8.80101 14.1868 11.4939 11.4939C14.1868 8.80099 17.3838 6.66487 20.9022 5.20749C24.4206 3.7501 28.1917 3 32 3L32 3Z"
                                stroke="currentColor" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round"></path>
                            <path
                                d="M32 3C36.5778 3 41.0906 4.08374 45.1692 6.16256C49.2477 8.24138 52.7762 11.2562 55.466 14.9605C58.1558 18.6647 59.9304 22.9531 60.6448 27.4748C61.3591 31.9965 60.9928 36.6232 59.5759 40.9762"
                                stroke="currentColor" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" className="text-gray-900">
                            </path>
                        </svg>
                    </div>
                    : "Add To Cart"}
            </button>
        </div>
    )
}

src/components/CartItem.tsx

"use client" // client component

import { formatNumber } from "@/utils/format";
import { useTransition } from "react";
import { delItem, delOneItem } from "@/app/add-to-cart/action";

// Defining the type for props passed to CartItem component
type CartItemProps = {
    no: number,
    id: number,
    userId: string,
    name: string,
    quantity: number,
    price: number,
}

export default function CartItem({
    no, id, userId, name, quantity, price
}: CartItemProps) {
    // Declaring state for pending transition
    let [isPending, startTransition] = useTransition()

    return <div className="flex justify-between text-slate-900">
        <div className="w-[40%] flex gap-2 items-center">
            <span className="text-sm text-slate-600">{no}</span>
            <span>{name}</span>
        </div>
        <div className="w-[30%] text-center">{quantity}</div>
        <div className="w-[30%] text-right">{formatNumber(quantity * price)}</div>
        <button
            className="text-sm font-semibold hover:text-slate-600"
            onClick={() => {
                // Initiating a transition to delete all items of this type from the cart
                startTransition(() => delItem(userId, id));
            }}
        >
            Delete all items
        </button>
        <button
            className="text-sm font-semibold hover:text-slate-600"
            onClick={() => {
                // Initiating a transition to delete one item of this type from the cart
                startTransition(() => delOneItem(userId, id));
            }}
        >
            Delete one item
        </button>
    </div>
}

To finish our application we will complete our page.tsx file inside add-to-cart by adding these two new components to which we will pass the products of our application and the cart of the registered user.

import ProductCard from "@/components/ProductCard";
import CartItem from "@/components/CartItem";
import { type Cart } from "./action"; // Importing type for Cart
import { kv } from "@vercel/kv";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/libs/auth";
import { Session } from "next-auth";
import { redirect } from "next/navigation";

export type Product = {
    id: number,
    name: string,
    price: number
}

export const products: Product[] = [
    {
        id: 1,
        name: "Americano",
        price: 40
    },
    {
        id: 2,
        name: "Expresso",
        price: 20
    },
    {
        id: 3,
        name: "Arabica",
        price: 10
    }
];

export default async function AddToCart() {
    const session: Session | null = await getServerSession(authOptions);

    if (!session) {
        redirect('/login');
    }

    // Extracting user ID from session
    const userId = session.user._id;

    // Retrieving user's cart from KV storage
    const cart: Cart | null = await kv.get(`testcart-${userId}`);

    // Calculating total quantity of items in the cart
    const total = cart?.items.reduce((sum, item) => sum + item.quantity, 0) || 0;

    return (
        <main className="flex flex-col items-center min-h-screen p-24">
            {/* Section for displaying products */}
            <div className="w-full">
                <h1 className="mb-6 text-xl font-semibold text-left text-slate-900">Products: </h1>
                <div className="flex gap-6">
                    {/* Mapping through products array and rendering ProductCard component for each product */}
                    {products.map(product =>
                        <ProductCard key={product.id}
                            id={product.id}
                            userId={userId}
                            name={product.name}
                            price={product.price}
                        />
                    )}
                </div>
            </div>

            {/* Section for displaying cart */}
            <div className="w-full mt-6">
                <h1 className="text-xl font-semibold text-slate-900">Cart: </h1>
                <div className="flex flex-col gap-2 px-6 py-4 mt-2 border rounded-xl border-slate-700">
                    {/* Rendering CartItem component for each item in the cart */}
                    {cart?.items ? cart.items.map((item, index) =>
                        <CartItem key={item.id}
                            no={index + 1}
                            id={item.id}
                            userId={userId}
                            name={item.name}
                            price={item.price}
                            quantity={item.quantity}
                        />
                    ) :
                        <span className="text-sm text-slate-600">No Item</span>
                    }
                </div>

                {/* Section for displaying total quantity of items in the cart */}
                <div className="flex justify-between px-6 mt-4 font-semibold text-slate-900">
                    <div>Total</div>
                    <div>{total}</div>
                </div>
            </div>
        </main>
    )
}

End and conclusion

This is how to create a shopping cart linked to a user in Next.js 14 using Vercel KV and Next-Auth, it’s pretty easy, isn’t it?

Thank you very much for making it this far, if you found it useful please leave me a clap and a star on GitHub ❤.

GitHub - MarcosCamara01/server-cart

Enjoyed this article?

Share it with your network to help others discover it

Notify: Just send the damn email. All with one API call.

Continue Learning

Discover more articles on similar topics