Building A Store with Next.js + Shopify - PART 6: Create & Update Cart with Product

Welcome to the last section of this tutorial.

Congratulations on making it thus far.

In this section you are going to create a cart for the user/customer to add products to and a checkout button.

This is another important part of an online store and when done well, can improve conversion.

In this section, you are going to write 4 queries.

It’s a lot. I know.

The 4 queries are:

  1. For creating a cart when not created already, and adding items.

  2. For updating a cart with items, when already created.

  3. For editing or updating the buyer’s information/identity.

  4. Fetching all the information about a cart, its items and buyers’ identity for the cart page.

Before you get started with writing queries.

Let’s talk about the plan here. Yes, I love planning

Since the user isn’t logged in or registered, you need a way to temporally store the cart ID. Yes, it should be unique for each user.

This way when the user visits the site or refreshes the page their cart doesn’t disappear.

There is a remedy.

Cookies!!

Also, you will only create a cart when the user tries to add an item to the cart. Not before!

So that you don’t get flooded with carts when google (or other search bots) crawl your store.

So, the first query.

The query to create a cart when there is no cartID stored in the browser's cookie and update a cart when cartID is found.

To create a cart in Shopify, you are going to be doing what is known in GraphQL as a mutation. This is creating something new in your database as opposed to reading information from the database. Which is what we have been doing all this while.

So, technically you can’t call the createCart query a query, but a mutation.

To write this mutation. You require some input. Which is what you intend to add / store in the database.

It’s called the CartInput.

This is what the CartInput type in Shopify GraphQL API looks like:

"input": {
    "attributes": [
      {
        "key": "",
        "value": ""
      }
    ],
    "buyerIdentity": {
      "countryCode": "",
      "customerAccessToken": "",
      "deliveryAddressPreferences": [
        {
          "customerAddressId": "",
          "deliveryAddress": {
            "address1": "",
            "address2": "",
            "city": "",
            "company": "",
            "country": "",
            "firstName": "",
            "lastName": "",
            "phone": "",
            "province": "",
            "zip": ""
          }
        }
      ],
      "email": "",
      "phone": "",
      "walletPreferences": [
        ""
      ]
    },
    "discountCodes": [
      ""
    ],
    "lines": [
      {
        "attributes": [
          {
            "key": "",
            "value": ""
          }
        ],
        "merchandiseId": "",
        "quantity": 1,
        "sellingPlanId": ""
      }
    ],
    "metafields": [
      {
        "key": "",
        "type": "",
        "value": ""
      }
    ],
    "note": ""
  }
}

Remember the user isn’t creating an account with your store so they wouldn’t set their information before adding items to their cart. They can only edit or set up their information when they add an item to the cart and a cart is created.

Because the user will add items to the cart before they can edit their information. And we need the customers information to create the cart. We have to use mock customer information while creating the cart for any customer/user.

I have prepared a mock input for creating the cart.

Here is the CartInput for your cart:

input: {
      lines: [{
        merchandiseId: id,
        quantity,
        attributes,
      }, ...],
      buyerIdentity: {
        email: 'exampler@example.com',
        countryCode: 'USD',
        deliveryAddressPreferences: {
          deliveryAddress: {
            address1: 'No Example Street',
            address2: '8th Example Floor',
            city: 'San Francisco',
            province: 'California',
            country: 'USD',
            zip: '10014',
          },
        },
      },
      attributes: {
        key: 'cart_attribute',
        value: 'This is a cart attribute',
      },
    }

The lines field holds the items the customer tried to add to the cart. Which is what triggers a cart creation in the first place.

This is what is generated when a customer tries to add an item to their cart for the first time. Below is the function that generates it.

Append this code to your <project>/app/api/utils.ts file.

/**
 * Generates an input object from a list of products.
 * @param lines The list of products id and quantity choosen by customer.
 * @returns An input object that is passed to query to create a cart.
 */
export function generateCreateCartInput(lines: Merchandise[]) {
  return {
    input: {
      lines: generateCartLinesInput(lines),
      buyerIdentity: {
        email: 'exampler@example.com',
        countryCode: 'NG',
        deliveryAddressPreferences: {
          deliveryAddress: {
            address1: 'No Example Street',
            address2: '8th Example Floor',
            city: 'Enugu',
            province: 'South-east',
            country: 'NG',
            zip: '41001',
          },
        },
      },
      attributes: {
        key: 'cart_attribute',
        value: 'This is a cart attribute',
      },
    },
  }
}

/**
 * Generates a list of products that can be passed as parameter to query.
 * @param lines The list of products id and quantity choosen by customer
 * @returns A list of products(merchandise) that can be passed to query
 */
export function generateCartLinesInput(lines: Merchandise[]) {
  return lines.map(({ id, quantity, attributes }) => ({
    merchandiseId: id,
    quantity,
    attributes,
  }))
}

Notice the type for lines in the above code is Merchandise. Here is the type definition.

Append this code to you <project>/app/api/types.ts file

export type Merchandise = {
  quantity: number
  id: string
  attributes: {
    key: string
    value: string
  }[]
}

Now you need a query to create a cart whenever the user adds items to their cart.

Paste this code in your app/api/query.ts file.

export const CREATE_CART = `
mutation ($input: CartInput) {
  cartCreate(input: $input) {
    cart {
      id
      lines(first: 10) {
        nodes {
          id
          quantity
          merchandise {
            ... on ProductVariant {
              id
            }
          }
          attributes {
            key
            value
          }
        }
      }
      cost {
        totalAmount {
          amount
          currencyCode
        }
        subtotalAmount {
          amount
          currencyCode
        }
        totalTaxAmount {
          amount
          currencyCode
        }
        totalDutyAmount {
          amount
          currencyCode
        }
      }
    }
  }
}
`

We also need a cart cleaner function for the return value. As usual.

But to do this. You need to tell typescript what the query result would look like. That means we need a type for that query result.

Append this code to your <project>/app/api/types.ts file.

export interface Cost {
  totalAmount: {
    amount: number
    currencyCode: string
  }
  subtotalAmount: {
    amount: number
    currencyCode: string
  }
  totalTaxAmount: {
    amount: number
    currencyCode: string
  }
}

export interface MiniCartQueryResult {
  id: string
  lines: {
    nodes: CartLine[]
  }
  cost: Cost
}

export type CartLine = {
  id: string
  quantity: number
  merchandise: {
    id: string
  }
  attributes: {
    key: string
    value: string
  }[]
}

Now we can write the cleaner function.

Paste this code in your <project>/app/api/util.ts file.

/**
 * Converts cart query result to a cleaner format.
 * @param queryResult A result gotten from querying for mini cart
 * @returns A cleaner format of cart that can be used by components
 */
export function cleanMiniCartResult(queryResult: MiniCartQueryResult) {
  const { id, lines, cost } = queryResult
  const cartLines = lines.nodes.map((node) => cleanCartLinesResult(node))

  return {
    id,
    cartLines,
    cost: {
      totalAmount: cost.totalAmount?.amount,
      subtotalAmount: cost.subtotalAmount?.amount,
      totalTaxAmount: cost.totalTaxAmount?.amount,
    },
  }
}

/**
 * Converts lines query results to a cleaner format.
 * @param line List of merchandise gotten from querying for cart
 * @returns A cleaner format that can be used by components
 */
export function cleanCartLinesResult(line: CartLine) {
  const { id, quantity, merchandise, attributes } = line

  return {
    id,
    quantity,
    merchandiseId: merchandise.id,
    attributes,
  }
}

The ID returned is then stored in the cookie with an expiry date of 3 weeks.

I choose 3 weeks because, it gives the customer enough time to decide whether to go through with the purchase or not.

But you will get to that in a bit.

For now, you need to write the ADD_ITEMS_TO_CART query. This query’s only job is to add items to an already existing cart.

Append this code to your <project>/app/api/query.ts file.

export const ADD_ITEMS_TO_CART = `
mutation ($cartId: ID!, $lines: [CartLineInput!]!) {
  cartLinesAdd(
    cartId: $cartId
    lines: $lines
  ) {
    cart {
      id
      lines(first: 10) {
        nodes {
          id
          quantity
          merchandise {
            ... on ProductVariant {
              id
            }
          }
          attributes {
            key
            value
          }
        }
      }
      cost {
        totalAmount {
          amount
          currencyCode
        }
        subtotalAmount {
          amount
          currencyCode
        }
        totalTaxAmount {
          amount
          currencyCode
        }
        totalDutyAmount {
          amount
          currencyCode
        }
      }
    }
  }
}
`

The return values needed from this query are also the id and the lines (the items in the cart).

The cleaner function for this query is also the same as that of the one for creating the cart from above. So, no need to write another one for it.

With that, we have written the major part of the cart. We haven’t designed anything in the front end yet. Before we do that let’s write API routes for fetching cart with our queries and mutations.

Here is the route code for creating and updating a cart.

Paste this code in your <project>/app/api/cart/route.ts file.

import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'
import { CREATE_CART, ADD_ITEMS_TO_CART } from '../query'
import {
  generateCartLinesInput,
  cleanMiniCartResult,
  generateCreateCartInput,
} from '../utils'

// Create cart
export async function POST(Request: NextRequest) {
  const { lines } = await Request.json()
  const { input } = generateCreateCartInput(lines)

  const { status, body } = await shopifyFetch({
    query: CREATE_CART,
    variables: { input },
  })

  if (status === 200) {
    const cart = cleanMiniCartResult(body.data?.cartCreate?.cart)
    return Response.json({ status: 200, body: cart })
  } else {
    return Response.json({ status: 500, message: 'Error receiving data' })
  }
}

// Update cart with lines
export async function PUT(Request: NextRequest) {
  const searchParams = Request.nextUrl.searchParams
  const cartId = searchParams.get('cartId')
  const { lines } = await Request.json()

  const variables = { cartId, lines: generateCartLinesInput(lines) }

  const { status, body } = await shopifyFetch({
    query: ADD_ITEMS_TO_CART,
    variables,
  })

  if (status === 200) {
    const cart = cleanMiniCartResult(body.data?.cartLinesAdd?.cart)
    return Response.json({ status: 200, body: cart })
  } else {
    return Response.json({ status: 500, message: 'Error receiving data' })
  }
}

You are done with the server for now.

Head back to the product page.

Install js-cookie library for storing cartID in the customers browser cookie.

$ npm install js-cookie

Now create the useCart hook to handle the functions related to handling cart.

Paste this code in your <project>/app/hooks/usecart.tsx file.

import cookies from 'js-cookie'
import { useState } from 'react'

interface Merchandise {
  quantity: number
  id: string
  attributes: {
    key: string
    value: string
  }[]
}

export default function useCart() {
  const [adding, setAdding] = useState<boolean>(false)
  const [cartId, setCartId] = useState<string | null>(
    cookies.get('cart_id') ?? null
  )

  const updateCart = (newMerchandise: Merchandise) => {
    if (cartId && cartId !== 'undefined') loadCart('PUT', newMerchandise)
    else loadCart('POST', newMerchandise)
  }

  const loadCart = (action: 'POST' | 'PUT', merch?: Merchandise) => {
    setAdding(true)

    fetch(`/api/cart?cartId=${cartId}`, {
      method: action,
      body: JSON.stringify({ lines: [merch] }),
    })
      .then((res) => res.json())
      .then((data) => {
        const newCartId = data?.body.id

        // Only store cartId if it's a new cart.
        !cartId && cookies.set('cart_id', newCartId, { expires: 7 })

        setCartId(newCartId)
      })
      .finally(() => setAdding(false))
  }

  return { adding, updateCart }
}

After which, you need to update your products page so that when the customer clicks the Add to cart button, it actually get's added to cart.

Update your <project>/app/collection/[cid]/product/[pid]/details.tsx file

'use client'

import { useEffect, useState } from 'react'
import { Button, MiniBox, OptionBox, Text } from '@/app/components/elements'
import { HR } from '@/app/components/filter'
import { formatMoney } from '@/lib/product'
import useCart from '@/app/hooks/usecart'

interface DetailsPanelProps {
  title: string
  handle: string
  featuredImage: string
  price: number
  discount: number
  options: { name: string; values: string[] }[]
  description: string
}

interface Variant {
  id: string
  sku: string
  price: number
  discount: number
  quantityAvailable: number
}

type SelectedOptions = { name: string; value: string }[]

const extractDefaultOption = (
  options: { name: string; values: string[] }[]
): SelectedOptions => {
  // Extract the first value of every item in the array and store them in this format.
  // [{name: "Color", value: "Bllue"}...]
  return options.map((option) => ({
    name: option.name,
    value: option.values[0],
  }))
}

export default function DetailsPanel({
  title,
  handle,
  featuredImage,
  price,
  discount,
  options,
  description,
}: DetailsPanelProps) {
  const [amount, setAmount] = useState<number>(1)
  const [variant, setVariant] = useState<Variant>()
  const [loading, setLoading] = useState<boolean>(true)
  const { adding, updateCart } = useCart()
  const [selectedOptions, setSelectedOptions] = useState<SelectedOptions>(
    extractDefaultOption(options)
  )

  const setOptionsValues = (name: string, value: string) => {
    const newSelectedOptions = selectedOptions.map((option) => {
      if (option.name === name) {
        return { ...option, value }
      }
      return option
    })

    setSelectedOptions(newSelectedOptions)
  }

  const inSelectedOptions = (name: string, value: string) => {
    return selectedOptions.some(
      (option) => option.name === name && option.value === value
    )
  }

  const addToCart = () => {
    const moreAttributes = [
      ...selectedOptions,
      { name: 'title', value: title },
      { name: 'price', value: (variant?.price ?? price).toString() },
      { name: 'featuredImage', value: featuredImage },
      {
        name: 'maxQuantity',
        value: (variant?.quantityAvailable ?? 0).toString(),
      },
    ]

    const newMerchandise = {
      quantity: amount,
      id: variant?.id ?? '',
      attributes: moreAttributes.map((option) => ({
        key: option.name,
        value: option.value,
      })),
    }

    updateCart(newMerchandise)
  }

  useEffect(() => {
    setLoading(true)

    fetch(`/api/products/variant?handle=${handle}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ selectedOptions }),
    })
      .then((res) => res.json())
      .then((data) => setVariant(data?.body))
      .catch((e) => console.log('An error occurred!', e))
      .finally(() => setLoading(false))
  }, [selectedOptions])

  return (
    <>
      <HR>
        <div className='flex flex-col gap-5 m-6 mb-4'>
          <Text size='xl'>{title}</Text>
          <div className='flex justify-start items-center gap-3'>
            <Text size='lg'>
              {loading ? '...' : formatMoney(variant?.price ?? price)}
            </Text>
            <span className='line-through decoration-from-font'>
              <Text size='sm'>
                {loading ? '...' : formatMoney(variant?.discount ?? discount)}
              </Text>
            </span>
          </div>
        </div>
      </HR>
      <HR>
        {options.map((option) => (
          <div key={option.name} className='flex flex-col gap-5 m-6 mb-4'>
            <Text size='md'>{`${option.name}s`}</Text>
            <div className='flex flex gap-4'>
              {option.values.map((value) => (
                <OptionBox
                  key={value}
                  active={inSelectedOptions(option.name, value)}
                  onClick={() => setOptionsValues(option.name, value)}
                >
                  {value}
                </OptionBox>
              ))}
            </div>
          </div>
        ))}
      </HR>
      <HR>
        <div className='flex flex-col justify-start items-start gap-8 m-6 mb-4'>
          <div className='flex flex-col gap-4'>
            <Text size='md'>Quantity</Text>
            <Text size='sm'>{`Only ${
              loading ? '...' : variant?.quantityAvailable ?? 0
            } item${
              variant?.quantityAvailable ?? 0 > 1 ? 's' : ''
            } left`}</Text>
            <div className='flex justify-start items-center gap-4'>
              <MiniBox
                onClick={() => amount > 1 && setAmount((prev) => prev - 1)}
              >
                -
              </MiniBox>
              <Text size='md'>
                {loading
                  ? '...'
                  : Math.min(
                      amount,
                      variant?.quantityAvailable ?? 0
                    ).toString()}
              </Text>
              <MiniBox
                onClick={() =>
                  amount < (variant?.quantityAvailable ?? 0) &&
                  setAmount((prev) => prev + 1)
                }
              >
                +
              </MiniBox>
            </div>
          </div>
        </div>
      </HR>
      <HR>
        <div className='flex flex-col justify-start items-start gap-8 m-6 mb-4'>
          <div className='flex flex-col gap-4'>
            <Text size='md'>Total</Text>
            <Text size='lg'>
              {loading
                ? '...'
                : formatMoney((variant?.price ?? price) * amount)}
            </Text>
          </div>

          <div className='flex justify-start items-center gap-8'>
            <Button
              onClick={() => console.log('Product bought!!')}
              disabled={(variant?.quantityAvailable ?? 0) < 1 || loading || adding}
            >
              Buy
            </Button>
            <Button
              onClick={addToCart}
              disabled={(variant?.quantityAvailable ?? 0) < 1 || loading || adding}
              outline
            >
              {adding ? 'Adding' : 'Add to cart'}
            </Button>
          </div>
        </div>
      </HR>
      <div>
        <div className='flex flex-col gap-5 m-6 mb-4'>
          <Text size='md'>Description</Text>
          <Text size='sm' copy>
            {description}
          </Text>
        </div>
      </div>
    </>
  )
}

Our addToCart function creates an array of important information you would like to store about the product variant and store it in the attributes part of the merchandise object. This way whenever we get the products in the cart we also have these information added.

And that's it.

Try adding any product to the cart. You can't see anything happen just yet. I know. But you just added products to an invisible cart.

Time to create some sort of indication. This way the customer knows when a product has been successfully added to the cart.

First, we need a query that fetches the id and lines in a cart if it exists - If there is an existing cartID available.

Update the <project>/app/api/query.ts file.

export const RETRIEVE_MINI_CART = `
query ($cartId: ID!) {
  cart(id: $cartId) {
    id
    lines(first: 10) {
      nodes {
        id
        quantity
        merchandise {
          ... on ProductVariant {
            id
          }
        }
        attributes {
          key
          value
        }
      }
      cost {
        totalAmount {
          amount
          currencyCode
        }
        subtotalAmount {
          amount
          currencyCode
        }
        totalTaxAmount {
          amount
          currencyCode
        }
        totalDutyAmount {
          amount
          currencyCode
        }
      }
    }
  }
}
`

Now, for the API route handler to send the query above.

Append this code to the <project>/app/api/cart/route.ts file.

// Get cart mini
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const cartId = searchParams.get('cartId')

  const { status, body } = await shopifyFetch({
    query: RETRIEVE_MINI_CART,
    variables: { cartId },
  })

  if (status === 200) {
    return Response.json({
      status: 200,
      body: cleanMiniCartResult(body.data?.cart),
    })
  } else {
    return Response.json({ status: 500, message: 'Error fetching data' })
  }
}

Because not only do you want the functions used to manipulate the cart available to all pages. You also want it to persist even on page load. For this reason we have to convert the useCart from just a hook for functions to a Context Provider consumer. The context provider will ensure consistent cart information across all pages that are it's children.

Update the <project>/app/hooks/usecart.tsx file.

'use client'

import cookies from 'js-cookie'
import {
  ReactNode,
  createContext,
  useContext,
  useEffect,
  useState,
} from 'react'

interface Merchandise {
  quantity: number
  id: string
  attributes: {
    key: string
    value: string
  }[]
}

interface Line {
  id: string
  merchandiseId: string
  quantity: number
  attributes: {
    key: string
    value: string
  }[]
}

interface Cost {
  subtotalAmount: number
  totalAmount: number
  totalTaxAmount: number
}

const DEFAULT_COST = { subtotalAmount: 0, totalAmount: 0, totalTaxAmount: 0 }

interface CartContextType {
  updateCart: (newMerchandise: Merchandise) => void
  adding: boolean
  cartSize: number
  cartPrice: Cost
}

const CartContext = createContext<CartContextType>({
  updateCart: () => {},
  adding: false,
  cartSize: 0,
  cartPrice: DEFAULT_COST
})

export const useCart = () => useContext(CartContext)

export function CartProvider({ children }: { children: ReactNode }) {
  const [adding, setAdding] = useState<boolean>(false)
  const [cartPrice, setCartPrice] = useState<Cost>(DEFAULT_COST)
  const [cartLines, setCartLines] = useState<Line[]>([])
  const [cartId, setCartId] = useState<string | null>(
    cookies.get('cart_id') ?? null
  )

  const updateCart = (newMerchandise: Merchandise) => {
    if (cartId && cartId !== 'undefined') loadCart('PUT', newMerchandise)
    else loadCart('POST', newMerchandise)
  }

  const loadCart = (action: 'POST' | 'PUT' | 'GET', merch?: Merchandise) => {
    setAdding(true)

    const body = action === 'GET' ? null : JSON.stringify({ lines: [merch] })

    fetch(`/api/cart?cartId=${cartId}`, {
      method: action,
      body,
    })
      .then((res) => res.json())
      .then((data) => {
        const newCartId = data?.body.id

        // Only store cartId if it's a new cart.
        !cartId && cookies.set('cart_id', newCartId, { expires: 7 })

        setCartId(newCartId)
        setCartLines(data?.body.cartLines)
        setCartPrice(data?.body.cost)
      })
      .finally(() => setAdding(false))
  }

  useEffect(() => {
    cartId && loadCart('GET')
  }, [])

  return (
    <CartContext.Provider
      value={{ updateCart, adding, cartSize: cartLines.length, cartPrice }}
    >
      {children}
    </CartContext.Provider>
  )
}

export default useCart

Now wrap it round the root layout - <project>/app/layout.tsx file

import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { CartProvider } from './hooks/usecart'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Belsac',
  description: 'One stop for top quality bags and accessories',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <CartProvider>{children}</CartProvider>
      </body>
    </html>
  )
}

One more component to update.

The Header component. This is where the cart icon resides. You will attach a badge signifying the number of items in the cart. And make it increase on successful addition of a product item to cart.

This way the customer knows that their item was added successfully.

Now update the <project>/app/component/header.tsx file

'use client'

import { PiHeartStraightThin, PiShoppingCartThin } from 'react-icons/pi'
import { useRouter } from 'next/navigation'
import { Button, Text } from './elements'
import { useState } from 'react'
import useCart from '../hooks/usecart'

export default function Header({ defaultText }: { defaultText?: string }) {
  const [searchText, setSearchText] = useState(defaultText)
  const { cartSize } = useCart()
  const router = useRouter()

  return (
    <div className='flex justify-between items-center gap-10 py-4'>
      <div className=''>
        <Text size='xl'>Belsac</Text>
      </div>
      <div className='flex justify-center items-stretch w-[40%]'>
        <input
          value={searchText}
          onChange={(e) => setSearchText(e.target.value)}
          placeholder='Search bags'
          className='grow self-stretch p-4 text-xl font-light border border-black/90 focus:outline-none placeholder:text-black/90 placeholder:text-xl'
        />
        <Button onClick={() => router.push(`/search?q=${searchText}`)}>
          Search
        </Button>
      </div>
      <div className='flex justify-end items-center gap-10'>
        <PiHeartStraightThin className='text-5xl' />
        <button
          type='button'
          onClick={() => router.push('/cart')}
          className='relative w-14 h-14 flex justify-center items-center hover:bg-gray-100 hover:text-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
        >
          <PiShoppingCartThin className='text-5xl' />
          <div className='absolute top-0 right-0 w-6 h-6 flex justify-center items-center bg-black text-white text-xs rounded-full'>
            {`${cartSize > 9 ? '9+' : cartSize}`}
          </div>
        </button>
      </div>
    </div>
  )
}

Reload the page and try adding another product to cart.

Phew!

That was quite the section. But you are not done yet.

Every online store needs a cart page. That will be easier than the previous section - I promise.

Let's start with the query for the cart page.

Now update the <project>/app/api/query.ts file

export const RETRIEVE_CART = `
query ($cartId: ID!) {
  cart(id: $cartId) {
    id
    checkoutUrl
    lines(first: 10) {
      nodes {
        id
        quantity
        merchandise {
          ... on ProductVariant {
            id
          }
        }
        attributes {
          key
          value
        }
      }
    }
    attributes {
      key
      value
    }
    buyerIdentity {
      email
      phone
      customer {
        id
        firstName
        lastName
      }
      countryCode
      deliveryAddressPreferences {
        ... on MailingAddress {
          address1
          address2
          city
          provinceCode
          countryCodeV2
          zip
        }
      }
    }
  }
}
`

As usual, our query result type and the cleaner function.

Append this code to the <project>/app/api/types.ts file

export interface FullCartQueryResult {
  id: string
  checkoutUrl: string
  lines: {
    nodes: CartLine[]
  }
  attributes: {
    key: string
    value: string
  }[]
  buyerIdentity: {
    email: string
    phone: string
    customer: {
      id: string
      firstName: string
      lastName: string
    }
    countryCode: string
    deliveryAddressPreferences: {
      address1: string
      address2: string
      city: string
      provinceCode: string
      countryCodeV2: string
      zip: string
      country: string
    }
  }
}

Append this code to the <project>/app/api/utils.ts file

/**
 * Converts full cart query result to a cleaner format.
 * @param fullCartResult A result gotten from querying for full cart
 * @returns A cleaner formart of cart that can be used by components
 */
export function cleanFullCartResult(fullCartResult: FullCartQueryResult) {
  const { id, checkoutUrl, lines, attributes, cost, buyerIdentity } = fullCartResult
  const cartLines = lines.nodes.map((node) => cleanCartLinesResult(node))

  return {
    id,
    checkoutUrl,
    cartLines,
    attributes,
    buyerIdentity: {
      email: buyerIdentity.email,
      phone: buyerIdentity.phone,
      customerId: buyerIdentity.customer?.id,
      firstName: buyerIdentity.customer?.firstName,
      lastName: buyerIdentity.customer?.lastName,
      address1: buyerIdentity.deliveryAddressPreferences?.address1,
      address2: buyerIdentity.deliveryAddressPreferences?.address2,
      city: buyerIdentity.deliveryAddressPreferences?.city,
      zip: buyerIdentity.deliveryAddressPreferences?.zip,
      country: buyerIdentity.deliveryAddressPreferences?.country,
    },
  }
}

Now for the API router that fetches the cart on page load.

Update the GET function in the <project>/app/api/cart/route.ts file

// Get cart both full & mini
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const cartId = searchParams.get('cartId')
  const type = searchParams.get('type')

  const { status, body } = await shopifyFetch({
    query: type ? RETRIEVE_CART : RETRIEVE_MINI_CART,
    variables: { cartId },
  })

  if (status === 200) {
    return Response.json({
      status: 200,
      body: type
        ? cleanFullCartResult(body.data?.cart)
        : cleanMiniCartResult(body.data?.cart),
    })
  } else {
    return Response.json({ status: 500, message: 'Error fetching data' })
  }
}

That's it for the server and APIs.

Now for the client cart page.

Paste this code in your <project>/app/cart/page.tsx file.

import { Text } from '@/app/components/elements'
import Header from '@/app/components/header'
import { PiCaretRightThin } from 'react-icons/pi'
import CartItems from './cartitems'

export default async function Cart() {
  return (
    <div className='px-7 max-w-[120rem] mx-auto'>
      <Header />
      <div className='py-4 mt-12'>
        <div className='flex justify-start items-center gap-5'>
          <Text size='xs' faded>
            Home
          </Text>
          <PiCaretRightThin className='text-2xl' />
          <Text size='xs' faded>
            Cart
          </Text>
        </div>
        <CartItems />
      </div>
    </div>
  )
}

And for the definition of the CartItems component in the page above

Paste this code in your <project>/app/cart/cartitems.tsx file.

'use client'

import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { TbShoppingCartOff } from 'react-icons/tb'
import { PiPencilSimpleLineThin, PiTrashSimpleThin } from 'react-icons/pi'
import useCart from '../hooks/usecart'
import { Button, Text } from '../components/elements'
import Image from 'next/image'
import { formatMoney } from '@/lib/product'

interface Attribute {
  key: string
  value: string
}

interface CartLine {
  attributes: Attribute[] | { [key: string]: any }
  id: string
  merchandiseId: string
  quantity: number
  price?: number
  title?: string
  featuredImage?: string
}

interface BuyerIdentity {
  email: string
  phone: string
  firstName: string
  lastName: string
  address1: string
  address2: string
  zip: string
  city: string
  country: string
}

interface Cart {
  id: string
  cartLines: CartLine[]
  buyerIdentity: BuyerIdentity
}

export default function CartItems() {
  const router = useRouter()
  const [loading, setLoading] = useState<boolean>(true)
  const [cart, setCart] = useState<Cart>()
  const { adding, cartId, cartPrice } = useCart()

  const extractAttributes = (lines: CartLine[]) => {
    return lines.map((line) => {
      const attributes = Object.fromEntries(
        line.attributes.map(({ key, value }: Attribute) => [key, value])
      )
      const { price, title, featuredImage, ...rest } = attributes

      return {
        title,
        featuredImage,
        price: Number(price),
        ...line,
        attributes: rest,
      }
    })
  }

  useEffect(() => {
    setLoading(true)

    if (cartId) {
      fetch(`/api/cart?cartId=${cartId}&type=1`, {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      })
        .then((res) => res.json())
        .then((data) =>
          setCart({
            ...data?.body,
            cartLines: extractAttributes(data?.body.cartLines),
          })
        )
        .finally(() => setLoading(false))
    }

    setLoading(false)
  }, [])

  if (!loading && !cartId)
    return (
      <div className='flex flex-col w-full items-center justify-center mt-20'>
        <TbShoppingCartOff className='text-9xl text-gray-300' />
        <Text size='md' faded>
          Your cart is empty
        </Text>
      </div>
    )

  return (
    <div className='grid grid-cols-12 place-content-between items-stretch gap-16 py-6'>
      {loading && (
        <div className='col-span-full flex justify-center mt-12'>
          <Text size='md'>Loading...</Text>
        </div>
      )}
      {cart && (
        <>
          <div className='col-span-8 flex flex-col gap-10'>
            {cart.cartLines.map((line) => (
              <CartItem key={line.id} line={line} />
            ))}
          </div>
          <div className='col-span-4 flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
            <div className='flex flex-col gap-8'>
              <Text size='md'>Buyer Information</Text>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Name</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{`${
                      cart.buyerIdentity.firstName ?? '...'
                    } ${cart.buyerIdentity.lastName ?? '...'}`}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Email</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.email}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Phone</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.phone}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>ZIP</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.zip}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Address 1</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.address1}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Address 2</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.address2}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Country</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.country}</Text>
                  </span>
                </div>
              </div>
              <Button
                onClick={() => console.log('Edit Buyer Information')}
                outline
              >
                <div className='flex justify-center gap-4 items-center'>
                  <PiPencilSimpleLineThin className='text-2xl text-black' />
                  <Text size='md'>Edit</Text>
                </div>
              </Button>
            </div>
            <div className='w-full border-b border-black/40' />
            <div className='flex flex-col gap-8'>
              <Text size='md'>Order Summary</Text>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Tax</Text>
                  <Text size='md'>
                    {formatMoney(Number(cartPrice.totalTaxAmount))}
                  </Text>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Subtotal</Text>
                  <Text size='md'>
                    {formatMoney(Number(cartPrice.subtotalAmount))}
                  </Text>
                </div>
              </div>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='md'>Total</Text>
                  <Text size='xl'>
                    {formatMoney(Number(cartPrice.totalAmount))}
                  </Text>
                </div>
                <Button 
                  disabled={adding} 
                  onClick={() => router.push(cart?.checkoutUrl)}
                >
                  Checkout
                </Button>
              </div>
            </div>
          </div>
        </>
      )}
    </div>
  )
}

function CartItem({
  line: { id, title, featuredImage, price, quantity, attributes },
}: {
  line: CartLine
}) {
  const hasImage = featuredImage && title
  const options = Object.entries(attributes)
    .map(([key, value]) => value)
    .join('.|.')
    .split('.')

  return (
    <div className='relative grid grid-cols-7 place-items-stretch min-h-[20rem] gap-16 py-16 px-8 border-b border-black/40 last:border-none'>
      <div className='col-span-2 w-full bg-gray-300'>
        {hasImage && (
          <Image
            src={featuredImage}
            alt={title}
            width={400}
            height={400}
            className='w-full object-cover'
          />
        )}
      </div>
      <div className='col-span-4 w-full flex flex-col gap-6'>
        <Text size='lg'>{title ?? '...'}</Text>
        <div className='flex justify-start items-center gap-6'>
          <Text size='lg'>{quantity.toString() ?? '0'}</Text>
          <Text size='lg'>x</Text>
          <Text size='lg'>{formatMoney(price ?? 0)}</Text>
        </div>
        <div className='flex justify-start items-center gap-6'>
          {options.map((option) => (
            <Text size='md'>{option}</Text>
          ))}
        </div>
      </div>
      <div className='absolute top-0 right-0 flex items-stretch justify-center gap-4'>
        <button
          type='button'
          onClick={() => console.log(`Line: ${id}, ready for editing!!`)}
          className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
          title='Edit line'
        >
          <PiPencilSimpleLineThin className='text-2xl text-black' />
        </button>
        <button
          type='button'
          onClick={() => console.log(`Line: ${id}, deleted!!`)}
          className='w-14 h-14 flex justify-center items-center border border-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
          title='Delete line'
        >
          <PiTrashSimpleThin className='text-2xl text-red-500' />
        </button>
      </div>
    </div>
  )
}

And that's it.

The Cart page is ready. Add a new product to the cart and load the cart page to see the product.

Congratulations on making it this far.

One feature addition. Notice the edit and delete icon button on every line item in the cart page.

Let's make them functional.

This is what you are going to do.

First, create the query for deleting a line from the cart.

Append this code to your <project>/app/api/query.ts file

export const DELETE_ITEM_FROM_CART = `
mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
  cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
    cart {
      id
      lines(first: 10) {
        nodes {
          id
          quantity
          merchandise {
            ... on ProductVariant {
              id
            }
          }
          attributes {
            key
            value
          }
        }
      }
      cost {
        totalAmount {
          amount
          currencyCode
        }
        subtotalAmount {
          amount
          currencyCode
        }
        totalTaxAmount {
          amount
          currencyCode
        }
        totalDutyAmount {
          amount
          currencyCode
        }
      }
    }
  }
}
`

Then add the API route for deleting a line to the server.

Append this to your <project>/app/api/cart/route.ts file

// Delete lines (items = product variants) from cart
export async function DELETE(Request: NextRequest) {
  const searchParams = Request.nextUrl.searchParams
  const cartId = searchParams.get('cartId')
  const { lines } = await Request.json()

  const variables = { cartId, lineIds: generateCartLineIds(lines) }

  const { status, body } = await shopifyFetch({
    query: DELETE_ITEM_FROM_CART,
    variables,
  })

  if (status === 200) {
    const cart = cleanMiniCartResult(body.data?.cartLinesRemove?.cart)
    return Response.json({ status: 200, body: cart })
  } else {
    return Response.json({ status: 500, message: 'Error receiving data' })
  }
}

And for the definition of the generateCartLineIds function above

Append this to your <project>/app/api/utils.ts file

export function generateCartLineIds(lines: Merchandise[]) {
  return lines.map(({ id }) => id)
}

After that, then create a function in the client cart context provider that calls this API route for the delete a specific line action.

Update your <project>/app/hooks/usecart.tsx file

'use client'

import cookies from 'js-cookie'
import {
  ReactNode,
  createContext,
  useContext,
  useEffect,
  useState,
} from 'react'

interface Merchandise {
  quantity: number
  id: string
  attributes: {
    key: string
    value: string
  }[]
}

interface Line {
  id: string
  merchandiseId: string
  quantity: number
  attributes: {
    key: string
    value: string
  }[]
}

interface CartContextType {
  cartId: string | null
  updateCart: (newMerchandise: Merchandise) => void
  deleteLine: (line: Line) => void
  adding: boolean
  cartSize: number
}

const CartContext = createContext<CartContextType>({
  cartId: null,
  updateCart: () => {},
  deleteLine: () => {},
  adding: false,
  cartSize: 0,
})

export const useCart = () => useContext(CartContext)

export function CartProvider({ children }: { children: ReactNode }) {
  const [adding, setAdding] = useState<boolean>(false)
  const [cartLines, setCartLines] = useState<Line[]>([])
  const [cartId, setCartId] = useState<string | null>(
    cookies.get('cart_id') ?? null
  )

  const updateCart = (newMerchandise: Merchandise) => {
    if (cartId && cartId !== 'undefined') loadCart('PUT', newMerchandise)
    else loadCart('POST', newMerchandise)
  }

  const deleteLine = (line: Line) => loadCart('DELETE', line)

  const loadCart = (
    action: 'POST' | 'PUT' | 'GET' | 'DELETE',
    merch?: Merchandise
  ) => {
    setAdding(true)

    const body = action === 'GET' ? null : JSON.stringify({ lines: [merch] })

    fetch(`/api/cart?cartId=${cartId}`, {
      method: action,
      body,
    })
      .then((res) => res.json())
      .then((data) => {
        const newCartId = data?.body.id

        // Only store cartId if it's a new cart.
        !cartId && cookies.set('cart_id', newCartId, { expires: 7 })

        setCartId(newCartId)
        setCartLines(data?.body.cartLines)
      })
      .finally(() => setAdding(false))
  }

  useEffect(() => {
    cartId && loadCart('GET')
  }, [])

  return (
    <CartContext.Provider
      value={{
        cartId,
        updateCart,
        deleteLine,
        adding,
        cartSize: cartLines.length,
      }}
    >
      {children}
    </CartContext.Provider>
  )
}

export default useCart

Now update your CartItem component in the cart page with the ability to delete lines from the cart using the delete icon button.

'use client'

import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { TbShoppingCartOff } from 'react-icons/tb'
import { PiPencilSimpleLineThin, PiTrashSimpleThin } from 'react-icons/pi'
import useCart from '../hooks/usecart'
import { Button, Text } from '../components/elements'
import Image from 'next/image'
import { formatMoney } from '@/lib/product'

interface Attribute {
  key: string
  value: string
}

interface CartLine {
  attributes: Attribute[] | { [key: string]: any }
  id: string
  merchandiseId: string
  quantity: number
  price?: number
  title?: string
  featuredImage?: string
}

interface BuyerIdentity {
  email: string
  phone: string
  firstName: string
  lastName: string
  address1: string
  address2: string
  city: string
  zip: string
  country: string
}

interface Cost {
  subtotalAmount: string
  totalAmount: string
  totalTaxAmount: string
}

interface Cart {
  id: string
  cost: Cost
  cartLines: CartLine[]
  buyerIdentity: BuyerIdentity
}

export default function CartItems() {
  const router = useRouter()
  const [loading, setLoading] = useState<boolean>(true)
  const [cart, setCart] = useState<Cart>()
  const { cartId, deleteLine } = useCart()

  const extractAttributes = (lines: CartLine[]) => {
    return lines.map((line) => {
      const attributes = Object.fromEntries(
        line.attributes.map(({ key, value }: Attribute) => [key, value])
      )
      const { price, title, featuredImage, ...rest } = attributes

      return {
        title,
        featuredImage,
        price: Number(price),
        ...line,
        attributes: rest,
      }
    })
  }

  const deleteCartLine = (line: CartLine) => {
    if (cart) {
      // Rmove the line from the cart
      const newCartLines = cart?.cartLines.filter(({ id }) => id !== line.id)
      setCart({ ...cart, cartLines: newCartLines })

      // Modify attributes field to match db lines
      const newAttributes = Object.entries(line.attributes).map(
        ([key, value]) => ({ key, value })
      )

      // Remove the line from store db
      deleteLine({ ...line, attributes: newAttributes })
    }
  }

  useEffect(() => {
    setLoading(true)

    if (cartId) {
      fetch(`/api/cart?cartId=${cartId}&type=1`, {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      })
        .then((res) => res.json())
        .then((data) =>
          setCart({
            ...data?.body,
            cartLines: extractAttributes(data?.body.cartLines),
          })
        )
        .finally(() => setLoading(false))
    }

    setLoading(false)
  }, [])

  if (!loading && !cartId)
    return (
      <div className='flex flex-col w-full items-center justify-center mt-20'>
        <TbShoppingCartOff className='text-9xl text-gray-300' />
        <Text size='md' faded>
          Your cart is empty
        </Text>
      </div>
    )

  return (
    <div className='grid grid-cols-12 place-content-between items-stretch gap-16 py-6'>
      {loading && (
        <div className='col-span-full flex justify-center mt-12'>
          <Text size='md'>Loading...</Text>
        </div>
      )}
      {cart && (
        <>
          <div className='col-span-8 flex flex-col gap-10'>
            {cart.cartLines.map((line) => (
              <CartItem key={line.id} line={line} deleteLine={deleteCartLine} />
            ))}
          </div>
          <div className='col-span-4 flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
            <div className='flex flex-col gap-8'>
              <Text size='md'>Buyer Information</Text>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Name</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{`${
                      cart.buyerIdentity.firstName ?? '...'
                    } ${cart.buyerIdentity.lastName ?? '...'}`}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Email</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.email}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Phone</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.phone}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>ZIP</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.zip}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Address 1</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.address1}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Address 2</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.address2}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Country</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.country}</Text>
                  </span>
                </div>
              </div>
              <Button
                onClick={() => console.log('Edit Buyer Information')}
                outline
              >
                <div className='flex justify-center gap-4 items-center'>
                  <PiPencilSimpleLineThin className='text-2xl text-black' />
                  <Text size='md'>Edit</Text>
                </div>
              </Button>
            </div>
            <div className='w-full border-b border-black/40' />
            <div className='flex flex-col gap-8'>
              <Text size='md'>Order Summary</Text>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Tax</Text>
                  <Text size='md'>
                    {formatMoney(Number(cart.cost.totalTaxAmount))}
                  </Text>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Subtotal</Text>
                  <Text size='md'>
                    {formatMoney(Number(cart.cost.subtotalAmount))}
                  </Text>
                </div>
              </div>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='md'>Total</Text>
                  <Text size='xl'>
                    {formatMoney(Number(cart.cost.totalAmount))}
                  </Text>
                </div>
                <Button 
                  disabled={adding} 
                  onClick={() => router.push(cart?.checkoutUrl)}
                >
                  Checkout
                </Button>
              </div>
            </div>
          </div>
        </>
      )}
    </div>
  )
}

function CartItem({
  line,
  deleteLine,
}: {
  line: CartLine
  deleteLine: (line: CartLine) => void
}) {
  const { id, title, featuredImage, price, quantity, attributes } = line
  const hasImage = featuredImage && title
  const options = Object.entries(attributes)
    .map(([key, value]) => value)
    .join('.|.')
    .split('.')

  return (
    <div className='relative grid grid-cols-7 place-items-stretch min-h-[20rem] gap-16 py-16 px-8 border-b border-black/40 last:border-none'>
      <div className='col-span-2 w-full bg-gray-300'>
        {hasImage && (
          <Image
            src={featuredImage}
            alt={title}
            width={400}
            height={400}
            className='w-full object-cover'
          />
        )}
      </div>
      <div className='col-span-4 w-full flex flex-col gap-6'>
        <Text size='lg'>{title ?? '...'}</Text>
        <div className='flex justify-start items-center gap-6'>
          <Text size='lg'>{quantity.toString() ?? '0'}</Text>
          <Text size='lg'>x</Text>
          <Text size='lg'>{formatMoney(price ?? 0)}</Text>
        </div>
        <div className='flex justify-start items-center gap-6'>
          {options.map((option) => (
            <Text size='md'>{option}</Text>
          ))}
        </div>
      </div>
      <div className='absolute top-0 right-0 flex items-stretch justify-center gap-4'>
        <button
          type='button'
          onClick={() => console.log(`Line: ${id}, ready for editing!!`)}
          className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
          title='Edit line'
        >
          <PiPencilSimpleLineThin className='text-2xl text-black' />
        </button>
        <button
          type='button'
          onClick={() => deleteLine(line)}
          className='w-14 h-14 flex justify-center items-center border border-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
          title='Delete line'
        >
          <PiTrashSimpleThin className='text-2xl text-red-500' />
        </button>
      </div>
    </div>
  )
}

And that's it for deleting a line from a cart. Not just deleting a line but also reflecting the changes in price in the checkout section of the cart page.

For the next section. You are going to make editing a cart line possible by clicking the icon

Let's start with the query.

Append this code to <project>/app/api/query.ts file

export const UPDATE_A_CART_ITEM = `
mutation ($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
  cartLinesUpdate(
    cartId: $cartId
    lines: $lines
  ) {
    cart {
      id
      lines(first: 10) {
        nodes {
          id
          quantity
          merchandise {
            ... on ProductVariant {
              id
            }
          }
          attributes {
            key
            value
          }
        }
      }
      cost {
        totalAmount {
          amount
          currencyCode
        }
        subtotalAmount {
          amount
          currencyCode
        }
        totalTaxAmount {
          amount
          currencyCode
        }
        totalDutyAmount {
          amount
          currencyCode
        }
      }
    }
  }
}
`

Next, update the API route for cart with this route function

Append this to <project>/app/api/cart/route.ts file

// Update lines in a cart
export async function PATCH(Request: NextRequest) {
  const searchParams = Request.nextUrl.searchParams
  const cartId = searchParams.get('cartId')
  const { lines } = await Request.json()

  const variables = { cartId, lines }

  const { status, body } = await shopifyFetch({
    query: UPDATE_A_CART_ITEM,
    variables,
  })

  if (status === 200) {
    const cart = cleanMiniCartResult(body.data?.cartLinesUpdate?.cart)
    return Response.json({ status: 200, body: cart })
  } else {
    return Response.json({ status: 500, message: 'Error receiving data' })
  }
}

Next, update the usecart context provider to be able to edit a line. And pass the function to it's consumers.

'use client'

import cookies from 'js-cookie'
import {
  ReactNode,
  createContext,
  useContext,
  useEffect,
  useState,
} from 'react'

interface Merchandise {
  quantity: number
  id: string
  attributes: {
    key: string
    value: string
  }[]
}

interface Line {
  id: string
  merchandiseId: string
  quantity: number
  attributes: {
    key: string
    value: string
  }[]
}

interface Cost {
  subtotalAmount: number
  totalAmount: number
  totalTaxAmount: number
}

const DEFAULT_COST = { subtotalAmount: 0, totalAmount: 0, totalTaxAmount: 0 }

interface CartContextType {
  cartId: string | null
  updateCart: (newMerchandise: Merchandise) => void
  deleteLine: (line: Line) => void
  editLine: (line: Line) => void
  adding: boolean
  cartSize: number
  cartPrice: Cost
}

const CartContext = createContext<CartContextType>({
  cartId: null,
  updateCart: () => {},
  deleteLine: () => {},
  editLine: () => {},
  adding: false,
  cartSize: 0,
  cartPrice: DEFAULT_COST,
})

export const useCart = () => useContext(CartContext)

export function CartProvider({ children }: { children: ReactNode }) {
  const [adding, setAdding] = useState<boolean>(false)
  const [cartPrice, setCartPrice] = useState<Cost>(DEFAULT_COST)
  const [cartLines, setCartLines] = useState<Line[]>([])
  const [cartId, setCartId] = useState<string | null>(
    cookies.get('cart_id') ?? null
  )

  const updateCart = (newMerchandise: Merchandise) => {
    if (cartId && cartId !== 'undefined') loadCart('PUT', newMerchandise)
    else loadCart('POST', newMerchandise)
  }

  const deleteLine = (line: Line) => loadCart('DELETE', line)

  const editLine = (line: Line) => loadCart('PATCH', line)

  const loadCart = (
    action: 'POST' | 'PUT' | 'GET' | 'DELETE' | 'PATCH',
    merch?: Merchandise
  ) => {
    setAdding(true)

    const body = action === 'GET' ? null : JSON.stringify({ lines: [merch] })

    fetch(`/api/cart?cartId=${cartId}`, {
      method: action,
      body,
    })
      .then((res) => res.json())
      .then((data) => {
        const newCartId = data?.body.id

        // Only store cartId if it's a new cart.
        !cartId && cookies.set('cart_id', newCartId, { expires: 7 })

        setCartId(newCartId)
        setCartLines(data?.body.cartLines)
        setCartPrice(data?.body.cost)
      })
      .finally(() => setAdding(false))
  }

  useEffect(() => {
    cartId && loadCart('GET')
  }, [])

  return (
    <CartContext.Provider
      value={{
        cartId,
        updateCart,
        deleteLine,
        editLine,
        adding,
        cartSize: cartLines.length,
        cartPrice,
      }}
    >
      {children}
    </CartContext.Provider>
  )
}

export default useCart

Lastly, you need to update the cart page and the CartItem component to be able to trigger cart line editing on edit icon click.

'use client'

import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { TbShoppingCartOff } from 'react-icons/tb'
import { PiPencilSimpleLineThin, PiTrashSimpleThin } from 'react-icons/pi'
import { TfiClose, TfiCheck } from 'react-icons/tfi'
import useCart from '../hooks/usecart'
import { Button, MiniBox, Text } from '../components/elements'
import Image from 'next/image'
import { formatMoney } from '@/lib/product'

interface Attribute {
  key: string
  value: string
}

interface CartLine {
  attributes: Attribute[]
  optionAttributes: { [key: string]: any }
  id: string
  merchandiseId: string
  quantity: number
  price?: number
  title?: string
  featuredImage?: string
  maxQuantity?: number
}

interface BuyerIdentity {
  email: string
  phone: string
  firstName: string
  lastName: string
  address1: string
  address2: string
  city: string
  zip: string
  country: string
}

interface Cart {
  id: string
  cartLines: CartLine[]
  buyerIdentity: BuyerIdentity
}

export default function CartItems() {
  const router = useRouter()
  const [loading, setLoading] = useState<boolean>(true)
  const [cart, setCart] = useState<Cart>()
  const { cartId, cartPrice, deleteLine, editLine } = useCart()

  const extractAttributes = (lines: CartLine[]) => {
    return lines.map((line) => {
      const attributes = Object.fromEntries(
        line.attributes.map(({ key, value }: Attribute) => [key, value])
      )
      const { price, title, featuredImage, maxQuantity, ...rest } = attributes

      return {
        title,
        featuredImage,
        price: Number(price),
        maxQuantity: Number(maxQuantity),
        ...line,
        optionAttributes: rest,
      }
    })
  }

  const deleteCartLine = (line: CartLine) => {
    if (cart) {
      // Rmove the line from the cart
      const newCartLines = cart?.cartLines.filter(({ id }) => id !== line.id)
      setCart({ ...cart, cartLines: newCartLines })

      // Remove the line from store db
      deleteLine(line)
    }
  }

  const editCartLine = (oldLine: CartLine, newAmount: number) => {
    if (cart) {
      const newLine = { ...oldLine, quantity: newAmount }

      // Edit the line in the cart
      const updatedCartLines = cart?.cartLines.map((line) => {
        if (line.id === newLine.id) {
          return newLine
        }

        return line
      })

      setCart({ ...cart, cartLines: updatedCartLines })

      // CartLine here should match the object structure of db cartline
      const editedLine = {
        id: newLine.id,
        merchandiseId: newLine.merchandiseId,
        quantity: newLine.quantity,
        attributes: newLine.attributes,
      }

      editLine(editedLine)
    }
  }

  useEffect(() => {
    setLoading(true)

    if (cartId) {
      fetch(`/api/cart?cartId=${cartId}&type=1`, {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      })
        .then((res) => res.json())
        .then((data) =>
          setCart({
            ...data?.body,
            cartLines: extractAttributes(data?.body.cartLines),
          })
        )
        .finally(() => setLoading(false))
    }

    setLoading(false)
  }, [])

  if (!loading && !cartId)
    return (
      <div className='flex flex-col w-full items-center justify-center mt-20'>
        <TbShoppingCartOff className='text-9xl text-gray-300' />
        <Text size='md' faded>
          Your cart is empty
        </Text>
      </div>
    )

  return (
    <div className='grid grid-cols-12 place-content-between items-stretch gap-16 py-6'>
      {loading && (
        <div className='col-span-full flex justify-center mt-12'>
          <Text size='md'>Loading...</Text>
        </div>
      )}
      {cart && (
        <>
          <div className='col-span-8 flex flex-col gap-10'>
            {cart.cartLines.map((line) => (
              <CartItem
                key={line.id}
                line={line}
                deleteLine={deleteCartLine}
                editLine={editCartLine}
              />
            ))}
          </div>
          <div className='col-span-4 flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
            <div className='flex flex-col gap-8 w-full overflow-hidden'>
              <Text size='md'>Buyer Information</Text>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Name</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{`${
                      cart.buyerIdentity.firstName ?? '...'
                    } ${cart.buyerIdentity.lastName ?? '...'}`}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Email</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.email}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Phone</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.phone}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>ZIP</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.zip}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Address 1</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.address1}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Address 2</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.address2}</Text>
                  </span>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Country</Text>
                  <span className='text-clamp-1'>
                    <Text size='sm'>{cart.buyerIdentity.country}</Text>
                  </span>
                </div>
              </div>
              <Button
                onClick={() => console.log('Edit Buyer Information')}
                outline
              >
                <div className='flex justify-center gap-4 items-center'>
                  <PiPencilSimpleLineThin className='text-2xl text-black' />
                  <Text size='md'>Edit</Text>
                </div>
              </Button>
            </div>
            <div className='w-full border-b border-black/40' />
            <div className='flex flex-col gap-8'>
              <Text size='md'>Order Summary</Text>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Tax</Text>
                  <Text size='md'>
                    {formatMoney(Number(cartPrice.totalTaxAmount))}
                  </Text>
                </div>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='xs'>Subtotal</Text>
                  <Text size='md'>
                    {formatMoney(Number(cartPrice.subtotalAmount))}
                  </Text>
                </div>
              </div>
              <div className='flex flex-col gap-3'>
                <div className='flex items-center justify-between gap-4'>
                  <Text size='md'>Total</Text>
                  <Text size='xl'>
                    {formatMoney(Number(cartPrice.totalAmount))}
                  </Text>
                </div>
                <Button 
                  disabled={adding} 
                  onClick={() => router.push(cart?.checkoutUrl)}
                >
                  Checkout
                </Button>
              </div>
            </div>
          </div>
        </>
      )}
    </div>
  )
}

function CartItem({
  line,
  deleteLine,
  editLine,
}: {
  line: CartLine
  deleteLine: (line: CartLine) => void
  editLine: (line: CartLine, newAmount: number) => void
}) {
  const [inEditMode, setInEditMode] = useState(false)
  const {
    title,
    featuredImage,
    price,
    quantity,
    maxQuantity,
    optionAttributes,
  } = line
  const [newAmount, setNewAmount] = useState(quantity)
  const hasImage = featuredImage && title
  const options = Object.entries(optionAttributes)
    .map(([key, value]) => value)
    .join('.|.')
    .split('.')

  const EditModeControl = () => (
    <div className='flex justify-start items-center gap-4'>
      <button
        type='button'
        onClick={() => {
          editLine(line, newAmount)
          setInEditMode(false)
        }}
        className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
        title='Save line edit'
      >
        <TfiCheck className='text-2xl text-black' />
      </button>
      <button
        type='button'
        onClick={() => {
          setNewAmount(quantity)
          setInEditMode(false)
        }}
        className='w-14 h-14 flex justify-center items-center border border-black bg-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
        title='Cancel line edit'
      >
        <TfiClose className='text-2xl text-white' />
      </button>
    </div>
  )

  const EditAmountPanel = () => (
    <div className='flex justify-start items-center gap-4'>
      <MiniBox
        onClick={() => newAmount > 1 && setNewAmount((prev) => prev - 1)}
      >
        -
      </MiniBox>
      <Text size='md'>{newAmount.toString()}</Text>
      <MiniBox
        onClick={() =>
          newAmount < (maxQuantity ?? 0) && setNewAmount((prev) => prev + 1)
        }
      >
        +
      </MiniBox>
    </div>
  )

  return (
    <div className='relative grid grid-cols-9 place-items-stretch min-h-[20rem] xl:gap-16 gap-8 py-16 px-8 border-b border-black/40 last:border-none'>
      <div className={`col-span-2 w-full ${!hasImage && 'bg-gray-300'}`}>
        {hasImage && (
          <Image
            src={featuredImage}
            alt={title}
            width={400}
            height={400}
            className='w-full object-cover'
          />
        )}
      </div>
      <div className='col-span-7 w-full flex flex-col gap-6'>
        <Text size='lg'>{title ?? '...'}</Text>
        <div className='flex justify-start items-center gap-6'>
          {inEditMode ? (
            <EditAmountPanel />
          ) : (
            <Text size='lg'>{quantity.toString()}</Text>
          )}
          <Text size='lg'>x</Text>
          <Text size='lg'>{formatMoney(price ?? 0)}</Text>
        </div>
        <div className='flex justify-start items-center gap-6'>
          {options.map((option, id) => (
            <Text key={option + id} size='md'>
              {option}
            </Text>
          ))}
        </div>
      </div>
      <div className='absolute top-0 right-0 flex items-stretch justify-center gap-4'>
        {inEditMode ? (
          <EditModeControl />
        ) : (
          <button
            type='button'
            onClick={() => setInEditMode(true)}
            className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
            title='Edit line'
          >
            <PiPencilSimpleLineThin className='text-2xl text-black' />
          </button>
        )}
        <button
          type='button'
          onClick={() => deleteLine(line)}
          className='w-14 h-14 flex justify-center items-center border border-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
          title='Delete line'
        >
          <PiTrashSimpleThin className='text-2xl text-red-500' />
        </button>
      </div>
    </div>
  )
}

Now we are already done with the major part of this section.

But remember we still have one more query left: The query to edit the customer/buyer identity or information.

Paste this code in your <project>/app/api/query.ts file.

export const UPDATE_CUSTOMER_INFO = `
mutation cartBuyerIdentityUpdate($buyerIdentity: CartBuyerIdentityInput!, $cartId: ID!) {
  cartBuyerIdentityUpdate(buyerIdentity: $buyerIdentity, cartId: $cartId) {
    cart {
      id
      buyerIdentity {
        email
        phone
        customer {
          id
          firstName
          lastName
        }
        countryCode
        deliveryAddressPreferences {
          ... on MailingAddress {
            address1
            address2
            city
            provinceCode
            countryCodeV2
            zip
            country
          }
        }
      }
    }
  }
}
`

As you already suspected, you need a cleaner function. And to do this you need to give the query result a type.

export interface CustomerInfoQueryResult {
  id: string
  buyerIdentity: {
    email: string
    phone: string
    customer: {
      id: string
      firstName: string
      lastName: string
    }
    countryCode: string
    deliveryAddressPreferences: {
      address1: string
      address2: string
      city: string
      provinceCode: string
      countryCodeV2: string
      zip: string
      country: string
    }
  }
}

Next, the cleaner function

Append this code to your <project>/app/api/utils.ts file.

/**
 * Converts customer info query result to a cleaner format.
 * @param result A result gotten from querying for customer info
 * @returns A cleaner formart of customer info that can be used by components
 */
export function cleanCustomerInfoResult(result: CustomerInfoQueryResult) {
  const { id, buyerIdentity } = result
  const {
    email,
    phone,
    customer: { firstName, lastName },
    deliveryAddressPreferences: { address1, address2, city, zip, country },
  } = buyerIdentity

  return {
    id,
    firstName,
    lastName,
    email,
    phone,
    address1,
    address2,
    city,
    country,
  }
}

Done?

Next, create the API router.

Paste this in the <project>/app/api/cart/customer/route.ts file

import { shopifyFetch } from '@/lib/fetch'
import { NextRequest } from 'next/server'
import { UPDATE_CUSTOMER_INFO } from '../../query'
import { cleanCustomerInfoResult } from '../../utils'

export async function POST(Request: NextRequest) {
  const searchParams = Request.nextUrl.searchParams
  const cartId = searchParams.get('cartId')
  const { customerInfo } = await Request.json()

  const { status, body } = await shopifyFetch({
    query: UPDATE_CUSTOMER_INFO,
    variables: { cartId, buyerIdentity: generateBuyerId(customerInfo) },
  })

  if (status === 200) {
    const cart = cleanCustomerInfoResult(
      body.data?.cartBuyerIdentityUpdate?.cart
    )
    return Response.json({ status: 200, body: cart })
  } else {
    return Response.json({ status: 500, message: 'Error receiving data' })
  }
}

Here is the definition for the generateBuyerId function above.

Append this to your <project>/app/api/utils.ts file

export function generateBuyerId(customerInfo: any) {
  const {
    firstName,
    lastName,
    email,
    phone,
    address1,
    address2,
    city,
    province,
    country,
    zip,
  } = customerInfo

  return {
    email,
    phone,
    deliveryAddressPreferences: {
      deliveryAddress: {
        firstName,
        lastName,
        address1,
        address2,
        city,
        province,
        country,
        zip,
      },
    },
  }
}

Now for the client. You have to create a new component for the checkout in the cart page folder. This will keep your codes clean.

Paste this in the <project/app/cart/cartinfo.tsx file

'use client'

import { useRouter } from 'next/navigation'
import { formatMoney } from '@/lib/product'
import { Button, Text } from '../components/elements'
import useCart from '../hooks/usecart'
import { PiPencilSimpleLineThin } from 'react-icons/pi'
import { useMemo, useState } from 'react'

export interface BuyerIdentity {
  email: string
  phone: string
  firstName: string
  lastName: string
  address1: string
  address2: string
  city: string
  zip: string
  country: string
}

export default function CartInfo({
  cartId,
  defaultBuyerIdentity,
  checkoutUrl,
}: {
  cartId: string
  defaultBuyerIdentity: BuyerIdentity
  checkoutUrl: string
}) {
  const { adding, cartPrice } = useCart()
  const router = useRouter()
  const [inEditMode, setInEditMode] = useState<boolean>(false)
  const [loading, setLoading] = useState<boolean>(false)
  const [buyerIdentity, setBuyerIdentity] =
    useState<BuyerIdentity>(defaultBuyerIdentity)
  const hasCompleteDetails = useMemo(
    () => Object.values(buyerIdentity).every((value) => value !== ''),
    [buyerIdentity]
  )

  // api fetch
  const updateInfo = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setLoading(true)

    const formData = new FormData(e.currentTarget)
    const newBuyerIdentity: BuyerIdentity = {
      email: formData.get('email') as string,
      phone: formData.get('phone') as string,
      firstName: formData.get('first name') as string,
      lastName: formData.get('last name') as string,
      address1: formData.get('Address 1') as string,
      address2: formData.get('Address 2') as string,
      city: '',
      zip: formData.get('zip') as string,
      country: formData.get('Country') as string,
    }

    fetch(`/api/cart/customer?cartId=${cartId}`, {
      method: 'POST',
      body: JSON.stringify({ customerInfo: newBuyerIdentity }),
    })
      .then((res) => res.json())
      .then((data) => {
        if (data?.status === '200') {
          setInEditMode(false)
          setBuyerIdentity(data?.body)
        }
      })
      .catch((e) => console.log('An error occurred.\n', e))
      .finally(() => setLoading(false))
  }

  return (
    <>
      <form
        onSubmit={updateInfo}
        className='flex flex-col gap-8 w-full overflow-hidden'
      >
        <Text size='md'>Buyer Information</Text>
        <div className='flex flex-col gap-3'>
          <div className='flex flex-col gap-2 items-stretch'>
            <div className='flex items-center justify-between gap-4'>
              <Text size='xs'>Name</Text>
              {!inEditMode && (
                <span className='text-clamp-1'>
                  <Text size='sm'>{`${buyerIdentity.firstName ?? '...'} ${
                    buyerIdentity.lastName ?? '...'
                  }`}</Text>
                </span>
              )}
            </div>
            {(inEditMode || loading) && (
              <div className='flex items-stretch justify-start gap-4'>
                <input
                  name='first name'
                  className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
                  placeholder='First Name'
                />
                <input
                  name='last name'
                  className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
                  placeholder='Last Name'
                />
              </div>
            )}
          </div>
          <div className='flex flex-col gap-2 items-stretch'>
            <div className='flex items-center justify-between gap-4'>
              <Text size='xs'>Email</Text>
              {!inEditMode && (
                <span className='text-clamp-1'>
                  <Text size='sm'>{buyerIdentity.email}</Text>
                </span>
              )}
            </div>
            {(inEditMode || loading) && (
              <input
                name='email'
                className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
                placeholder='Email'
              />
            )}
          </div>
          <div className='flex flex-col gap-2 items-stretch'>
            <div className='flex items-center justify-between gap-4'>
              <Text size='xs'>Phone</Text>
              {!inEditMode && (
                <span className='text-clamp-1'>
                  <Text size='sm'>{buyerIdentity.phone}</Text>
                </span>
              )}
            </div>
            {(inEditMode || loading) && (
              <input
                name='phone'
                className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
                placeholder='Phone'
              />
            )}
          </div>
          <div className='flex flex-col gap-2 items-stretch'>
            <div className='flex items-center justify-between gap-4'>
              <Text size='xs'>ZIP</Text>
              {!inEditMode && (
                <span className='text-clamp-1'>
                  <Text size='sm'>{buyerIdentity.zip}</Text>
                </span>
              )}
            </div>
            {(inEditMode || loading) && (
              <input
                name='zip'
                className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
                placeholder='Zip'
              />
            )}
          </div>
          <div className='flex flex-col gap-2 items-stretch'>
            <div className='flex items-center justify-between gap-4'>
              <Text size='xs'>Address 1</Text>
              {!inEditMode && (
                <span className='text-clamp-1'>
                  <Text size='sm'>{buyerIdentity.address1}</Text>
                </span>
              )}
            </div>
            {(inEditMode || loading) && (
              <input
                name='Address 1'
                className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
                placeholder='Address 1'
              />
            )}
          </div>
          <div className='flex flex-col gap-2 items-stretch'>
            <div className='flex items-center justify-between gap-4'>
              <Text size='xs'>Address 2</Text>
              {!inEditMode && (
                <span className='text-clamp-1'>
                  <Text size='sm'>{buyerIdentity.address2}</Text>
                </span>
              )}
            </div>
            {(inEditMode || loading) && (
              <input
                name='Address 2'
                className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
                placeholder='Address 2'
              />
            )}
          </div>
          <div className='flex flex-col gap-2 items-stretch'>
            <div className='flex items-center justify-between gap-4'>
              <Text size='xs'>Country</Text>
              {!inEditMode && (
                <span className='text-clamp-1'>
                  <Text size='sm'>{buyerIdentity.country}</Text>
                </span>
              )}
            </div>
            {(inEditMode || loading) && (
              <input
                name='Country'
                className='w-full h-10 border border-black/40 focus:outline-black px-4 py-2 placeholder:font-light'
                placeholder='Country'
              />
            )}
          </div>
        </div>
        {inEditMode ? (
          <div className='flex items-center justify-center gap-4'>
            <button
              type='submit'
              disabled={!hasCompleteDetails}
              className='py-4 px-10 shadow-md border border-black/90 leading-none text-2xl font-light text-black/90 bg-transparent hover:bg-gray-300 disabled:border-black/30'
            >
              <Text size='md'>{loading ? 'Saving' : 'Save'}</Text>
            </button>
            <Button onClick={() => setInEditMode(false)}>
              <Text size='md' white>
                Cancel
              </Text>
            </Button>
          </div>
        ) : (
          <Button onClick={() => setInEditMode(true)} outline>
            <div className='flex justify-center gap-4 items-center'>
              <PiPencilSimpleLineThin className='text-2xl text-black' />
              <Text size='md'>Edit</Text>
            </div>
          </Button>
        )}
      </form>
      <div className='w-full border-b border-black/40' />
      <div className='flex flex-col gap-8'>
        <Text size='md'>Order Summary</Text>
        <div className='flex flex-col gap-3'>
          <div className='flex items-center justify-between gap-4'>
            <Text size='xs'>Tax</Text>
            <Text size='md'>
              {formatMoney(Number(cartPrice.totalTaxAmount))}
            </Text>
          </div>
          <div className='flex items-center justify-between gap-4'>
            <Text size='xs'>Subtotal</Text>
            <Text size='md'>
              {formatMoney(Number(cartPrice.subtotalAmount))}
            </Text>
          </div>
        </div>
        <div className='flex flex-col gap-3'>
          <div className='flex items-center justify-between gap-4'>
            <Text size='md'>Total</Text>
            <Text size='xl'>{formatMoney(Number(cartPrice.totalAmount))}</Text>
          </div>
          <div className='flex flex-col items-stretch gap-3'>
            <Button
              disabled={adding || !hasCompleteDetails || loading}
              onClick={() => router.push(checkoutUrl)}
            >
              Checkout
            </Button>
            {!hasCompleteDetails && (
              <Text size='xs'>
                * Please fill up your information before checkout.
              </Text>
            )}
          </div>
        </div>
      </div>
    </>
  )
}

Here is an update of the CartItem component accomodating the component above.

Append this to your <project>/app/cart/cartitem.tsx file

'use client'

import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { TbShoppingCartOff } from 'react-icons/tb'
import { PiPencilSimpleLineThin, PiTrashSimpleThin } from 'react-icons/pi'
import { TfiClose, TfiCheck } from 'react-icons/tfi'
import useCart from '../hooks/usecart'
import { Button, MiniBox, Text } from '../components/elements'
import Image from 'next/image'
import { formatMoney } from '@/lib/product'
import CartInfo, { BuyerIdentity } from './cartinfo'

interface Attribute {
  key: string
  value: string
}

interface CartLine {
  attributes: Attribute[]
  optionAttributes: { [key: string]: any }
  id: string
  merchandiseId: string
  quantity: number
  price?: number
  title?: string
  featuredImage?: string
  maxQuantity?: number
}

interface Cart {
  id: string
  checkoutUrl: string
  cartLines: CartLine[]
  buyerIdentity: BuyerIdentity
}

export default function CartItems() {
  const router = useRouter()
  const [loading, setLoading] = useState<boolean>(true)
  const [cart, setCart] = useState<Cart>()
  const { adding, cartId, cartPrice, deleteLine, editLine } = useCart()

  const extractAttributes = (lines: CartLine[]) => {
    return lines.map((line) => {
      const attributes = Object.fromEntries(
        line.attributes.map(({ key, value }: Attribute) => [key, value])
      )
      const { price, title, featuredImage, maxQuantity, ...rest } = attributes

      return {
        title,
        featuredImage,
        price: Number(price),
        maxQuantity: Number(maxQuantity),
        ...line,
        optionAttributes: rest,
      }
    })
  }

  const deleteCartLine = (line: CartLine) => {
    if (cart) {
      // Rmove the line from the cart
      const newCartLines = cart?.cartLines.filter(({ id }) => id !== line.id)
      setCart({ ...cart, cartLines: newCartLines })

      // Remove the line from store db
      deleteLine(line)
    }
  }

  const editCartLine = (oldLine: CartLine, newAmount: number) => {
    if (cart) {
      const newLine = { ...oldLine, quantity: newAmount }

      // Edit the line in the cart
      const updatedCartLines = cart?.cartLines.map((line) => {
        if (line.id === newLine.id) {
          return newLine
        }

        return line
      })

      setCart({ ...cart, cartLines: updatedCartLines })

      // CartLine here should match the object structure of db cartline
      const editedLine = {
        id: newLine.id,
        merchandiseId: newLine.merchandiseId,
        quantity: newLine.quantity,
        attributes: newLine.attributes,
      }

      editLine(editedLine)
    }
  }

  useEffect(() => {
    setLoading(true)

    if (cartId) {
      fetch(`/api/cart?cartId=${cartId}&type=1`, {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      })
        .then((res) => res.json())
        .then((data) =>
          setCart({
            ...data?.body,
            cartLines: extractAttributes(data?.body.cartLines),
          })
        )
        .finally(() => setLoading(false))
    }

    setLoading(false)
  }, [])

  if (!loading && !cartId)
    return (
      <div className='flex flex-col w-full items-center justify-center mt-20'>
        <TbShoppingCartOff className='text-9xl text-gray-300' />
        <Text size='md' faded>
          Your cart is empty
        </Text>
      </div>
    )

  return (
    <div className='grid grid-cols-12 place-content-between items-stretch gap-16 py-6'>
      {loading && (
        <div className='col-span-full flex justify-center mt-12'>
          <Text size='md'>Loading...</Text>
        </div>
      )}
      {cart && (
        <>
          <div className='col-span-8 flex flex-col gap-10'>
            {cart.cartLines.map((line) => (
              <CartItem
                key={line.id}
                line={line}
                deleteLine={deleteCartLine}
                editLine={editCartLine}
              />
            ))}
          </div>
          <div className='col-span-4 flex flex-col gap-16 p-8 h-fit w-full ring ring-gray-200'>
            <CartInfo
              cartId={cart.id}
              defaultBuyerIdentity={cart.buyerIdentity}
              checkoutUrl={cart.checkoutUrl}
            />
          </div>
        </>
      )}
    </div>
  )
}

function CartItem({
  line,
  deleteLine,
  editLine,
}: {
  line: CartLine
  deleteLine: (line: CartLine) => void
  editLine: (line: CartLine, newAmount: number) => void
}) {
  const [inEditMode, setInEditMode] = useState(false)
  const {
    title,
    featuredImage,
    price,
    quantity,
    maxQuantity,
    optionAttributes,
  } = line
  const [newAmount, setNewAmount] = useState(quantity)
  const hasImage = featuredImage && title
  const options = Object.entries(optionAttributes)
    .map(([key, value]) => value)
    .join('.|.')
    .split('.')

  const EditModeControl = () => (
    <div className='flex justify-start items-center gap-4'>
      <button
        type='button'
        onClick={() => {
          editLine(line, newAmount)
          setInEditMode(false)
        }}
        className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
        title='Save line edit'
      >
        <TfiCheck className='text-2xl text-black' />
      </button>
      <button
        type='button'
        onClick={() => {
          setNewAmount(quantity)
          setInEditMode(false)
        }}
        className='w-14 h-14 flex justify-center items-center border border-black bg-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
        title='Cancel line edit'
      >
        <TfiClose className='text-2xl text-white' />
      </button>
    </div>
  )

  const EditAmountPanel = () => (
    <div className='flex justify-start items-center gap-4'>
      <MiniBox
        onClick={() => newAmount > 1 && setNewAmount((prev) => prev - 1)}
      >
        -
      </MiniBox>
      <Text size='md'>{newAmount.toString()}</Text>
      <MiniBox
        onClick={() =>
          newAmount < (maxQuantity ?? 0) && setNewAmount((prev) => prev + 1)
        }
      >
        +
      </MiniBox>
    </div>
  )

  return (
    <div className='relative grid grid-cols-9 place-items-stretch min-h-[20rem] xl:gap-16 gap-8 py-16 px-8 border-b border-black/40 last:border-none'>
      <div className={`col-span-2 w-full ${!hasImage && 'bg-gray-300'}`}>
        {hasImage && (
          <Image
            src={featuredImage}
            alt={title}
            width={400}
            height={400}
            className='w-full object-cover'
          />
        )}
      </div>
      <div className='col-span-7 w-full flex flex-col gap-6'>
        <Text size='lg'>{title ?? '...'}</Text>
        <div className='flex justify-start items-center gap-6'>
          {inEditMode ? (
            <EditAmountPanel />
          ) : (
            <Text size='lg'>{quantity.toString()}</Text>
          )}
          <Text size='lg'>x</Text>
          <Text size='lg'>{formatMoney(price ?? 0)}</Text>
        </div>
        <div className='flex justify-start items-center gap-6'>
          {options.map((option, id) => (
            <Text key={option + id} size='md'>
              {option}
            </Text>
          ))}
        </div>
      </div>
      <div className='absolute top-0 right-0 flex items-stretch justify-center gap-4'>
        {inEditMode ? (
          <EditModeControl />
        ) : (
          <button
            type='button'
            onClick={() => setInEditMode(true)}
            className='w-14 h-14 flex justify-center items-center border border-black hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
            title='Edit line'
          >
            <PiPencilSimpleLineThin className='text-2xl text-black' />
          </button>
        )}
        <button
          type='button'
          onClick={() => deleteLine(line)}
          className='w-14 h-14 flex justify-center items-center border border-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black'
          title='Delete line'
        >
          <PiTrashSimpleThin className='text-2xl text-red-500' />
        </button>
      </div>
    </div>
  )
}

Without the customer complete information the checkout button is disabled to avoid customers moving to the checkout URL with incomplete information on delivery.

If you haven't granted an unauthorised_user access to your customer in the headless app of in your Shopify admin store. It might prevent you from getting results. So just go to headless and edit the access of your created storefront.

This will make sure the checkout process is smooth.

One more thing.

Remember our product page. The BUY button doesn't work as it should. You need to fix that. When the buy button is clicked, the product should be added to the cart and the the customer should be taken immediately to the cart page.

Update your button in the <project>/app/collection/[cid]/product/[pid]/details.tsx file:

...
            <Button
              onClick={() => {
                addToCart()
                router.push('/cart')
              }}
              disabled={
                (variant?.quantityAvailable ?? 0) < 1 || loading || adding
              }
            >
              Buy
            </Button>
...

And we are done. Yes, I meant it.

Now everything works.

Congratulations on staying till the end.

It was a rough road but we are here at last. Now you can build online stores with Shopify and Next.js together with ease.

You will find the full code for this tutorial in this GitHub repo.

If you are an enthusiast like me, you should make the store responsive. I know I will.

Practice and have fun.