Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shopify Storefront API shouldn't be used on the server #6

Open
gabrielvincent opened this issue May 13, 2022 · 4 comments
Open

Shopify Storefront API shouldn't be used on the server #6

gabrielvincent opened this issue May 13, 2022 · 4 comments

Comments

@gabrielvincent
Copy link

gabrielvincent commented May 13, 2022

From Shopify's docs:

The Storefront API is rated limited by the buyer IP and can not be utilized server-side or with a proxy for this reason.
The Storefront API utilizes a time-based leaky bucket algorithm and every request to the Storefront API costs at least 0.5 seconds to run.

This means that every request that hasn't already been cached on Redis will be forwarded to Shopify Storefront API. This is the case for every possible cart. Consider the following scenario:

  1. User A adds first Product X and then Product Y to the cart and proceeds to checkout.
  2. Ten minutes later User B adds first Product Y and then Product X to the cart and proceeds to checkout.

Both actions call the getCheckoutUrl function.

Even though the carts have the same products, User B won't hit a cache because the hash used for caching that was produced by User A's request is different from User B's due to the different sequence with which the items were added. This becomes even more problematic if you give some customAttributes to each lineItem added to the cart, since each different set of customAttributes will result in a cache miss.

One solution I thought of is to perform on the server only API calls that are more cacheable (such as getProduct or getPage) but what happens when the cache goes stale and we have to revalidate it? In a medium-sized store we'd hit the API rate limit very quickly.

Am I missing something here? It seems to me that using exclusively SSR might not be the best strategy for building a Shopify storefront.

@tylerhellner
Copy link

tylerhellner commented May 13, 2022

Good catch, although I do think the title of the post is a little misleading. The Storefront API handles everything from fetching products, collections, images, metafields, so on and so forth. It's only things with unique identifiers to their requests that change a heck of a lot — i.e. the checkout and the cart — that should be run on the client. This spreads out the rate-limiting across every IP that is buying something. Not doing so would, as you said, make every purchase come from our server, which would only allow about ~0.5 to 2 checkouts per second to stay around the bucket leak rate.

I've been playing around with this app for a week or so for work and am thinking of building it out into a production store. Have you done any work on moving the checkout and cart client-side? I've set aside some time this weekend to work on it.

@gabrielvincent
Copy link
Author

I haven't yet done the job of moving some of those requests to the client. I'll work on that. Although it raises the question: would it still make sense to use Remix if I have to delegate to the client many of the requests a user will make during their purchase journey? Wouldn't that kind of defeat the whole purpose of Remix? I mean, if I can't be parallelizing API calls before rendering, then I'm missing on Remix's core architectural feature. I'd still have to deal with mutations and state syncing on the client. If I have to go down that path, I think maybe I should stick to NextJS for now since it has a more mature, production-ready e-commerce solution.

My use-case is the following. I sell hyper-customized products. Each product has a landing page that looks like this:

image

The user can customize their product. When they finish, they see this page:

image

The landing page can easily be SSG, but the customized page needs to be SSR.

These screenshots are from a NextJS experiment I'm running (https://beta.ephemeris.co/product/birth-chart-talisman).

If I choose to go with NextJS I'd lose the performance of server-side rendering on the edge (which really would only make a big difference when rendering the customized product pages), but I'd initially gain the development speed and stability of a more battle-tested solution. A high-performant landing page is much more important because it's the entry point for potential buyers and NextJS delivers on that pretty well due to its SSG capabilities.
On the other hand, I believe that in the long run Remix would provide higher overall code maintainability. The problem is that right now I'd have to do a lot of work to move those mutations related to Cart and Checkout from server to client side. I wonder if I could re-use NextJS Commerce Shopify provider in a Remix project. I think I'll give that a try.

@gabrielvincent
Copy link
Author

@tylerhellner I abandoned the idea of re-using NextJS Commerce's Shopify Provider. That would be too much work. I decided to manually move all cart/checkout requests to the client. It was really not that hard.

First thing I had to do was clone ~/models/shopify.server.ts into ~/models/shopify.client.ts. All I did here was remove stuff related to Redis. Here's ~/models/shopify.client.ts:

export function createShopifyProvider({
  shop,
  storefrontAccessToken,
}: ShopifyProviderOptions) {
  let href = `https://${shop}.myshopify.com/api/2021-10/graphql.json`;

  async function query(query: string, variables?: any) {
    let request = new Request(href, {
      method: "POST",
      headers: {
        "Accept-Language": "en",
        "Content-Type": "application/json",
        "X-Shopify-Storefront-Access-Token": storefrontAccessToken,
      },
      body: JSON.stringify({ query, variables }),
    });

    return fetch(request).then((res) => res.json());
  }

  async function createCart(items: NewCartItem[]): Promise<Cart> {
    const lines = items.map((item) => ({
      merchandiseId: item.variantId,
      quantity: item.quantity,
      attributes: item.customAttributes,
    }));
    const cart = await query(createCartMutation, { lines }).then(
      (response) => response.data.cartCreate.cart
    );

    return normalizeCartOutput(cart);
  }

  async function addItemsToCart(
    cartId: string,
    items: CartItem[]
  ): Promise<Cart> {
    const lines = items.map((item) => ({
      merchandiseId: item.variantId,
      quantity: item.quantity,
      attributes: item.customAttributes,
    }));
    const cart = await query(cartLinesAddMutation, { cartId, lines }).then(
      (response) => response.data.cartLinesAdd.cart
    );

    return normalizeCartOutput(cart);
  }

  return {
    async addToCart(items: NewCartItem[] | CartItem[]): Promise<Cart> {
      const cart = localStorage.getItem<Cart>("cart");

      return cart
        ? addItemsToCart(cart.id, items as CartItem[])
        : createCart(items as NewCartItem[]);
    },

    async removeFromCart(cartId: string, lineIds: string[]) {
      const cart = await query(cartLinesRemoveMutation, {
        cartId,
        lineIds,
      }).then((response) => response.data.cartLinesRemove.cart);

      return normalizeCartOutput(cart);
    },

    async updateCartItems(cartId: string, items: Partial<CartItem>[]) {
      const lines = items.map((item) => ({
        id: item.id,
        merchandiseId: item.variantId,
        quantity: item.quantity,
        attributes: item.customAttributes,
      }));
      const cart = await query(cartLinesUpdateMutation, {
        cartId,
        lines,
      }).then((response) => response.data.cartLinesUpdate.cart);

      return normalizeCartOutput(cart);
    },
  };
}

Then I had to do the same thing to ~/app/commerce.server.ts, which was cloned into ~/app/commerce.client.ts. Again, this is very similar to the server version, but without the creation of the Redis cache:

import { createShopifyProvider } from "./models/ecommerce-providers/shopify.client";

let commerce = createShopifyProvider({
  shop: "SHOP_NAME",
  storefrontAccessToken: "STOREFRONT_ACCESS_TOKEN",
});

export default commerce;

Because cart mutations live on the client now, I had to deal with backend-frontend state syncing. I did that by creating a CartProvider that enables the useCart hook. Here's some example usage:

const AddToCartButton: React.FC<AddToCartButtonProps> = ({
  variant,
  getCustomAttributes,
}) => {
  const { openCart } = useCartDrawer();
  const customAttributes = getCustomAttributes();
  const { addItem, status } = useCart();

  const handleAddToCart = async () => {
    await addItem({ quantity: 1, variantId: variant.id, customAttributes });
    openCart();
  };

  return (
    <button onClick={handleAddToCart} disabled={status === "loading"}>
      {status === "idle" ?  "Add to cart" : <Spinner />}
    </button>
  );
};

And here's what the actions exposed by useCart look like:

const actions = (dispatch: DispatchFunction) => {
  function setStatus(status: State["status"]) {
    dispatch({
      type: "SET_STATUS",
      payload: { status },
    });
  }

  async function performCartMutation(mutate: () => Promise<Cart>) {
    setStatus("loading");

    const cart = await mutate();
    persistCart(cart);

    setStatus("idle");

    dispatch({
      type: "SET_CART",
      payload: { cart },
    });
  }

  return {
    addItem(item: NewCartItem) {
      return performCartMutation(() => commerce.addToCart([item]));
    },

    updateItem(cartId: string, item: CartItem) {
      return performCartMutation(() =>
        commerce.updateCartItems(cartId, [item])
      );
    },

    removeItem(cartId: string, itemId: string) {
      return performCartMutation(() =>
        commerce.removeFromCart(cartId, [itemId])
      );
    },
  };
};

Finally, I had to change ~/app/component/checkout-form.tsx so it won't fire a server-side action anymore. It's really no longer a form at all, it's just a button now:

export function CheckoutForm({ className }: { className: string }) {
  const { cart, status } = useCart();

  return (
    <button
      className={cn(
        "mt-6 py-4 bg-gray-50 text-gray-900 block w-full text-center font-semibold uppercase",
        status === "loading" && "opacity-50 pointer-events-none"
      )}
      onClick={() => {
        if (!cart) return;
        window.location.href = cart.checkoutUrl;
      }}
    >
      {status === "idle" ? "Proceed to checkout" : <Spinner />}
    </button>
  );
}

@jacob-ebey
Copy link
Owner

Shopify API is fine to call from the server. IDK why the docs say that. Please check out the Hydrogen project for Remix ecomm best practices. I am not maintaining this and will probably never look at this repo again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants