LIGHTNING FAST

E-COMMERCE

REMIX YOUR SHOP WITH SHOPIFY HYDROGEN

Alexandra Spalato

Developer Relations Engineer

alexa.spalato@storyblok.com

 

OVERVIEW

01

02

03

HEADLESS COMMERCE

Definition, importance, comparison

 

SHOPIFY HYDROGEN

Introduction, features

 

HYDROGEN CUSTOM STOREFRONT

Let's build a storefront!

 

HEADLESS COMMERCE DEFINITION

  • Separation of frontend and backend

  • API driven architecture

  • Flexible, customizable front-end

BENEFITS OF HEADLESS COMMERCE

  • Omnichannel selling

  • Faster time to market

  • Better performance

  • Better SEO

  • Personalization and customization

  • Improved security

Well, some of them...

HEADLESS VS MONOLITHIC

HEADLESS

  • Front-end presentation layer separated from back-end
  • Enables businesses to create and deliver custom shopping experiences
  • More flexibility, scalability, and agility
     

MONOLITHIC

  • Front-end and back-end tightly integrated
  • Limited flexibility, scalability, and agility
  • Not easily adaptable to changing consumer preferences and market trends

     

INTRODUCING SHOPIFY HYDROGEN

  • New framework for building headless Shopify stores
  • Simplified development experience
  • Built-in best practices
  • Built on Remix!
     

KEY FEATURES OF HYDROGEN

  • Components-based architecture
  • Improved performance with REMIX
  • Seamless integration with Shopify APIs

CREATING A CUSTOM STOREFRONT

  • Creating the project
  • Fetching  and mutating data in Hydrogen
  • Building the collections and product
  • Building a cart

CREATE THE PROJECT

npm create @shopify/hydrogen@latest

FETCHING AND MUTATING DATA IN HYDROGEN

Hydrogen provides a storefront client to send queries and mutations from Remix loaders and actions

 

  • Create and inject the Storefront client in server.js
     
  • Call the storefront client in loaders and actions
    • Use storefront.query() to send GraphQL queries to your storefront and load data
       
    • Use storefront.mutate() to perform GraphQL mutations in Remix actions
       

CREATING COLLECTIONS

In routes create a collections folder

  • index.jsx will display all the collections entries
  • $handle.jsx will display the title, description and paginated products form a collection
     

CREATING PRODUCTS

In routes create a products folder

  • $handle.jsx will display the product details
  • Use hydrogen-react components: Money, Image, ShopPayButton
     

ADD A SHOP PAY BUTTON

export const loader = async ({params, context, request}) => {
  const {handle} = params;
  const searchParams = new URL(request.url).searchParams;
  const selectedOptions = [];

  searchParams.forEach((value, name) => {
    selectedOptions.push({name, value});
  });

  const {product} = await context.storefront.query(PRODUCT_QUERY, {
    variables: {
      handle,
      selectedOptions,
    },
  });

  if (!product?.id) {
    throw new Response(null, {status: 404});
  }

  const selectedVariant =
    product.selectedVariant ?? product?.variants?.nodes[0];
  const storeDomain = context.storefront.getShopifyDomain();

  return json({
    product,
    selectedVariant,
    storeDomain,
  });
};

BUILDING A CART

  • Client side cart operations degrade performance and UX
  • Remix mutations API leverage web standards like form and HTTP
  • Let’s build a performant server-side cart!

CREATE A CART ROUTE

  • app/routes/cart.jsx
  • Create an action that handles a form submission to the cart route
export async function action({request, context}) {
  const {session, storefront} = context;
  const headers = new Headers();

  const [formData, storedCartId] = await Promise.all([
    request.formData(),
    session.get('cartId'),
  ]);

  let cartId = storedCartId;

  let status = 200;
  let result;

  // TODO form action
}

CREATE THE API FUNCTIONS

Create functions to create a Cart, add line items to the cart, and remove items from the cart

Each function will use the context.storefront

CREATE CART

  • cartCreate creates a cart with line item data using the CREATE_CART_MUTATION query
export async function cartCreate({input, storefront}) {
  const {cartCreate} = await storefront.mutate(CREATE_CART_MUTATION, {
    variables: {input},
  });

  return cartCreate;
}

ADD TO CART

  • cartAdd adds line items to an existing cart using the ADD_LINES_MUTATION query
export async function cartAdd({cartId, lines, storefront}) {
  const {cartLinesAdd} = await storefront.mutate(ADD_LINES_MUTATION, {
    variables: {cartId, lines},
  });

  return cartLinesAdd;
}

REMOVE FROM CART

  • cartRemove removes line items from a cart using the REMOVE_LINE_ITEMS_MUTATION query
export async function cartRemove({cartId, lineIds, storefront}) {
  const {cartLinesRemove} = await storefront.mutate(
    REMOVE_LINE_ITEMS_MUTATION,
    {
      variables: {
        cartId,
        lineIds,
      },
    },
  );

  if (!cartLinesRemove) {
    throw new Error('No data returned from remove lines mutation');
  }
  return cartLinesRemove;
}

CALL THE FUNCTIONS FROM THE ACTION

A route can only have 1 action, so we create a switch to run the function based on the submitted formAction input

CREATE AN ADD TO CART BUTTON

import {useFetcher} from '@remix-run/react';

export const AddToCartButton = ({variantId}) => {
  const fetcher = useFetcher();

  const lines = [{merchandiseId: variantId, quantity: 1}];

  return (
    <fetcher.Form action="/cart" method="post">
      <input type="hidden" name="cartAction" value={'ADD_TO_CART'} />
      <input type="hidden" name="lines" value={JSON.stringify(lines)} />
      <button className="bg-black text-white px-6 py-3 w-full rounded-md text-center font-medium max-w-[400px]">
        Add to Cart
      </button>
    </fetcher.Form>
  );
};

CREATE AN ADD TO CART BUTTON

  switch (cartAction) {
    case 'ADD_TO_CART':
      const lines = formData.get('lines')
        ? JSON.parse(String(formData.get('lines')))
        : [];

      if (!cartId) {
        result = await cartCreate({
          input: countryCode ? {lines, buyerIdentity: {countryCode}} : {lines},
          storefront,
        });
      } else {
        result = await cartAdd({
          cartId,
          lines,
          storefront,
        });
      }

      cartId = result.cart.id;
      break;
    
      }

CREATE A REMOVE FROM CART BUTTON

import {useFetcher} from '@remix-run/react';
import {BsTrash3 as IconRemove} from 'react-icons/bs';

const RemoveFromCartButton = ({lineIds}) => {
  const fetcher = useFetcher();
  return (
    <fetcher.Form action="/cart" method="post">
      <input type="hidden" name="cartAction" value="REMOVE_FROM_CART" />
      <input type="hidden" name="linesIds" value={JSON.stringify(lineIds)} />
      <button
        className="flex items-center justify-center w-10 h-10 max-w-xl my-2 leading-none text-center text-black bg-white border border-black rounded-md hover:text-white hover:bg-black font-small"
        type="submit"
      >
        <IconRemove />
      </button>
    </fetcher.Form>
  );
};

export default RemoveFromCartButton;

CREATE A REMOVE FROM CART BUTTON

case 'REMOVE_FROM_CART':
      const lineIds = formData.get('linesIds')
        ? JSON.parse(String(formData.get('linesIds')))
        : [];

      if (!lineIds.length) {
        throw new Error('No lines to remove');
      }

      result = await cartRemove({
        cartId,
        lineIds,
        storefront,
      });

FETCH AND READ THE CART

  • Create a cart query in app/queries/cart.js
  • Create a cart fetcher function
  • Load the cart

FETCH AND READ THE CART

  • Create a cart fetcher function: getCart() 
  • Disable the cache to avoid stale data
export async function getCart({storefront}, cartId) {
  if (!storefront) {
    throw new Error('missing storefront client in cart query');
  }

  const {cart} = await storefront.query(CART_QUERY, {
    variables: {
      cartId,
      country: storefront.i18n.country,
      language: storefront.i18n.language,
    },
    cache: storefront.CacheNone(),
  });

  return cart;
}

FETCH AND READ THE CART

Load the Cart

import {getCart} from '~/utils/getCart';

export const loader = async ({context}) => {
  const cartId = await context.session.get('cartId');
  return json({cart: cartId ? await getCart(context, cartId) : undefined});
};

CREATE THE CART COMPONENTS

  • LineItem.jsx: app/components/cart/LineItem.jsx
  • CartLineItems.jsx: app/components/cart/CartLineItems.jsx
  • CartSummary.jsx: app/components/cart/CartSummary.jsx
  • CheckoutButton.jsx: app/components/cart/CheckoutButton.jsx
  • CartContent.jsx: app/components/cart/CartContent.jsx

CREATE THE CART COMPONENTS

Finally render our cart data on the cart route

const Cart = () => {
  const {cart} = useLoaderData();
  return cart?.totalQuantity > 0 ? (
    <CartContent cart={cart} location="page" />
  ) : (
    <CartEmpty />
  );
};

CREATE A CART DRAWER

  • create Drawer.jsx: app/components/Drawer.jsx using Headless UI
  • Fetch the cart in the root loader to make it globally available
  • Create the CartDrawer

CREATE A CART DRAWER

Fetch the cart inside the loader 

import {defer} from '@shopify/remix-oxygen';

export async function loader({context}) {
  const cartId = await context.session.get('cartId');
  
  const layout = await context.storefront.query(LAYOUT_QUERY);
  
  return defer({
    layout,
    cart: cartId ? getCart(context, cartId) : undefined,
  });
}

CREATE A CART DRAWER

Build the CartDrawer component:

  • useLoaderData to get the cart data from the root
  • Suspense and Await to resolve the cart deffered value
  const {cart} = useLoaderData();
    <Suspense>
      <Await resolve={cart}>
        {(data) => (
          <>
            <button
              className="relative flex items-center justify-center w-8 h-8 ml-auto"
              onClick={openDrawer}
            >
              <IconCart />
              {data?.totalQuantity > 0 && (
                <div className="text-contrast bg-red-500 text-white absolute bottom-1 right-1 text-[0.625rem] font-medium subpixel-antialiased h-3 min-w-[0.75rem] flex items-center justify-center leading-none text-center rounded-full w-auto px-[0.125rem] pb-px">
                  <span>{data?.totalQuantity}</span>
                </div>
              )}
            </button>
            <Drawer open={isOpen} onClose={closeDrawer}>
              <>
                {data?.totalQuantity > 0 ? (
                  <CartContent cart={data} />
                ) : (
                  <CartEmpty />
                )}
                <button
                  onClick={closeDrawer}
                  className="inline-block w-full max-w-xl px-6 py-3 font-medium leading-none text-center text-white bg-black rounded-sm"
                ></button>
              </>
            </Drawer>
          </>
        )}
      </Await>
    </Suspense>
Made with Slides.com