-
Notifications
You must be signed in to change notification settings - Fork 87
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
Comments
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. |
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: The user can customize their product. When they finish, they see this page: 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. |
@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 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 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 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 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 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>
);
} |
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. |
From Shopify's docs:
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:
User A
adds firstProduct X
and thenProduct Y
to the cart and proceeds to checkout.User B
adds firstProduct Y
and thenProduct 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 byUser A
's request is different fromUser B
's due to the different sequence with which the items were added. This becomes even more problematic if you give somecustomAttributes
to each lineItem added to the cart, since each different set ofcustomAttributes
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
orgetPage
) 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.
The text was updated successfully, but these errors were encountered: