Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions examples/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The scaffold ships an `AGENTS.md` (and `CLAUDE.md` pointer to it) that tells cod
## Stubbed vs. live

- **Live**: product data, collection data, article content, prices, images, options, cart state (server-fetched via `createCartServerHandlers().get` in the root layout and hydrated into `CartProvider`), cart drawer, cart page mutations, product variant selection, add-to-cart, checkout, Shop Pay.
- **Stubbed**: hero links, search, account, newsletter form, news index list, color swatch hex values (mapped client-side from option name → CSS color in `app/components/ProductDetails.tsx`).
- **Stubbed**: hero links, search, account, newsletter form, news index list, color swatch hex values (mapped client-side from option name → CSS color in `components/ProductDetails.tsx`).

## Run

Expand All @@ -50,10 +50,10 @@ Note: Next.js 16 does **not** cache `fetch` by default — every request to a se

Same shape as the React Router example — every framework port re-invents the same three pieces, so this is the feedback loop into the Hydrogen package in `packages/hydrogen`:

- `storefrontClient.graphql()` in `app/lib/storefront.ts` — now uses `createStorefrontClient` from `@shopify/hydrogen` with `gql.tada` for zero-config type inference. Error normalization and request-id propagation are handled by the core client.
- `formatMoney()` in `app/lib/money.ts` — every example needs money formatting from a `MoneyV2`-shaped object.
- `storefrontClient.graphql()` in `lib/storefront.ts` — now uses `createStorefrontClient` from `@shopify/hydrogen` with `gql.tada` for zero-config type inference. Error normalization and request-id propagation are handled by the core client.
- `formatMoney()` in `lib/money.ts` — every example needs money formatting from a `MoneyV2`-shaped object.
- `ProductCard` reads a narrow product shape (`handle`, `title`, `featuredImage`, `priceRange.minVariantPrice`). A core fragment + type for "product card" would let routes share GraphQL fragments instead of re-listing fields.
- Color swatch mapping (`SWATCHES` in `app/components/ProductDetails.tsx`) is hand-rolled — option-value → swatch metadata is a real merchant problem; the SDK should have an opinion on how to expose it.
- Color swatch mapping (`SWATCHES` in `components/ProductDetails.tsx`) is hand-rolled — option-value → swatch metadata is a real merchant problem; the SDK should have an opinion on how to expose it.

## Open questions

Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/app/blogs/news/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";

import { getStorefrontClient } from "../../../lib/storefront";
import { getStorefrontClient } from "@/lib/storefront";

const ARTICLE_QUERY = gql(`
query Article($handle: String!) {
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/app/blogs/news/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { gql } from "@shopify/hydrogen";
import Link from "next/link";
import { notFound } from "next/navigation";

import { getStorefrontClient } from "../../lib/storefront";
import { getStorefrontClient } from "@/lib/storefront";

const NEWS_QUERY = gql(`
query News {
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/app/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Metadata } from "next";

import { CartContent } from "../components/Cart";
import { CartContent } from "@/components/Cart";

export const metadata: Metadata = {
title: "Cart — Mock.shop",
Expand Down
10 changes: 5 additions & 5 deletions examples/nextjs/app/collections/[handle]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { CollectionBrowser } from "../../components/CollectionBrowser";
import { CollectionViewedTracker } from "../../components/CollectionViewedTracker";
import { queryCollection } from "../../lib/collection";
import { getStorefrontClient } from "../../lib/storefront";
import { pageSearchParamsToUrlSearchParams, type PageSearchParams } from "../../lib/url";
import { CollectionBrowser } from "@/components/CollectionBrowser";
import { CollectionViewedTracker } from "@/components/CollectionViewedTracker";
import { queryCollection } from "@/lib/collection";
import { getStorefrontClient } from "@/lib/storefront";
import { pageSearchParamsToUrlSearchParams, type PageSearchParams } from "@/lib/url";

type Props = {
params: Promise<{ handle: string }>;
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/app/collections/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { gql } from "@shopify/hydrogen";
import Link from "next/link";

import { getStorefrontClient } from "../lib/storefront";
import { getStorefrontClient } from "@/lib/storefront";

const COLLECTIONS_QUERY = gql(`
query Collections {
Expand Down
42 changes: 14 additions & 28 deletions examples/nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import { HEADER_COLLECTIONS_QUERY, normalizeHeaderCollections } from "@shared/header";
import type { Metadata } from "next";

import "./globals.css";
import { Inter } from "next/font/google";
import Script from "next/script";
import { Suspense } from "react";

import { AnalyticsTracker } from "./components/AnalyticsTracker";
import { CartDrawer } from "./components/CartDrawer";
import { Footer } from "./components/Footer";
import { Header } from "./components/Header";
import { Providers } from "./components/Providers";
import { cartHandlers } from "./lib/cart-handlers";
import { getStorefrontClient } from "./lib/storefront";
import { AnalyticsTracker } from "@/components/AnalyticsTracker";
import { CartDrawer } from "@/components/CartDrawer";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { Providers } from "@/components/Providers";

const inter = Inter({
subsets: ["latin"],
Expand All @@ -24,36 +21,25 @@ export const metadata: Metadata = {
title: "Mock.shop — Hydrogen",
};

export const dynamic = "force-dynamic";

export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const storefrontClient = await getStorefrontClient();
const [{ data: cartData }, { data: headerData }] = await Promise.all([
cartHandlers.get({ storefrontClient }),
storefrontClient.graphql(HEADER_COLLECTIONS_QUERY),
]);
const headerCollections = normalizeHeaderCollections(headerData?.collections?.nodes);

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={inter.variable}>
<head>
<Script
src="https://cdn.shopify.com/storefront/standard-actions.js"
type="module"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
</head>
<body className="bg-white text-black">
<Providers cart={cartData.cart}>
<Header collections={headerCollections} />
<Providers>
<Header />
<Suspense fallback={null}>
<AnalyticsTracker />
</Suspense>
{children}
<Footer />
<CartDrawer />
</Providers>
<Script
src="https://cdn.shopify.com/storefront/standard-actions.js"
type="module"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
</body>
</html>
);
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { headers } from "next/headers";
import Link from "next/link";
import { redirect } from "next/navigation";

import { getStorefrontClient } from "./lib/storefront";
import { getStorefrontClient } from "@/lib/storefront";

// Reading headers() and possibly redirecting must happen per-request.
export const dynamic = "force-dynamic";
Expand Down
44 changes: 3 additions & 41 deletions examples/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,8 @@
import { gql } from "@shopify/hydrogen";
import Link from "next/link";

import { ProductCard } from "./components/ProductCard";
import { getStorefrontClient } from "./lib/storefront";

const HOME_QUERY = gql(`
query Home {
products(first: 3) {
nodes {
handle
title
featuredImage {
url
altText
}
priceRange {
minVariantPrice {
amount
currencyCode
}
}
}
}
}
`);

export default async function HomePage() {
const storefrontClient = await getStorefrontClient();
const { data } = await storefrontClient.graphql(HOME_QUERY);
const products = data?.products?.nodes ?? [];
import { ProductListShell } from "@/components/ProductList";

export default function HomePage() {
return (
<main>
<section className="grid grid-cols-1 md:grid-cols-2">
Expand Down Expand Up @@ -66,18 +39,7 @@ export default async function HomePage() {
</div>
</Link>
</section>

<section className="bg-paper py-24 md:py-32">
<div className="mx-auto max-w-[1480px] px-6 text-center">
<p className="text-sm font-medium tracking-wide text-black/70">New Arrivals</p>
<h2 className="mt-4 text-6xl font-black tracking-tight md:text-8xl">Spring &apos;26</h2>
<div className="mt-16 grid grid-cols-1 gap-8 text-left md:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.handle} product={product} />
))}
</div>
</div>
</section>
<ProductListShell />
</main>
);
}
4 changes: 2 additions & 2 deletions examples/nextjs/app/products/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { getSelectedProductOptions, gql, type SelectedOption } from "@shopify/hy
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { ProductDetails } from "../../components/ProductDetails";
import { getStorefrontClient } from "../../lib/storefront";
import { ProductDetails } from "@/components/ProductDetails";
import { getStorefrontClient } from "@/lib/storefront";

const PRODUCT_VARIANT_FRAGMENT = gql(`
fragment ProductVariantFragment on ProductVariant {
Expand Down
8 changes: 4 additions & 4 deletions examples/nextjs/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Metadata } from "next";

import { CollectionBrowser } from "../components/CollectionBrowser";
import { querySearch } from "../lib/search";
import { getStorefrontClient } from "../lib/storefront";
import { pageSearchParamsToUrlSearchParams, type PageSearchParams } from "../lib/url";
import { CollectionBrowser } from "@/components/CollectionBrowser";
import { querySearch } from "@/lib/search";
import { getStorefrontClient } from "@/lib/storefront";
import { pageSearchParamsToUrlSearchParams, type PageSearchParams } from "@/lib/url";

type Props = {
searchParams: PageSearchParams;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";

import { getAnalytics, analyticsShop, AnalyticsEvent } from "../lib/analytics";
import { getAnalytics, analyticsShop, AnalyticsEvent } from "@/lib/analytics";

export function AnalyticsTracker() {
const pathname = usePathname();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { ShopPayButton as HydrogenShopPayButton } from "@shopify/hydrogen/react";
import { useEffect, useMemo, useState } from "react";

import { useCart, useCartForm } from "../lib/cart";
import { formatMoney } from "../lib/money";
import { useCart, useCartForm } from "@/lib/cart";
import { formatMoney } from "@/lib/money";

function AddTestItem() {
return (
Expand Down
72 changes: 72 additions & 0 deletions examples/nextjs/components/CartButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import Link from "next/link";
import { useEffect, useState } from "react";

import { useCart } from "@/lib/cart";
import { CART_DRAWER_ID, openCartDrawer, supportsDialogCommands } from "@/lib/cart-drawer";

// React types do not include Invoker Commands yet: https://github.com/facebook/react/issues/32478
const openCartCommandAttributes = {
command: "show-modal",
commandfor: CART_DRAWER_ID,
};

export function CartButton() {
const totalQuantity = useCart((s) => s.data.totalQuantity);
const [hasHydrated, setHasHydrated] = useState(false);
const cartLabel =
totalQuantity === 0
? "Cart, empty"
: `Cart, ${totalQuantity > 99 ? "99 or more" : totalQuantity} ${totalQuantity === 1 ? "item" : "items"}`;
const cartBadge = totalQuantity > 0 && (
<span className="absolute -top-2 -right-2 grid h-5 min-w-5 place-items-center rounded-full bg-black px-1 text-[11px] font-bold text-white">
{totalQuantity > 99 ? "99+" : totalQuantity}
</span>
);

useEffect(() => setHasHydrated(true), []);

if (!hasHydrated) {
return (
<Link href="/cart" aria-label={cartLabel} className="relative hover:opacity-60">
<CartIcon />
{cartBadge}
</Link>
);
}

return (
<button
type="button"
aria-label={cartLabel}
aria-controls={CART_DRAWER_ID}
aria-haspopup="dialog"
{...openCartCommandAttributes}
onClick={() => {
if (!supportsDialogCommands()) openCartDrawer();
}}
className="relative hover:opacity-60"
>
<CartIcon />
{cartBadge}
</button>
);
}

function CartIcon() {
return (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden={true}
>
<path d="M5 7h14l-1.5 12a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 7Z" />
<path d="M9 7V5a3 3 0 0 1 6 0v2" />
</svg>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import { useEffect, useMemo } from "react";

import { useCart } from "../lib/cart";
import { useCart } from "@/lib/cart";
import {
CART_DRAWER_ID,
closeCartDrawer,
configureOpenCartAction,
supportsDialogCommands,
} from "../lib/cart-drawer";
} from "@/lib/cart-drawer";

import { CartNote, CartTotals, CheckoutButton, DiscountCodes, LineItems } from "./Cart";

// React types do not include Invoker Commands yet: https://github.com/facebook/react/issues/32478
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";

import { AnalyticsEvent, analyticsShop, getAnalytics } from "../lib/analytics";
import { formatMoney } from "../lib/money";
import { AnalyticsEvent, analyticsShop, getAnalytics } from "@/lib/analytics";
import { formatMoney } from "@/lib/money";

import { ProductCard, type ProductCardData } from "./ProductCard";

const COLLECTION_SORT_OPTIONS = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect } from "react";

import { getAnalytics, analyticsShop, AnalyticsEvent } from "../lib/analytics";
import { getAnalytics, analyticsShop, AnalyticsEvent } from "@/lib/analytics";

type Props = {
collection: { id: string; handle: string };
Expand Down
Loading
Loading