REMIX YOUR SHOP WITH SHOPIFY HYDROGEN
01
02
03
HEADLESS COMMERCE
Definition, importance, comparison
SHOPIFY HYDROGEN
Introduction, features
HYDROGEN CUSTOM STOREFRONT
Let's build a storefront!
Separation of frontend and backend
API driven architecture
Flexible, customizable front-end
Omnichannel selling
Faster time to market
Better performance
Better SEO
Personalization and customization
Improved security
Well, some of them...
HEADLESS
MONOLITHIC
npm create @shopify/hydrogen@latest
Hydrogen provides a storefront client to send queries and mutations from Remix loaders and actions
In routes create a collections folder
In routes create a products folder
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,
});
};
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 functions to create a Cart, add line items to the cart, and remove items from the cart
Each function will use the context.storefront
cartCreate
creates a cart with line item data using the CREATE_CART_MUTATION
queryexport async function cartCreate({input, storefront}) {
const {cartCreate} = await storefront.mutate(CREATE_CART_MUTATION, {
variables: {input},
});
return cartCreate;
}
cartAdd
adds line items to an existing cart using the ADD_LINES_MUTATION
queryexport async function cartAdd({cartId, lines, storefront}) {
const {cartLinesAdd} = await storefront.mutate(ADD_LINES_MUTATION, {
variables: {cartId, lines},
});
return cartLinesAdd;
}
cartRemove
removes line items from a cart using the REMOVE_LINE_ITEMS_MUTATION
queryexport 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;
}
A route can only have 1 action, so we create a switch to run the function based on the submitted formAction input
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>
);
};
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;
}
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;
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,
});
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;
}
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});
};
Finally render our cart data on the cart route
const Cart = () => {
const {cart} = useLoaderData();
return cart?.totalQuantity > 0 ? (
<CartContent cart={cart} location="page" />
) : (
<CartEmpty />
);
};
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,
});
}
Build the CartDrawer component:
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>