commit f23332e42cedbaa3e4fb0ba72f3cf4d40c767499 Author: Rami Bitar Date: Wed Jul 29 11:02:40 2026 -0400 Initial commit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e17c26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies +/node_modules + +# yarn +.yarn/install-state.gz + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# typescript +*.tsbuildinfo +next-env.d.ts + +# local CDN asset symlinks into workspace package dist folders +/public/storefront/analytics/shopify.js +/public/storefront/analytics/shopify.js.map +/public/storefront/webmcp.js +/public/storefront/webmcp.js.map diff --git a/README.md b/README.md new file mode 100644 index 0000000..2958e4e --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Hydrogen — Next.js (App Router) example + +A brand-new Next.js 16 (App Router, Turbopack, React 19.2) storefront example, +translating `examples/core` idiomatically into Next.js and bound to +`@shopify/hydrogen`. Runs on Vercel. Zero secrets required (mock.shop fallback). + +## Scripts + +- `pnpm --filter @shopify/hydrogen-example-nextjs dev` — dev server (Turbopack). +- `pnpm --filter @shopify/hydrogen-example-nextjs build` — production build. +- `pnpm --filter @shopify/hydrogen-example-nextjs start` — serve the build. +- `pnpm --filter @shopify/hydrogen-example-nextjs typecheck` — `tsc` + + `gql.tada check --fail-on-warn`. + +## Architecture + +- **Request lifecycle:** `proxy.ts` (`handleShopifyRoutes` pre-routing + + forwarded headers + mock.shop fallback) + `app/not-found.tsx` + (`handleShopifyRedirects` post-404). +- **Storefront client:** `getStorefrontClient()` (per-buyer, cart seed only) + + `staticStorefrontClient` (shared rate-limit, all catalog reads) — F2. A + browser-safe `publicStorefrontClient` (`lib/public-storefront.ts`) is provided + for future client-side Storefront fetches (e.g. TanStack Query); browser + predictive search currently goes through the same-origin + `/api/predictive-search` handler instead. +- **Layout:** shared code lives at the top level — `lib/` (storefront clients, + queries, fragments, cart, analytics, image, money, filters, markets) and + `components/` (Header, ProductCard, CartDrawer, …). `app/` holds only route + files. Imports use the `@/` alias (`tsconfig` `"@/*": ["./*"]`). +- **Caching:** Next-native `use cache` + `cacheLife`/`cacheTag` cache-points + keyed by serializable inputs (`cacheComponents: true`). No Oxygen LRU. +- **Cart seed (F1, F4):** root layout is a static shell wrapping an async + `AppShell` (cart seed via `Promise.race` + analytics shop) in `` — + the Cache Components idiom (static shell prerenders, per-buyer parts stream). +- **Markets:** `getMarketFromHeaders` reads `x-storefront-url`; the client + auto-injects `$country`/`$language` (never passed in query variables). +- **No-JS (F4):** variant GET-links switch variants server-side; cart reachable + via footer `/cart`; filter forms `method="get"` + explicit `action`. + +## mock.shop fallback + +When no `PRIVATE_STOREFRONT_API_TOKEN` is present, the example falls back to +`mock.shop` + `mock-private-token` so it runs with zero secrets. Decrypt +secrets (`pnpm examples:secrets:decrypt`) to hit a real store. + +## Customer Accounts (local HTTPS + real store) + +Customer Accounts require an HTTPS origin (Shopify OAuth rejects `http`) and a +real store (mock.shop has no Customer Account API). + +One-time setup: + +1. `pnpm https:setup` (repo root) — trusts `mkcert` and creates the + `.cert/localtest.me*` certificates. +2. `pnpm examples:secrets:decrypt` — provisions `PRIVATE_STOREFRONT_API_TOKEN` + so the example runs against a real store instead of mock.shop. + +Run the HTTPS dev server and open : + +``` +pnpm --filter @shopify/hydrogen-example-nextjs https:dev +``` + +The `/account` page shows your name + email. `/account/login`, +`/account/logout`, `/account/refresh`, and `/account/authorize` are +Hydrogen-owned routes intercepted in `proxy.ts` (no app route files exist for +them) — Customer Account OAuth login/refresh/logout is handled there. The +header account link is hidden on mock.shop and shown only when a real store is +configured. + +## Build note: `--debug-prerender` + +The `build` script uses `next build --debug-prerender`. Next.js 16 + +React 19.2 has a confirmed framework bug +([vercel/next.js#84994](https://github.com/vercel/next.js/issues/84994), +[#86178](https://github.com/vercel/next.js/issues/86178), +[#94667](https://github.com/vercel/next.js/discussions/94667)) where the +internal `/_global-error` route fails to prerender with +`TypeError: Cannot read properties of null (reading 'useContext')`, blocking +`next build`. The bug reproduces with no custom error page and is independent +of this app's code; the fix requires React 19.3.0 (unreleased). The +`--debug-prerender` flag is the only available workaround and produces a +complete, valid Partial-Prerender production build (`next start` serves it +correctly). Remove the flag once React 19.3.0 ships. diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000..d057c3d --- /dev/null +++ b/app/account/page.tsx @@ -0,0 +1,216 @@ +import "server-only"; +import { customerAccountConfig } from "@shared/config"; +import { createCustomerAccountClient, gql } from "@shopify/hydrogen/customer-account"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { getCustomerAccessToken } from "@/lib/customer-account"; +import { isCustomerAccountsAvailable } from "@/lib/storefront-config"; +import { toURLSearchParams } from "@/lib/url-params"; + +export const metadata: Metadata = { + title: "Account", +}; + +const CUSTOMER_QUERY = gql(` + query CurrentCustomer { + customer { + firstName + lastName + emailAddress { + emailAddress + } + } + } +`); + +type AccountCustomer = { + firstName?: string | null; + lastName?: string | null; + emailAddress?: { emailAddress?: string | null } | null; +}; + +type AccountPageProps = { + searchParams: Promise>; +}; + +/** + * `/account` — core Customer Accounts surface (name + email only). + * + * No `export const dynamic` / `export const fetchCache`: under + * `cacheComponents: true`, `force-dynamic` is not allowed. The page relies on + * the `AppShell` dynamic context (`await connection()` in the root layout's + * shell), which opts the whole subtree into dynamic rendering — any + * `headers()`/`cookies()` read inside (e.g. `getCustomerAccessToken()` → + * `createCurrentRequest()` → `headers()`) is then automatically dynamic, the + * same convention `cart/page.tsx` follows. + * + * Token refresh is delegated to the `/account/refresh` handler (intercepted in + * `proxy.ts`): the page only **reads** the token via `getAccessToken` + * (read-only, no mutation, no render-time commit — the proxy runs before RSC + * render and cannot commit mutations that occur during render). When no usable + * token is present, the page `redirect()`s to `/account/refresh?return_to=…`; + * the handler refreshes, commits the cookie on its response, and redirects + * back to `/account`, which now reads a valid token. + */ +export default async function AccountPage({ searchParams }: AccountPageProps) { + const available = isCustomerAccountsAvailable(); // sync — no await + const urlSearchParams = toURLSearchParams(await searchParams); + const loginFailed = urlSearchParams.get("login") === "failed"; + const refreshAttempted = urlSearchParams.get("refreshed") === "1"; + + if (!available) return ; + + const { accessToken, requestContext } = await getCustomerAccessToken(); + if (!accessToken && !loginFailed && !refreshAttempted) { + // Delegate the refresh to the `/account/refresh` handler instead of calling + // `getOrRefreshAccessToken` here: this page only *reads* the token during + // RSC render, and the proxy runs before render — it cannot commit a cookie + // mutation that happens during render. Refreshing in-page would break that + // no-render-commit invariant. The handler refreshes, sets the cookie on its + // own response, and redirects back with `refreshed=1`. + redirect(`/account/refresh?return_to=${encodeURIComponent("/account?refreshed=1")}`); + } + if (!accessToken) return ; + + // Build the client per-call (Next has no RR "context" equivalent; per-call + // construction from `headers()` is the native Next approach, matching + // `getStorefrontClient`). + const customerAccount = createCustomerAccountClient({ + shopId: customerAccountConfig.shopId, + requestContext, + }); + + // Keep the try/catch so a network/timeout failure surfaces as a friendly + // error card instead of an unhandled 500 in RSC render. + let customer: AccountCustomer | undefined; + let error: string | undefined; + try { + const { data, errors } = await customerAccount.graphql(CUSTOMER_QUERY, { accessToken }); + customer = errors ? undefined : data.customer; + error = errors?.[0]?.message; + } catch { + error = "Customer Account API request failed. Try again later."; + } + + return ; +} + +type AccountShellProps = + | { notice: "real-store" } + | { loginFailed: boolean } + | { customer?: AccountCustomer | null; error?: string }; + +function AccountShell(props: AccountShellProps) { + return ( +
+

+ Account +

+

+ Sign in with Shopify Customer Accounts to view your basic account identity. +

+ + {"notice" in props && } + {"loginFailed" in props && } + {"customer" in props && + (props.error ? ( + + ) : ( + + ))} +
+ ); +} + +function RealStoreNotice() { + return ( +
+

+ Customer Accounts require a real store +

+

+ This example is running against mock.shop, which has no Customer Account API. + Set your store credentials in .env.local and run{" "} + yarn https:dev to enable login. +

+
+ ); +} + +function LoginPanel({ loginFailed }: { loginFailed: boolean }) { + return ( +
+

+ Sign in +

+

+ Use your customer account to view your name and email for this store. +

+ {loginFailed ? ( +

+ We could not complete your login. Try signing in again. +

+ ) : null} + {/* Plain `` — `/account/login` is handler-intercepted in `proxy.ts`. */} + + Log in + +
+ ); +} + +function CustomerAccountError({ message }: { message: string }) { + return ( +

+ {message} +

+ ); +} + +function CustomerCard({ customer }: { customer?: AccountCustomer | null }) { + const name = [customer?.firstName, customer?.lastName].filter(Boolean).join(" ") || "Customer"; + + return ( +
+

+ Customer identity +

+

{name}

+ {customer?.emailAddress?.emailAddress ? ( +

{customer.emailAddress.emailAddress}

+ ) : null} +
+ +
+
+ ); +} diff --git a/app/app-shell.tsx b/app/app-shell.tsx new file mode 100644 index 0000000..0d1bccd --- /dev/null +++ b/app/app-shell.tsx @@ -0,0 +1,94 @@ +import "server-only"; +import { analyticsConsent as analyticsConsentConfig } from "@shared/config"; +import { connection } from "next/server"; +import { Suspense } from "react"; + +import { CartDrawer } from "@/components/CartDrawer"; +import { ConsentBanner } from "@/components/ConsentBanner"; +import { Footer } from "@/components/Footer"; +import { Header } from "@/components/Header"; +import { HeaderAccountLink, HeaderAccountLinkFallback } from "@/components/HeaderAccountLink"; +import { ShopifyScriptsClient } from "@/components/ShopifyScriptsClient"; +import { getAnalyticsShop, getScriptsShop } from "@/lib/analytics-shop"; +import { cartHandlers } from "@/lib/cart-handlers"; +import { getStorefrontClient } from "@/lib/storefront"; + +import { Providers } from "./providers"; + +/** + * Async server shell that owns the per-request (dynamic) reads: the cart seed + * + the shop analytics GID. With `cacheComponents: true`, uncached/dynamic data + * accessed in a Server Component must sit inside a `` boundary so the + * static HTML shell prerenders and the per-buyer parts stream + * (`next/server` `connection()` + `headers()`/`cookies()` are per-request). + * + * Rendered inside `` from the root layout. `await connection()` + * opts the subtree into dynamic rendering (resolves immediately on a real + * request, never during prerender). + */ +export async function AppShell({ children }: { children: React.ReactNode }) { + await connection(); + + // Cart seed: per-buyer `getStorefrontClient()` (skill-mandated; the cart is + // personalized). Non-blocking via `Promise.race` (F1). On success pass the + // full `{cart, errors?}` envelope; on timeout/error pass `undefined` so the + // client fetches `/api/cart` after hydration (NOT `{cart:null}` — that would + // suppress the retry). + const storefrontClient = await getStorefrontClient(); + let cartData: Awaited>["data"] | undefined; + try { + const result = await Promise.race([ + cartHandlers.get({ storefrontClient }), + timeoutReject(2000), + ]); + cartData = result.data; + } catch (error) { + console.error("[hydrogen] Cart seed failed or timed out", error); + cartData = undefined; + } + + // Analytics shop GID: best-effort, non-blocking (F1). Merged with + // `@shared/config` metadata (acceptedLanguage/currency/hydrogenSubchannelId). + const analyticsShop = await getAnalyticsShop(); + const scriptsShop = getScriptsShop(); + + return ( + + + Skip to content + + +
}> + + + } + /> + +
+ {children} +
+ +