From atum-cms-ecom
Shopify Hydrogen (headless React storefront) pattern library — Hydrogen 2024+ with Remix + Vite, Oxygen edge deployment, Storefront API GraphQL queries (buyer-facing), Customer Account API (2024+ replacement for customer auth), cart management via CartProvider and useCart, products/collections/cart/checkout loaders, i18n markets and currencies, caching strategies (CacheShort/CacheLong/CacheCustom), SEO with remix-oxygen meta, analytics integration, and Shopify CDN image optimization. Use when building or maintaining Hydrogen storefronts: React components consuming the Storefront API, Remix loaders/actions for product/collection/cart routes, Oxygen deployment configuration, i18n setup, or migration from Liquid themes to headless. Differentiates from atum-stack-web Next.js generic patterns by covering Shopify-specific primitives (Storefront API, cart mutations, Customer Account API, Oxygen edge caching) and from shopify-liquid-patterns (server-side Liquid templating).
npx claudepluginhub arnwaldn/atum-plugins-collection --plugin atum-cms-ecomThis skill uses the workspace's default tool permissions.
Patterns pour Hydrogen 2024+, le framework headless officiel Shopify basé sur Remix + Vite, déployé sur Oxygen (CDN edge Shopify).
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Executes pre-written implementation plans: critically reviews, follows bite-sized steps exactly, runs verifications, tracks progress with checkpoints, uses git worktrees, stops on blockers.
Patterns pour Hydrogen 2024+, le framework headless officiel Shopify basé sur Remix + Vite, déployé sur Oxygen (CDN edge Shopify).
Attention : Hydrogen a changé d'architecture en 2023 (ancien Hydrogen 1 sur Vite custom → Hydrogen 2+ sur Remix + Vite standard). Tout le code ancien est obsolète.
hydrogen-storefront/
├── app/
│ ├── root.tsx # Layout racine + meta + links
│ ├── entry.client.tsx # Hydration client
│ ├── entry.server.tsx # SSR Remix
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── Footer.tsx
│ │ ├── ProductCard.tsx
│ │ └── Cart.tsx
│ ├── routes/
│ │ ├── _index.tsx # Homepage
│ │ ├── products.$handle.tsx # Product page
│ │ ├── collections.$handle.tsx
│ │ ├── cart.tsx
│ │ ├── account.tsx
│ │ └── api.*.tsx # API routes
│ ├── lib/
│ │ ├── fragments.ts # Fragments GraphQL partagés
│ │ ├── session.server.ts # Session management
│ │ └── seo.ts
│ └── styles/
├── public/
├── server.ts # Oxygen entry point
├── vite.config.ts
├── tsconfig.json
└── package.json
npm create @shopify/hydrogen@latest
# Choisir : JavaScript ou TypeScript, starter, routing, i18n
// app/routes/products.$handle.tsx
import { json, type LoaderFunctionArgs } from "@shopify/remix-oxygen";
import { useLoaderData } from "@remix-run/react";
const PRODUCT_QUERY = `#graphql
query Product($handle: String!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
product(handle: $handle) {
id
title
vendor
handle
descriptionHtml
featuredImage {
url
altText
width
height
}
priceRange {
minVariantPrice { amount currencyCode }
maxVariantPrice { amount currencyCode }
}
variants(first: 100) {
nodes {
id
title
availableForSale
selectedOptions { name value }
image { url altText }
price { amount currencyCode }
compareAtPrice { amount currencyCode }
}
}
seo { title description }
}
}
`;
export async function loader({ params, context }: LoaderFunctionArgs) {
const { handle } = params;
if (!handle) throw new Response("Missing handle", { status: 400 });
const { product } = await context.storefront.query(PRODUCT_QUERY, {
variables: {
handle,
country: context.storefront.i18n.country,
language: context.storefront.i18n.language,
},
cache: context.storefront.CacheLong(),
});
if (!product) {
throw new Response("Product not found", { status: 404 });
}
return json({ product });
}
export default function Product() {
const { product } = useLoaderData<typeof loader>();
return (
<article>
<h1>{product.title}</h1>
{product.featuredImage && (
<img
src={product.featuredImage.url}
alt={product.featuredImage.altText ?? product.title}
width={product.featuredImage.width}
height={product.featuredImage.height}
loading="eager"
/>
)}
{/*
Rendre product.descriptionHtml requiert une sanitization côté serveur.
Option 1 : pré-sanitizer avec DOMPurify côté loader avant json().
Option 2 : utiliser @shopify/hydrogen <RichText /> qui sanitize.
Ne jamais injecter descriptionHtml brut dans le DOM sans filtre.
*/}
{/* Variant picker + add-to-cart ici */}
</article>
);
}
// app/routes/cart.tsx
import { json, type ActionFunctionArgs } from "@shopify/remix-oxygen";
import { CartForm, type CartQueryDataReturn } from "@shopify/hydrogen";
export async function action({ request, context }: ActionFunctionArgs) {
const { cart } = context;
const formData = await request.formData();
const { action: cartAction, inputs } = CartForm.getFormInput(formData);
let result: CartQueryDataReturn;
switch (cartAction) {
case CartForm.ACTIONS.LinesAdd:
result = await cart.addLines(inputs.lines);
break;
case CartForm.ACTIONS.LinesUpdate:
result = await cart.updateLines(inputs.lines);
break;
case CartForm.ACTIONS.LinesRemove:
result = await cart.removeLines(inputs.lineIds);
break;
default:
throw new Error(`Unknown cart action ${cartAction}`);
}
const { cart: updatedCart, errors } = result;
return json({ cart: updatedCart, errors });
}
Usage côté UI :
// app/components/AddToCartButton.tsx
import { CartForm } from "@shopify/hydrogen";
export function AddToCartButton({ variantId }: { variantId: string }) {
return (
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesAdd}
inputs={{ lines: [{ merchandiseId: variantId, quantity: 1 }] }}
>
{(fetcher) => (
<button
type="submit"
disabled={fetcher.state !== "idle"}
>
{fetcher.state !== "idle" ? "Adding..." : "Add to cart"}
</button>
)}
</CartForm>
);
}
Hydrogen 2024 utilise le nouveau Customer Account API (pas l'ancien Storefront Customer API). Il fournit une UX d'authentification gérée par Shopify (OTP email/SMS, pas de mot de passe côté storefront).
// app/routes/account.tsx
import { json, redirect, type LoaderFunctionArgs } from "@shopify/remix-oxygen";
export async function loader({ context }: LoaderFunctionArgs) {
const { customerAccount } = context;
if (!(await customerAccount.isLoggedIn())) {
return redirect("/account/login");
}
const { data } = await customerAccount.query(`#graphql
query Customer {
customer {
id
firstName
lastName
emailAddress { emailAddress }
defaultAddress { formatted }
}
}
`);
return json({ customer: data.customer });
}
// CacheShort : 1 seconde (dev, volatile data)
cache: context.storefront.CacheShort();
// CacheLong : 1 heure (produits, collections stables)
cache: context.storefront.CacheLong();
// CacheCustom : custom max-age + stale-while-revalidate
cache: context.storefront.CacheCustom({
mode: "public",
maxAge: 60 * 5, // 5 min
staleWhileRevalidate: 60, // 1 min
});
// Pas de cache : données dynamiques (cart, customer)
cache: context.storefront.CacheNone();
Règle : produits et collections → CacheLong. Cart et customer → CacheNone. Homepage → CacheCustom (mix).
import { Image } from "@shopify/hydrogen";
<Image
data={product.featuredImage}
aspectRatio="1/1"
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
/>
Hydrogen utilise automatiquement le CDN Shopify pour :
srcset multi-résolutions// app/lib/i18n.ts
export const COUNTRIES = {
default: { label: "United States", language: "EN", country: "US", currency: "USD" },
fr: { label: "France", language: "FR", country: "FR", currency: "EUR" },
de: { label: "Germany", language: "DE", country: "DE", currency: "EUR" },
};
export function getLocaleFromRequest(request: Request): I18nLocale {
const url = new URL(request.url);
const firstPathPart = url.pathname.split("/")[1];
return COUNTRIES[firstPathPart] ?? COUNTRIES.default;
}
Toutes les requêtes Storefront API doivent passer country et language dans @inContext(...) pour que Shopify retourne les bons prix/traductions.
# Login Shopify Partners
npm run shopify hydrogen login
# Lier à un store
npm run shopify hydrogen link
# Déployer
npm run shopify hydrogen deploy
# Preview local
npm run dev
Oxygen gère automatiquement :
loading="eager")font-display: swap)CacheShort/Long/Customdefer()<img> brut sans le composant <Image> de @shopify/hydrogencountry / language → toujours dériver de la locale (URL segment)@inContext dans les queries → prix et traductions incorrects par marchéCartForm → perte d'optimistic UI + error handling.env local committé → toujours via Oxygen environment variablesdescriptionHtml sans sanitization → XSS : utiliser un composant <RichText /> Hydrogen ou pré-sanitizer côté loader avec DOMPurify// app/routes/products.$handle.tsx
import type { MetaFunction } from "@shopify/remix-oxygen";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.product) return [];
return [
{ title: data.product.seo.title ?? data.product.title },
{ name: "description", content: data.product.seo.description ?? "" },
{
tagName: "link",
rel: "canonical",
href: `https://example.com/products/${data.product.handle}`,
},
{
"script:ld+json": {
"@context": "https://schema.org",
"@type": "Product",
name: data.product.title,
description: data.product.seo.description,
brand: { "@type": "Brand", name: data.product.vendor },
offers: {
"@type": "Offer",
price: data.product.priceRange.minVariantPrice.amount,
priceCurrency: data.product.priceRange.minVariantPrice.currencyCode,
availability: "https://schema.org/InStock",
},
},
},
];
};
atum-stack-web (nextjs-expert ou remix patterns génériques)shopify-liquid-patterns (dans ce plugin)shopify-expert (dans ce plugin)security-reviewer