Lab - Build a Basic Cart Workflow

Plan: Composable Developer

Lesson 20 of 27 · 60 min

Introduction

This exercise will expand on your basic Next.js storefront with the capability to add products to a cart and check out.

In this lab, you will:

  • Implement Add to Cart functionality on product detail pages
  • Add a mini-cart to the storefront header, displaying basic cart details
  • Create a simple cart page
  • Use a checkout redirect URL to navigate customers to checkout on BigCommerce

Prerequisites

This exercise builds on the basic Next.js application begun in previous labs and has the same technical requirements.

Your store should have the following catalog data associated with your headless channel:

  • Products without variant options or required modifier options

The simple cart workflow you’ll build in the exercise will not support products that require option selections.

Setup

This exercise continues where you left off previously in your basic Next.js project. Simply start the dev server from the project root if it’s not currently running:

pnpm run dev

If you need a fresh start, you can follow the instructions below to set up a new project complete with previous exercise code.

  1. In your terminal, run the following, replacing the path to the working directory with your own path:
corepack enable pnpm && pnpm dlx create-next-app@latest -e https://github.com/bigcommerce-edu/lab-nextjs-storefront/tree/bcu-lab-cart-start /path/to/working/directory
  1. Copy .env.example as .env.local and modify .env.local with configuration details for your store.
  2. Start the dev server process:
cd /path/to/working/directory
pnpm run dev
  1. Browse to the local URL printed in the pnpm output and verify your catalog pages load successfully.

Exercise Reference Copy

You may choose to download or clone the completed version of this lab in a separate location to serve as a reference for the final state.

Cart Lab Snapshot

Create the Add to Cart Button

The objective of this section is to enable customers to add a product to a cart from the product detail page. This will require not one but two GraphQL mutations (for creating a new cart or adding an item to an existing one), as well as tracking the user’s cart ID using a cookie.

We’ll start with the button itself.

  1. Open the file components/product/add-to-cart.tsx. and update the component as follows.
const AddToCart = ({
product,
}: {
product: Product,
}) => {
const [loading, setLoading] = useState(false);
const onClick = async () => {
}
return (
<div>
<button disabled={loading}
className="p-2 rounded-md text-lg w-44 cursor-pointer bg-neutral-700 text-white hover:bg-neutral-500 disabled:bg-neutral-500"
onClick={onClick}>
{loading ? (
<span>...</span>
) : (
<span>
Add to Cart
</span>
)}
</button>
</div>
);
};

Note that the new product prop accepted by the component is expected to match the interface previously defined.

A very simple “loading” state in the component will give the user feedback while the request executes.

Finally, notice the directive 'use client' located at the top of the file. Because this component involves interactivity (responding to a button click), this directive declares it as a client component, with the JSX and logic being executed in the browser.

  1. In the same file, fill in the logic for onClick, which will set the loading state, call the appropriate function, then un-set the loading state.
...
const onClick = async () => {
setLoading(true);
const res = await addProductToCart({
productId: product.entityId,
});
setLoading(false);
}
...

Despite being browser-executed JavaScript, this event handler calls the function addProductToCart directly to execute the proper GraphQL request.

In the file where this function is defined - components/product/_actions/add-product-to-cart.ts - the directive 'use server' declares it as a Server Function. When executed by the browser, the result is actually a network request, with addProductToCart being run at the server. This is a convenient pattern for running server-side logic from the client without the need for manually creating API routes.

We’re not filling in the logic for addProductToCart yet, but it currently doesn’t accept the productId param being passed to it.

  1. Open the file components/product/_actions/add-product-to-cart.ts and modify the function as follows.
export const addProductToCart = async ({
productId,
quantity=1,
}: {
productId: number,
quantity?: number,
}) => {
...
};
  1. In app/product/[...productPath]/page.tsx, add the appropriate component placement.
export default async function ProductPage({
...
}: {
...
}) {
...
return (
<>
<PageHeading>{product.name}</PageHeading>
<div className="w-full ...">
<div className="w-1/2">
...
</div>
<div className="w-1/2 p-4">
...
<div className="text-lg my-4">
<label ...>Price:</label>
...
</div>
<AddToCart product={product} />
</div>
</div>
</>
);
}
  1. Browse to a product page and verify that the “Add to Cart” button displays. You can interact with the button and initiate a successful request, but this will currently have no effect.

Example Code

Implement Add to Cart Logic

  1. Open the file types/cart.ts and fill in the definitions of BasicCart and CartFragment.
export interface BasicCart {
entityId: string;
currencyCode: string;
amount: {
value: number;
};
}
export const CartFragment = `
fragment cartFields on Cart {
entityId
currencyCode
amount {
value
}
lineItems {
totalQuantity
}
}
`;

In this case, you’ve included a GraphQL fragment because this fragment will be needed by mutations in two separate files.

  1. Open the file components/product/_actions/add-product-to-cart.ts and add the GraphQL and vars/response types for creating a new cart.
import ...
const createCartQuery = `
mutation CreateCart(
$productId: Int!,
$quantity: Int!
) {
cart {
createCart(
input: {
lineItems: [
{
quantity: $quantity,
productEntityId: $productId
}
]
}
) {
cart {
...cartFields
}
}
}
}
${CartFragment}
`;
interface CreateCartVars {
productId: number;
quantity: number;
}
interface CreateCartResp {
data: {
cart: {
createCart: {
cart: BasicCart & {
lineItems: {
totalQuantity: number;
}
}
}
}
}
}
/**
* Add item to new or existing cart
*/
export const addProductToCart = async ({
...
  1. In the same file, add the createCart function to perform the mutation and return the resulting cart data.
...
const createCart = async ({
productId,
quantity,
}: {
productId: number,
quantity: number,
}) => {
const cartResp = await bcGqlFetch<CreateCartResp, CreateCartVars>(
createCartQuery,
{
productId,
quantity,
}
);
const cart = cartResp.data.cart.createCart.cart;
return {
...cart,
totalQuantity: cart.lineItems.totalQuantity,
};
};
/**
* Add item to new or existing cart
*/
export const addProductToCart = async ({
...

The createCart function will be appropriate for when the user has no pre-existing cart. You’ll need a separate function, similar in most respects, for a different mutation when a cart exists.

  1. In the same file, add the mutation and type definitions.
const createCart = async ({
...
}) => {
...
};
const addCartLineItemQuery = `
mutation AddCartLineItem(
$cartId: String!,
$productId: Int!,
$quantity: Int!
) {
cart {
addCartLineItems(
input: {
cartEntityId: $cartId,
data: {
lineItems: [
{
quantity: $quantity,
productEntityId: $productId
}
]
}
}
) {
cart {
...cartFields
}
}
}
}
${CartFragment}
`;
interface AddCartLineItemVars {
cartId: string;
productId: number;
quantity: number;
}
interface AddCartLineItemResp {
data: {
cart: {
addCartLineItems: {
cart: BasicCart & {
lineItems: {
totalQuantity: number;
}
}
}
}
}
}
/**
* Add item to new or existing cart
*/
export const addProductToCart = async ({
...

You can see that the structure is nearly identical to that of createCart. The chief distinction is the inclusion of a cartId argument for the mutation.

  1. In the same file, add addCartLineItem to perform the mutation.
...
const addCartLineItem = async ({
cartId,
productId,
quantity
}: {
cartId: string,
productId: number,
quantity: number,
}) => {
const cartResp = await bcGqlFetch<AddCartLineItemResp, AddCartLineItemVars>(
addCartLineItemQuery,
{
cartId,
productId,
quantity,
}
);
const cart = cartResp.data.cart.addCartLineItems.cart;
return {
...cart,
totalQuantity: cart.lineItems.totalQuantity,
};
}
/**
* Add item to new or existing cart
*/
export const addProductToCart = async ({
...

Finally, you’ll need the existing addProductToCart function to act as the main controller for this operation, deciding on whether to create a cart or add to an existing one.

  1. In the same file, update the addProductToCart function as follows.
export const addProductToCart = async ({
...
}: {
...
}) => {
const cookieStore = await cookies();
const secure = await isSecure();
const cookieName = getCookieName({ name: "cartId", secure });
const cartId = cookieStore.get(cookieName)?.value;
let cart = null;
if (cartId) {
try {
cart = await addCartLineItem({ cartId, productId, quantity });
} catch (err) {
// Existing cart ID might not have matched a cart
}
}
if (!cart) {
try {
cart = await createCart({ productId, quantity });
} catch (err) {
console.log(err);
}
}
if (cart) {
cookieStore.set({
name: cookieName,
value: cart.entityId,
httpOnly: true,
secure,
});
}
return cart;
};

The above code is doing several important things:

  • A couple of helper functions defined in lib/cookies.ts are being used to appropriately prefix the cookie name based on whether HTTPS is being used.
  • The addCartLineItem mutation is tried if the user has a “cartId” cookie.
  • If no cartId cookie was set or the attempt to add an item failed, the createCart mutation is used to generate a new cart with the item.
  • The ID returned in the cart data is set on the cartId cookie.
  • The basic cart data is returned from the function
  1. Browse to the detail page of a product without required options and test the Add to Cart button. While you’ll see no change in the storefront yet to indicate your new cart, you should be able to use your browser’s developer tools to verify the value of your cartId cookie.

Example Code

Display Cart in the Header

The storefront not only displays no status update indicating an item has been added to the cart, but also provides no navigation to the cart itself. Let’s implement a mini-cart in the site header to meet both objectives.

The mini-cart will require fetching data matching the user’s cart ID using another GraphQL query, and this data will be required on every page of the storefront. While you might consider including this cart data in the same fetch populating the rest of the header, these details are both user-specific and non-essential for the main content on any given page. Decoupling this into its own component will give you more flexibility in the future for strategies such as caching or partial prerendering.

  1. Open the file components/mini-cart/index.tsx and update the component as follows.
const MiniCart = async () => {
const cookieStore = await cookies();
const secure = await isSecure();
const cookieName = getCookieName({ name: "cartId", secure });
const cartId = cookieStore.get(cookieName)?.value;
let cart;
if (cartId) {
try {
cart = await getCart({ cartId });
} catch (err) {
cart = null;
cookieStore.delete(cookieName);
}
} else {
cart = null;
}
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: cart?.currencyCode ?? 'USD',
});
return (
<div>
<Link href="/cart"><Cart className="w-6 h-6 inline-block" /></Link>
{cart && (
<span>({cart.totalQuantity}) - {currencyFormatter.format(cart.amount.value)}</span>
)}
</div>
);
};

The component does the work of checking for a cart ID from the appropriate cookie and passing this to an appropriate data fetching function.

  1. In components/header/index.tsx, add the MiniCart component.
const Header = async () => {
...
return (
<header ...>
<div ...>
<h1>
...
</h1>
<div className="flex">
<MiniCart />
</div>
</div>
...
</header>
)
}

The new mini-cart will display a simple cart icon if there is no current cart; the total quantity and grand total will be added to this if cart data is loaded.

Now let’s set up the data fetching.

  1. Open the file components/mini-cart/_data/component-data.ts. The logic for the getCart query will look similar to that of the createCart and addCartLineItem mutations, just without the need for any arguments. Add the GraphQL query and type definitions.
import ...
const getCartQuery = `
query GetCart($cartId: String!) {
site {
cart(entityId: $cartId) {
...cartFields
}
}
}
${CartFragment}
`;
interface GetCartVars {
cartId: string;
}
interface GetCartResp {
data: {
site: {
cart: BasicCart & {
lineItems: {
totalQuantity: number;
}
}
}
}
}
/**
* Get basic details of a cart for header
*/
export const getCart = async ({
...
  1. Update the getCart function to perform the query.
export const getCart = async ({
cartId,
}: {
cartId: string,
}) => {
const cartResp = await bcGqlFetch<GetCartResp, GetCartVars>(
getCartQuery,
{
cartId
}
);
const cart = cartResp.data.site.cart;
if (!cart) {
throw new Error("Cart not found");
}
return {
...cart,
totalQuantity: cart.lineItems.totalQuantity,
};
};
  1. Refresh your current page. If you previously added a product to your cart, you should see the header mini-cart information reflect your item total and cart subtotal.
  2. Browse to a product page for a product with no required options and add it to the cart. Observe that the mini-cart information will be updated accordingly after the operation is complete.

Example Code

Implement the Cart Details Query

The header mini-cart already links to the path /cart, which is currently a blank page.

The information to be displayed on the cart page includes more details than in the header - namely, the cart line items. These further details are handled with a separate query.

  1. In types/cart.ts, fill in the details of the CartItem and BasicCartDetails interfaces.
...
export interface CartItem {
entityId: string;
productEntityId: number;
sku?: string;
name: string;
imageUrl?: string;
quantity: number;
salePrice: {
value: number;
};
extendedSalePrice: {
value: number;
};
}
export interface BasicCartDetails extends BasicCart {
baseAmount: {
value: number
};
}
...
  1. Open the file app/cart/page-data.ts and add the query and type definitions.
import ...
const cartItemFields = `
entityId
productEntityId
sku
name
imageUrl
quantity
salePrice {
value
}
extendedSalePrice {
value
}
`;
const getCartsDetailsQuery = `
query GetCart($cartId: String!) {
site {
cart(entityId: $cartId) {
entityId
currencyCode
amount {
value
}
baseAmount {
value
}
lineItems {
totalQuantity,
physicalItems {
...PhysicalItemFields
}
digitalItems {
...DigitalItemFields
}
}
}
}
}
fragment PhysicalItemFields on CartPhysicalItem {
${cartItemFields}
}
fragment DigitalItemFields on CartDigitalItem {
${cartItemFields}
}
`;
interface GetCartDetailsVars {
cartId: string;
}
interface GetCartDetailsResp {
data: {
site: {
cart: BasicCartDetails & {
lineItems: {
totalQuantity: number;
physicalItems: CartItem[];
digitalItems: CartItem[];
}
}
}
}
}
/**
* Fetch details of a cart
*/
export const getCartDetails = async ({
...

Note that separate GraphQL types must be queried for physicalItems and digitalItems, but the same fields are queried for both.

  1. In the same file, modify the getCartDetails function to perform the query.
export const getCartDetails = async ({
cartId,
}: {
cartId: string,
}) => {
const cartResp = await bcGqlFetch<GetCartDetailsResp, GetCartDetailsVars>(
getCartsDetailsQuery,
{
cartId,
}
);
const cart = cartResp.data.site.cart;
if (!cart) {
throw new Error("Cart not found");
}
return {
...cart,
totalQuantity: cart.lineItems.totalQuantity,
lineItems: cart.lineItems.physicalItems.concat(cart.lineItems.digitalItems),
};
};
  1. Open the file app/cart/page.tsx, where we’ll start with the same initial logging step we’ve done for previous pages. Modify the page component to fetch and log the cart details.
export default async function CartPage() {
const cookieStore = await cookies();
const secure = await isSecure();
const cookieName = getCookieName({ name: "cartId", secure });
const cartId = cookieStore.get(cookieName)?.value;
let cart;
try {
cart = (cartId) ? await getCartDetails({ cartId }) : null;
} catch (err) {
cart = null;
}
console.log(cart);
return ...
}
  1. With at least one product added to the cart, browse to the cart page by clicking the icon in the site header and verify that the expected cart details are written to the terminal.

Example Code

Build the Cart Page

  1. Open the file components/cart/item-row.tsx. This component will be used to display an individual line item on the cart page. Update the component definition as follows.
const CartItemRow = ({
item,
currencyCode,
}: {
item: CartItem,
currencyCode: string,
}) => {
const thumbnailSize = 100;
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode ?? 'USD',
});
return (
<tr className="border-t border-neutral-300">
<td className="w-1/2 p-8">
{item.imageUrl && (
<Image src={item.imageUrl}
alt={item.name}
width={thumbnailSize} height={thumbnailSize / 2}
className="max-w-2xl max-w-full block" />
)}
<p className="font-bold">{item.name}</p>
{item.sku && (
<div className="text-sm">
<label className="font-bold">SKU:</label> {item.sku}
</div>
)}
<p>{currencyFormatter.format(item.salePrice.value)}</p>
</td>
<td className="p-8">
<label className="font-bold">Qty:</label>
<span> {item.quantity}</span>
</td>
<td className="p-8 text-right">
{currencyFormatter.format(item.extendedSalePrice.value)}
</td>
</tr>
);
};
  1. Open the file app/cart/page.tsx and update the component logic to render a simple “no items” message if no cart was loaded. We’ll also set up the currency formatter that will be needed for displaying the cart total. (Don’t forget to remove the previous logging statement.)
export default async function CartPage() {
...
let cart;
try {
...
} catch (err) {
...
}
if (!cart) {
return (
<>
<PageHeading>Cart</PageHeading>
<div className="w-1/2 text-center">There are no items in the cart.</div>
</>
)
}
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: cart?.currencyCode ?? 'USD',
});
return ...
}
  1. Update the CartPage component with the appropriate content.
export default async function CartPage() {
...
return (
<>
<PageHeading>Cart</PageHeading>
<div className="w-full flex justify-center">
<table className="w-2/3">
<tbody>
{cart.lineItems.map(item => (
<CartItemRow key={item.entityId} item={item} currencyCode={cart.currencyCode} />
))}
</tbody>
<tfoot>
<tr>
<th className="px-8 py-4 text-right" colSpan={2}>Subtotal</th>
<td className="px-8 py-4 text-right">{currencyFormatter.format(cart.baseAmount.value)}</td>
</tr>
<tr>
<th className="px-8 py-4 text-right" colSpan={2}>Grand Total</th>
<td className="px-8 py-4 text-right">{currencyFormatter.format(cart.amount.value)}</td>
</tr>
</tfoot>
</table>
</div>
</>
);
}
  1. Refresh the cart page and verify that your cart details are displayed.

Example Code

Redirect to Checkout

For completion of checkout, your storefront will redirect users to the BigCommerce storefront using the checkout URL set on your Channel Site. (Unless you have updated it, this will be the URL of your store’s default channel.)

  1. Open the file app/cart/page-data.ts and add the query and type definitions.
...
const createCartRedirectQuery = `
mutation GetCartRedirectUrls(
$cartId: String!
) {
cart {
createCartRedirectUrls(
input: {
cartEntityId: $cartId
}
) {
redirectUrls {
redirectedCheckoutUrl
}
}
}
}
`;
interface CreateCartRedirectVars {
cartId: string;
}
interface CreateCartRedirectResp {
data: {
cart: {
createCartRedirectUrls: {
redirectUrls: {
redirectedCheckoutUrl: string;
}
}
}
}
}
/**
* Generate a redirect URL for a cart
*/
export const createCartRedirect = async ({
...
  1. Update the definition of createCartRedirect.
export const createCartRedirect = async ({
cartId,
}:{
cartId: string,
}) => {
const cartRedirectResp = await bcGqlFetch<CreateCartRedirectResp, CreateCartRedirectVars>(
createCartRedirectQuery,
{
cartId,
}
);
const url = cartRedirectResp.data.cart.createCartRedirectUrls.redirectUrls.redirectedCheckoutUrl;
if (!url) {
throw new Error("Creating cart redirect URL failed");
}
return url;
};
  1. In app/cart/page.tsx, update the component logic to fetch and return the checkout redirect URL.
export default async function CartPage() {
...
const checkoutRedirectUrl = (cartId) ? await createCartRedirect({ cartId }) : '';
return ..
}
  1. In the same file, update the CartPage component to add a “Proceed to Checkout” link in the JSX.
export default async function CartPage() {
...
return (
<>
<PageHeading>Cart</PageHeading>
<div ...>
<table ...>
<tbody>
...
</tbody>
<tfoot>
<tr>
...
</tr>
<tr>
<td></td>
<td className="p-8 text-right" colSpan={2}>
{checkoutRedirectUrl && (
<a className="p-2 rounded-md text-lg px-4 font-normal cursor-pointer
bg-neutral-700 text-white hover:bg-neutral-500 disabled:bg-neutral-500
hover:no-underline"
href={checkoutRedirectUrl}>
Proceed to Checkout
</a>
)}
</td>
</tr>
</tfoot>
</table>
</div>
</>
);
}
  1. Browse to your cart page with products in the cart, and test out the new “Proceed to Cart” link. You should be redirected to the BigCommerce storefront with your cart items populated.

Example Code

The Effects of the Checkout Base URL

The base URL used to generate the cart redirect URLs depends on the checkout URL set on the Channel Site. In production, it is important to make sure you have correctly configured this URL for the channel associated with your headless storefront using the Update a Channel Site API endpoint, or you will not get correct redirect URLs.

Remember that, for these lab exercises, we have left the default checkout URL in place, meaning the shopper arrives at the store’s default Stencil storefront for checkout.

The configured checkout URL will affect the experience of getting back to the main storefront. You may notice that after arriving on the order confirmation page in your sandbox environment, the links on the page (such as the main store logo) navigate to your default Stencil storefront.

In production with a single-channel setup and properly configured domains, all navigation links on the order confirmation page should properly take the customer back to the headless storefront. As a reminder, you should update both the primary and checkout URLs on the Channel Site for this behavior:

  • primary: Set this to the domain where your headless storefront is deployed (for example, mystore.com). With this in place, the BigCommerce hosted storefront will know what base URL to use for the links generated on the order confirmation page.
  • checkout: A common strategy is to use a subdomain (for example, checkout.mystore.com) and configure DNS for this subdomain to point to your headless channel’s canonical BigCommerce URL.

If you do legitimately need to redirect shoppers to a different channel for checkout in production, then additional customization will be necessary to direct them back to the proper storefront after placing an order.

Full Lab Code

Full Lab Code

Taking It Further

This cart implementation is a good demonstration of the essential workflows involved, but to be truly complete, a few things are still missing. Try the following on your own if you like.

  • Add the ability to update item quantities and delete items from the cart page. This will require new server functions for the right mutations and interaction in the CartItemRow component.
  • Implement support for product options. Add *productOptions *to the main product query, then build a form with the appropriate controls on the product detail page. (The control required for a given option will depend on its GraphQL __typename in the returned data.) Capture selected options and translate them into the appropriate arguments in the Add to Cart mutations.