Skip to content

feat(shop): headless Shopify storefront at /shop#831

Merged
tannerlinsley merged 3 commits intomainfrom
feat/shop-headless-shopify
Apr 15, 2026
Merged

feat(shop): headless Shopify storefront at /shop#831
tannerlinsley merged 3 commits intomainfrom
feat/shop-headless-shopify

Conversation

@tannerlinsley
Copy link
Copy Markdown
Member

@tannerlinsley tannerlinsley commented Apr 15, 2026

Summary

  • Headless Shopify storefront served by tanstack.com at /shop, /shop/products/$handle, /shop/cart — replaces the outbound Cotton Bureau link on /merch
  • Cart runs through server functions (getCart/addToCart/updateCartLine/removeCartLine) with the cart ID in an httpOnly cookie; the private Storefront API token never leaves the server
  • Parent /shop loader prefetches the cart into React Query so every child page SSRs with real data on the first frame — no hydration gap
  • Fixes the createFileRoute(variable) pattern across library landings (was crashing router-generator's crawl and producing "Crawling result not available" at dev startup)

Details

Shop

  • /shop — product grid sorted by BEST_SELLING, edge-cached for 5m + swr
  • /shop/products/$handle — PDP with variant-option selectors (Color, Size, etc.), auto-resolves to matching variant, hero image swaps to the variant image, SEO meta from Shopify product SEO fields
  • /shop/cart — full cart with line items, quantity stepper, trash button, summary panel, Checkout button redirecting to Shopify-hosted cart.checkoutUrl
  • Shop sidebar anchored to the viewport's left rail, collapsible with hover-expand overlay (matches the doc sidebar pattern)
  • Navbar Title triggers the main site nav's flyout mode on /shop/*, so the shop sidebar takes over the primary left rail
  • Navbar cart button: always visible on /shop/*, site-wide once the cart has items, hidden elsewhere when empty

Cart architecture

  • tanstack_cart_id httpOnly cookie (server-only, secure in prod, 1-year max-age)
  • All reads + mutations go through createServerFn — one consistent auth path, private token stays server-side, rate-limit headroom via bot-protection buyer-IP header if we want it later
  • React Query caches on a single ['shopify', 'cart'] key; optimistic onMutate bumps totalQuantity for instant navbar badge feedback on add, with full line-level optimism on update/remove, onError rollback, onSuccess reconcile with server truth
  • Loader-seeded cache means no skeleton state and no hydration flash

Rolled in

  • createFileRoute('/library/$version/') literal across 16 library-landing routes + check-name.ts (drops the as any cast) — this is the permanent fix for the router-generator crawl error
  • .env.* glob in .gitignore to prevent env backups/variants from leaking
  • .env.local is now the single env file (folded .env into it; dotenv -e .env.local in the dev script)

Dependencies added

  • @shopify/hydrogen-react (types + Storefront API schema; no providers used, so the client bundle only picks up the small utility imports we actually use)

Test plan

  • Set SHOPIFY_* + VITE_SHOPIFY_* env in Netlify for deploy preview + prod
  • /shop lists products from tanstack-2.myshopify.com
  • Navigate to a PDP, select a variant, click Add to cart → navbar badge bumps immediately
  • /shop/cart shows the added line with correct quantity, variant options, and subtotal
  • Change quantity via stepper → line total + cart total update optimistically, then reconcile
  • Remove a line → line disappears, totals recompute, cart empties at 0
  • Click Checkout → redirects to Shopify-hosted checkout with the right cart
  • Reload /shop/cart after adding items → SSR paints the populated cart on the first frame (no skeleton, no hydration pop-in)
  • Collapse/expand the shop sidebar; hover-expand on the collapsed rail shows as an overlay without layout shift
  • On non-shop routes (e.g. /docs), the cart badge is hidden when empty and appears when the cart has items
  • Dev server starts cleanly (no "Crawling result not available" or "expected route id" errors)

Summary by CodeRabbit

  • New Features

    • Full e-commerce flow: shop listing, product pages, responsive shop layout, cart page with quantity controls and checkout
    • Navbar cart indicator with item count and quick cart access
  • Documentation

    • Performance plan for homepage/library/docs
    • Two NPM watchlist/watchlist-digest proposals
  • Chores

    • Dependency bumps and updated environment handling; added .env.* to .gitignore

Introduces a Shopify-backed commerce experience served directly by
tanstack.com, replacing the outbound Cotton Bureau link on /merch.

Routes
- /shop — product grid (cached via Netlify edge, 5m TTL + swr)
- /shop/products/$handle — PDP with variant selection
- /shop/cart — cart with quantity controls, checkout redirect

Architecture
- Shopify Storefront API (2026-01) via @shopify/hydrogen-react types
- Private token never leaves the server; cart ID in httpOnly cookie
- All cart reads/mutations go through createServerFn (getCart,
  addToCart, updateCartLine, removeCartLine)
- /shop parent loader prefetches cart + collections into RQ cache so
  every child page renders with real data on the first frame — no
  hydration gap, no skeletons
- Optimistic updates via React Query onMutate/rollback
- Shop layout anchors a collapsible sidebar to the viewport's left
  rail; Navbar Title flips the main site nav into flyout mode on any
  /shop/* route (same pattern as library landings)

Also rolled in
- createFileRoute literalization across library landings — the variable
  pattern was crashing router-generator's crawl and producing
  "Crawling result not available" at dev startup. Inlining the path
  string on both the createFileRoute and createLibraryLandingPage calls
  fixes it permanently.
- check-name.ts: drop the "as any" cast on createFileRoute now that the
  generator can parse the literal directly.
- .gitignore: match .env.* so future env variants (including .bak
  files from accidental backups) never leak into commits.
- env merge: .env now folded into .env.local as a single source, with
  the dev script pointing at just .env.local.
@netlify
Copy link
Copy Markdown

netlify bot commented Apr 15, 2026

Deploy Preview for tanstack ready!

Name Link
🔨 Latest commit 7924bdd
🔍 Latest deploy log https://app.netlify.com/projects/tanstack/deploys/69dff6251f8e82dca771fa6b
😎 Deploy Preview https://deploy-preview-831--tanstack.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 43 (🔴 down 16 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (🔴 down 9 from production)
SEO: 97 (no change from production)
PWA: 70 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7f54bd5b-a175-4822-8889-d26787794c86

📥 Commits

Reviewing files that changed from the base of the PR and between 7bda760 and 7924bdd.

📒 Files selected for processing (2)
  • src/server/shopify/fetch.ts
  • src/utils/env.ts
✅ Files skipped from review due to trivial changes (1)
  • src/utils/env.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/server/shopify/fetch.ts

📝 Walkthrough

Walkthrough

Adds a Shopify-backed shop (routes, product pages, cart UI), server Shopify fetch/utilities/GraphQL queries, TanStack server functions for shop/cart, React Query cart hooks and navbar cart UI, shop layout components, route-tree additions, env schema update for a Shopify token, docs and dependency changes, and many small route inlinings.

Changes

Cohort / File(s) Summary
VCS & Config
\.gitignore, package.json
Ignore .env.*; adjust with-env script; add @shopify/hydrogen-react and bump several @tanstack and plugin versions.
Environment Schema
src/utils/env.ts
Add optional server-only env key: SHOPIFY_PRIVATE_STOREFRONT_TOKEN.
Documentation & Proposals
docs/performance-plan-home-library-docs.md, docs/proposals/npm-watchlist-registry-draft.md, docs/proposals/npm-watchlists-and-weekly-digests.md
Add performance plan and two NPM-watchlist/weekly-digest proposal/spec documents.
Routing refactors
src/routes/.../*.index.tsx, src/routes/api/builder/deploy/check-name.ts
Inline per-file routePath constants into createFileRoute/createLibraryLandingPage; remove an unnecessary as any cast.
Shop routes & route tree
src/routes/shop.tsx, src/routes/shop.index.tsx, src/routes/shop.cart.tsx, src/routes/shop.products.$handle.tsx, src/routeTree.gen.ts
Add /shop parent route and child routes (index, cart, product detail); update generated route tree/types.
Server-side Shopify integration
src/server/shopify/fetch.ts, src/utils/shopify-queries.ts, src/utils/shop.functions.ts
Add server-only Shopify GraphQL client, typed queries/mutations/types, and TanStack server functions for browsing and cart CRUD with cookie handling, cache headers, and user-error handling.
Client-side shop utilities
src/utils/shopify-format.ts, src/utils/application-starter.server.ts
Add money/image formatting helpers; inject migration-guide URL into prompt generation when available.
Cart state & hooks
src/hooks/useCart.ts
Add CART_QUERY_KEY, useCart, useAddToCart, useUpdateCartLine, useRemoveCartLine with optimistic updates, rollback, and cache invalidation.
Components & UI
src/components/Navbar.tsx, src/components/NavbarCartButton.tsx, src/components/RedirectVersionBanner.tsx, src/components/application-builder/shared.ts, src/components/shop/ShopLayout.tsx
Add NavbarCartButton and render it; small type-assert change in RedirectVersionBanner; add Remix migration detection and guide constants; introduce ShopLayout with responsive sidebar and nav helpers.
Product pages & cart UI
src/routes/shop.products.$handle.tsx, src/routes/shop.cart.tsx, src/routes/shop.index.tsx
Implement product detail (variants, add-to-cart), cart page (quantity stepper, remove, summary), and shop index (product grid) using loaders and hooks.
Miscellaneous
other small edits
Many library-landing route files updated to inline path literals; minor formatting/refactors across several files.

Sequence Diagram

sequenceDiagram
    participant User
    participant Client as Client (React)
    participant Cache as ReactQuery Cache
    participant Server as App Server (serverFns)
    participant Shopify as Shopify API

    Note over User,Shopify: Add-to-cart flow
    User->>Client: Click "Add to cart"
    Client->>Cache: Optimistic update (totalQuantity, line)
    Client->>Server: POST addToCart(variantId, quantity)
    Server->>Shopify: CartCreate / CartLinesAdd mutation
    Shopify-->>Server: CartDetail
    Server-->>Cache: Write cart response
    Cache-->>Client: Re-render with updated cart
    Client-->>User: NavbarCartButton shows new count
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through queries, cookies, and cart,

GraphQL crumbs tucked close to my heart,
Optimistic bounces, badges that glow,
New routes and layouts where shoppers can go,
I nibble the docs and applaud every part!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature: adding a headless Shopify storefront at the /shop route. It is specific, concise, and directly reflects the changeset's primary objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shop-headless-shopify

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/RedirectVersionBanner.tsx (1)

32-44: ⚠️ Potential issue | 🟠 Major

Replace as never casts with proper type handling to maintain router safety.

The as never casts on to and params at lines 32-33 and 43-44 bypass TanStack Router's type validation entirely. While both usages are constrained to versioned routes (confirmed: only used in src/routes/-library-landing.tsx and src/routes/$libraryId/$version.tsx), these casts prevent TypeScript from detecting future breaking changes. If the route structure or version param handling evolves, these assertions would silently allow invalid navigation at runtime.

Consider using a more targeted type assertion or refactoring the navigation pattern to preserve compile-time route safety instead of opting out of it entirely with as never.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/RedirectVersionBanner.tsx` around lines 32 - 44, The code in
RedirectVersionBanner is bypassing TanStack Router's type checks by casting both
Link props to "as never"; replace these casts by passing properly typed route
info or generating the target href with the router utilities so TypeScript can
verify params. Specifically, in RedirectVersionBanner remove the "as never"
casts on Link and Button props and instead either: 1) use the typed match object
(activeMatch) to derive a correctly typed destination (e.g., use the router's
createHref/generatePath helper or a typed Link generic) so params={{ version:
'latest' }} is checked, or 2) pass activeMatch.params (or a shallow-clone with
version updated) as the params value so the version param is typed by the route;
ensure changes touch the Link and Button usages that reference
activeMatch.fullPath and params version so route safety is preserved.
🧹 Nitpick comments (7)
.gitignore (1)

10-10: Consider removing redundant .env patterns.

The new .env.* wildcard pattern now covers the entries at lines 24 (.env.sentry-build-plugin) and 27 (.env.local). Removing those redundant entries would improve maintainability and clarity.

🧹 Proposed cleanup to remove redundant patterns

Remove the redundant entries that are already matched by .env.*:

 .env
 .env.*
 .vercel
 .output
 .vinxi
 .tanstack-start/build
 .nitro/*
 .netlify/*

 /build/
 /api/
 /server/build
 /public/build

 # Sentry Config File
-.env.sentry-build-plugin
 dist
 .vscode/
-.env.local

 # Content Collections generated files
 .content-collections
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitignore at line 10, Remove the redundant explicit .env entries now
covered by the new .env.* pattern: delete the individual
`.env.sentry-build-plugin` and `.env.local` lines from the .gitignore so only
`.env.*` remains; ensure no other unique `.env` exceptions are needed and run a
quick grep to confirm `.env.*` covers the removed patterns.
src/components/application-builder/shared.ts (1)

156-166: Unify migration-intent checks to avoid behavior drift.

getStarterMigrationGuideUrl now handles Next.js + Remix, but migration-specific prompt augmentation still gates on Next.js only. Consider introducing a shared isMigrationInput helper and reusing it in both paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/application-builder/shared.ts` around lines 156 - 166,
Introduce a shared predicate isMigrationInput(input: string) that returns true
for both isNextJsMigrationInput(input) and isRemixMigrationInput(input), then
replace the ad-hoc Next.js-only checks elsewhere (the migration-specific prompt
augmentation logic) to use isMigrationInput instead of isNextJsMigrationInput;
also update getStarterMigrationGuideUrl to keep using isNextJsMigrationInput and
isRemixMigrationInput for guide selection while any other migration-intent
decision points (e.g., prompt augmentation) should call isMigrationInput to
ensure consistent behavior.
src/components/NavbarCartButton.tsx (1)

24-24: Pluralize the cart aria-label for singular counts.

At Line 24, screen readers will get Cart (1 items). Use singular/plural grammar for better accessibility polish.

💡 Suggested tweak
-      aria-label={totalQuantity > 0 ? `Cart (${totalQuantity} items)` : 'Cart'}
+      aria-label={
+        totalQuantity > 0
+          ? `Cart (${totalQuantity} ${totalQuantity === 1 ? 'item' : 'items'})`
+          : 'Cart'
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/NavbarCartButton.tsx` at line 24, The aria-label in the
NavbarCartButton currently renders "Cart (1 items)" when totalQuantity is 1;
update the aria-label expression in the NavbarCartButton component to choose the
singular "item" when totalQuantity === 1 and "items" otherwise (e.g., use
totalQuantity > 0 ? `Cart (${totalQuantity} ${totalQuantity === 1 ? 'item' :
'items'})` : 'Cart'), referencing the totalQuantity prop and the aria-label
attribute to apply the conditional pluralization.
docs/performance-plan-home-library-docs.md (1)

9-14: Use stable identifiers for the baseline.

These hashed asset names will be obsolete after the next build, so the plan won’t stay useful as living documentation. Prefer route/chunk labels plus the measurement date, or link the generated size report that produced these numbers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/performance-plan-home-library-docs.md` around lines 9 - 14, The
documented asset sizes use ephemeral hashed filenames (e.g., index-Bq0A5jmY.js,
app-shell-BikUtTEO.js, SearchModal-Bl-tUxqr.js, DocsLayout-Bga1-HA9.js,
MarkdownContent-ia2V1dk8.js, app-CBMELhsb.css) which will change after each
build; replace those hashed identifiers with stable route/chunk labels (e.g.,
"Homepage route chunk", "Shared shell chunk", "Search modal chunk", "Docs shell
chunk", "Markdown chrome chunk", "Global CSS") and add the measurement date or a
permalink to the generated size report that produced the numbers so the baseline
remains meaningful. Ensure the text in
docs/performance-plan-home-library-docs.md references the stable labels and
includes either a date or a link to the source size-report.
docs/proposals/npm-watchlists-and-weekly-digests.md (1)

138-197: Keep the model definition in one proposal.

These exact NpmTrackedEntity / NpmRollup / NpmWatchlist shapes are also defined in docs/proposals/npm-watchlist-registry-draft.md. Duplicating the canonical model in two docs will drift quickly once the registry evolves. I’d keep the full type block in one place and summarize/link to it here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/proposals/npm-watchlists-and-weekly-digests.md` around lines 138 - 197,
The docs duplicate the canonical type definitions for NpmTrackedEntity,
NpmRollup, and NpmWatchlist; remove the full type block from this proposal and
replace it with a short summary and a link/reference to the single
source-of-truth (the definitions in npm-watchlist-registry-draft.md), while
keeping the type names NpmTrackedEntity, NpmRollup, and NpmWatchlist mentioned
so readers can find the canonical shapes; ensure any examples here only
reference the canonical types rather than re-defining them.
src/utils/shopify-queries.ts (1)

73-80: Consider simplifying the redundant null union.

(Pick<StorefrontImage, ...> | null) | null can be simplified to Pick<StorefrontImage, ...> | null.

♻️ Suggested fix
 export type ProductListItem = Pick<Product, 'id' | 'handle' | 'title'> & {
-  featuredImage:
-    | (Pick<StorefrontImage, 'url' | 'altText' | 'width' | 'height'> | null)
-    | null
+  featuredImage: Pick<StorefrontImage, 'url' | 'altText' | 'width' | 'height'> | null
   priceRange: {
     minVariantPrice: Pick<MoneyV2, 'amount' | 'currencyCode'>
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/shopify-queries.ts` around lines 73 - 80, The ProductListItem
type's featuredImage currently has a redundant nested null union; update the
featuredImage type on ProductListItem to use a single null union by changing its
type from "(Pick<StorefrontImage, 'url' | 'altText' | 'width' | 'height'> |
null) | null" to "Pick<StorefrontImage, 'url' | 'altText' | 'width' | 'height'>
| null", keeping the same Pick fields (StorefrontImage) and preserving
priceRange and other fields.
src/utils/shop.functions.ts (1)

222-245: Document that quantity: 0 removes the line.

The validator allows v.minValue(0), which per Shopify's API will remove the line entirely. This behavior might surprise callers who expect a dedicated removeCartLine for removal. Consider adding a brief comment or using removeCartLine internally when quantity is 0.

📝 Suggested documentation
 export const updateCartLine = createServerFn({ method: 'POST' })
   .inputValidator(
     v.object({
       lineId: v.string(),
+      // Note: quantity=0 removes the line (Shopify behavior). Use removeCartLine for explicit removal.
       quantity: v.pipe(v.number(), v.integer(), v.minValue(0)),
     }),
   )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/shop.functions.ts` around lines 222 - 245, The updateCartLine
handler currently allows quantity 0 via the inputValidator (v.minValue(0))
which, per Shopify API, removes the line item — this should be made explicit or
handled differently; update the updateCartLine function to either: a) add a
clear inline comment above the inputValidator and handler stating "quantity: 0
will remove the line item per Shopify API" so callers are aware, or b) detect
data.quantity === 0 inside the handler and call the existing removeCartLine flow
instead of CART_LINES_UPDATE_MUTATION (use getCookie(CART_COOKIE_NAME) and
throwIfUserErrors similarly), then return the resulting cart; ensure any added
path uses the same error handling (throwIfUserErrors) and returns CartDetail.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package.json`:
- Line 47: Move the package "@shopify/hydrogen-react" from dependencies to
devDependencies in package.json because all uses are type-only; update
package.json so "@shopify/hydrogen-react": "^2026.4.0" appears under
devDependencies instead of dependencies, and run npm/yarn install to update
lockfile; confirm in src/utils/shopify-queries.ts that imports are "import type"
to justify the change.

In `@src/components/shop/ShopLayout.tsx`:
- Around line 247-252: The sidebar Link in ShopLayout.tsx is casting routes to
any to generate links to /shop/collections/$handle despite no route handler
existing; either add a proper route and page component for the dynamic
collection path (e.g., create a CollectionDetail component and register a route
like '/shop/collections/:handle' in your router/route config so Link's to/params
point to a real route), or stop rendering collection links in ShopLayout (filter
out collection entries before mapping to Link and remove the to={to as
any}/params={params as any} workaround) so navigation doesn't point to a
non-existent route.

In `@src/routes/shop.cart.tsx`:
- Around line 41-53: The <dl> in the cart UI contains a stray <p> ("Shipping and
taxes calculated at checkout.") which is invalid inside a description list; move
that note outside the <dl> so the <dl className="flex flex-col gap-2 text-sm">
only contains term/value groups (the two <div className="flex justify-between">
and the total row <div className="flex justify-between border-t ...">). Render
the paragraph immediately after the closing </dl> (preserving its className and
text) to ensure correct semantics and consistent screen-reader announcements.

In `@src/routes/shop.products`.$handle.tsx:
- Around line 109-114: The product.descriptionHtml is rendered with
dangerouslySetInnerHTML without sanitization; sanitize it first (e.g., import
sanitize-html or DOMPurify) and use the sanitized string when setting
dangerouslySetInnerHTML. Concretely, create a sanitized variable (e.g., const
safeDescription = sanitizeHtml(product.descriptionHtml, { allowedTags: [...],
allowedAttributes: {...} }) or DOMPurify.sanitize(product.descriptionHtml)),
then replace dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} with
dangerouslySetInnerHTML={{ __html: safeDescription }} in the component rendering
the product (the JSX block that references product.descriptionHtml).
- Around line 34-36: The selected state is only initialized once and must be
reset when a new product is loaded; add an effect that watches the product
identity (e.g., product.id or product.handle) and calls setSelected with
Object.fromEntries(product.options.map(o => [o.name, o.values[0]!])) so the
selection reflects the current product before calling findMatchingVariant;
update the file to include this useEffect alongside the existing
selected/setSelected state initialization.

In `@src/server/shopify/fetch.ts`:
- Around line 55-80: The current fetch flow can hang or surface non-Shopify
exceptions; wrap the network/parse steps around a timeout and try/catch and
rethrow as ShopifyError with underlying error details: when performing the fetch
to `https://${domain}/api/${version}/graphql.json` (the fetch call) use an
AbortController with a sensible timeout and abort the request on timeout, and
surround both the fetch and the subsequent `await response.json()` (parsed as
`ShopifyResponse<TData>`) in a try/catch that converts any thrown error into a
`ShopifyError` (include the original error message/stack as context), leaving
the existing response.ok and json.errors checks intact so upstream failures stay
normalized.

In `@src/utils/shop.functions.ts`:
- Around line 108-120: Change getProduct to use method: 'GET' so responses can
be cached at the edge; update the handler to read the handle from the request
query instead of the POST body and validate it there. Specifically, modify
createServerFn({ method: 'POST' }) to createServerFn({ method: 'GET' }), adjust
the handler signature to accept and validate the request query (instead of
destructuring data) so you pass variables: { handle: query.handle } into
shopifyServerFetch, keep calling setBrowseCacheHeaders(), and preserve the same
return of result.product; ensure the input validation for the handle is applied
to the query parameter (e.g., validate query.handle with v.string()).

In `@src/utils/shopify-queries.ts`:
- Around line 190-197: Update the misleading header comment in the Cart section
of src/utils/shopify-queries.ts to reflect that cart operations are performed
server-side via shop.functions.ts (using the private Storefront token) and that
the cart ID is stored in an httpOnly cookie rather than localStorage; locate the
block around the "Cart — queries + mutations" comment and replace the
browser/localStorage wording with a concise statement that cart requests go
through the server functions (e.g., shop.functions.ts) and cookies are used for
cart ID storage.

---

Outside diff comments:
In `@src/components/RedirectVersionBanner.tsx`:
- Around line 32-44: The code in RedirectVersionBanner is bypassing TanStack
Router's type checks by casting both Link props to "as never"; replace these
casts by passing properly typed route info or generating the target href with
the router utilities so TypeScript can verify params. Specifically, in
RedirectVersionBanner remove the "as never" casts on Link and Button props and
instead either: 1) use the typed match object (activeMatch) to derive a
correctly typed destination (e.g., use the router's createHref/generatePath
helper or a typed Link generic) so params={{ version: 'latest' }} is checked, or
2) pass activeMatch.params (or a shallow-clone with version updated) as the
params value so the version param is typed by the route; ensure changes touch
the Link and Button usages that reference activeMatch.fullPath and params
version so route safety is preserved.

---

Nitpick comments:
In @.gitignore:
- Line 10: Remove the redundant explicit .env entries now covered by the new
.env.* pattern: delete the individual `.env.sentry-build-plugin` and
`.env.local` lines from the .gitignore so only `.env.*` remains; ensure no other
unique `.env` exceptions are needed and run a quick grep to confirm `.env.*`
covers the removed patterns.

In `@docs/performance-plan-home-library-docs.md`:
- Around line 9-14: The documented asset sizes use ephemeral hashed filenames
(e.g., index-Bq0A5jmY.js, app-shell-BikUtTEO.js, SearchModal-Bl-tUxqr.js,
DocsLayout-Bga1-HA9.js, MarkdownContent-ia2V1dk8.js, app-CBMELhsb.css) which
will change after each build; replace those hashed identifiers with stable
route/chunk labels (e.g., "Homepage route chunk", "Shared shell chunk", "Search
modal chunk", "Docs shell chunk", "Markdown chrome chunk", "Global CSS") and add
the measurement date or a permalink to the generated size report that produced
the numbers so the baseline remains meaningful. Ensure the text in
docs/performance-plan-home-library-docs.md references the stable labels and
includes either a date or a link to the source size-report.

In `@docs/proposals/npm-watchlists-and-weekly-digests.md`:
- Around line 138-197: The docs duplicate the canonical type definitions for
NpmTrackedEntity, NpmRollup, and NpmWatchlist; remove the full type block from
this proposal and replace it with a short summary and a link/reference to the
single source-of-truth (the definitions in npm-watchlist-registry-draft.md),
while keeping the type names NpmTrackedEntity, NpmRollup, and NpmWatchlist
mentioned so readers can find the canonical shapes; ensure any examples here
only reference the canonical types rather than re-defining them.

In `@src/components/application-builder/shared.ts`:
- Around line 156-166: Introduce a shared predicate isMigrationInput(input:
string) that returns true for both isNextJsMigrationInput(input) and
isRemixMigrationInput(input), then replace the ad-hoc Next.js-only checks
elsewhere (the migration-specific prompt augmentation logic) to use
isMigrationInput instead of isNextJsMigrationInput; also update
getStarterMigrationGuideUrl to keep using isNextJsMigrationInput and
isRemixMigrationInput for guide selection while any other migration-intent
decision points (e.g., prompt augmentation) should call isMigrationInput to
ensure consistent behavior.

In `@src/components/NavbarCartButton.tsx`:
- Line 24: The aria-label in the NavbarCartButton currently renders "Cart (1
items)" when totalQuantity is 1; update the aria-label expression in the
NavbarCartButton component to choose the singular "item" when totalQuantity ===
1 and "items" otherwise (e.g., use totalQuantity > 0 ? `Cart (${totalQuantity}
${totalQuantity === 1 ? 'item' : 'items'})` : 'Cart'), referencing the
totalQuantity prop and the aria-label attribute to apply the conditional
pluralization.

In `@src/utils/shop.functions.ts`:
- Around line 222-245: The updateCartLine handler currently allows quantity 0
via the inputValidator (v.minValue(0)) which, per Shopify API, removes the line
item — this should be made explicit or handled differently; update the
updateCartLine function to either: a) add a clear inline comment above the
inputValidator and handler stating "quantity: 0 will remove the line item per
Shopify API" so callers are aware, or b) detect data.quantity === 0 inside the
handler and call the existing removeCartLine flow instead of
CART_LINES_UPDATE_MUTATION (use getCookie(CART_COOKIE_NAME) and
throwIfUserErrors similarly), then return the resulting cart; ensure any added
path uses the same error handling (throwIfUserErrors) and returns CartDetail.

In `@src/utils/shopify-queries.ts`:
- Around line 73-80: The ProductListItem type's featuredImage currently has a
redundant nested null union; update the featuredImage type on ProductListItem to
use a single null union by changing its type from "(Pick<StorefrontImage, 'url'
| 'altText' | 'width' | 'height'> | null) | null" to "Pick<StorefrontImage,
'url' | 'altText' | 'width' | 'height'> | null", keeping the same Pick fields
(StorefrontImage) and preserving priceRange and other fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 04addb31-7ba4-48e7-8d04-5f43da8868f0

📥 Commits

Reviewing files that changed from the base of the PR and between c030f08 and bedc678.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (39)
  • .gitignore
  • docs/performance-plan-home-library-docs.md
  • docs/proposals/npm-watchlist-registry-draft.md
  • docs/proposals/npm-watchlists-and-weekly-digests.md
  • package.json
  • src/components/Navbar.tsx
  • src/components/NavbarCartButton.tsx
  • src/components/RedirectVersionBanner.tsx
  • src/components/application-builder/shared.ts
  • src/components/shop/ShopLayout.tsx
  • src/hooks/useCart.ts
  • src/routeTree.gen.ts
  • src/routes/ai.$version.index.tsx
  • src/routes/api/builder/deploy/check-name.ts
  • src/routes/cli.$version.index.tsx
  • src/routes/config.$version.index.tsx
  • src/routes/db.$version.index.tsx
  • src/routes/devtools.$version.index.tsx
  • src/routes/form.$version.index.tsx
  • src/routes/hotkeys.$version.index.tsx
  • src/routes/intent.$version.index.tsx
  • src/routes/pacer.$version.index.tsx
  • src/routes/query.$version.index.tsx
  • src/routes/ranger.$version.index.tsx
  • src/routes/router.$version.index.tsx
  • src/routes/shop.cart.tsx
  • src/routes/shop.index.tsx
  • src/routes/shop.products.$handle.tsx
  • src/routes/shop.tsx
  • src/routes/start.$version.index.tsx
  • src/routes/store.$version.index.tsx
  • src/routes/table.$version.index.tsx
  • src/routes/virtual.$version.index.tsx
  • src/server/shopify/fetch.ts
  • src/utils/application-starter.server.ts
  • src/utils/env.ts
  • src/utils/shop.functions.ts
  • src/utils/shopify-format.ts
  • src/utils/shopify-queries.ts

Comment thread package.json
"@sentry/browser": "^10.47.0",
"@sentry/node": "^10.47.0",
"@sentry/tanstackstart-react": "^10.47.0",
"@shopify/hydrogen-react": "^2026.4.0",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find every usage of `@shopify/hydrogen-react` and show context
rg -n -C2 --type=ts --type=tsx --type=js --type=jsx "['\"]@shopify/hydrogen-react['\"]"

# Quick check for type-only imports
rg -n --type=ts --type=tsx "import type .*from ['\"]@shopify/hydrogen-react['\"]"

Repository: TanStack/tanstack.com

Length of output: 92


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all imports of `@shopify/hydrogen-react` in TypeScript/JavaScript files
# Using rg without problematic type filters
rg -n -C2 "@shopify/hydrogen-react" --glob="*.ts" --glob="*.tsx" --glob="*.js" --glob="*.jsx"

echo "---"
echo "Checking for type-only imports:"
rg -n "import type.*@shopify/hydrogen-react" --glob="*.ts" --glob="*.tsx" --glob="*.js" --glob="*.jsx"

Repository: TanStack/tanstack.com

Length of output: 782


🏁 Script executed:

#!/bin/bash

# Show the full import statement at the top of the file
head -20 src/utils/shopify-queries.ts

echo "---"
echo "Checking if ProductOption and ProductVariant are used at runtime:"
rg -n "ProductOption|ProductVariant" src/utils/shopify-queries.ts

echo "---"
echo "Checking for any other `@shopify` references in codebase:"
rg -n "@shopify" --glob="*.ts" --glob="*.tsx" --glob="*.js" --glob="*.jsx"

Repository: TanStack/tanstack.com

Length of output: 1198


Move @shopify/hydrogen-react to devDependencies.

All imports from this package use import type syntax (lines 1-10 in src/utils/shopify-queries.ts), meaning they are type-only and not needed at runtime. This reduces production install surface with no functional impact.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 47, Move the package "@shopify/hydrogen-react" from
dependencies to devDependencies in package.json because all uses are type-only;
update package.json so "@shopify/hydrogen-react": "^2026.4.0" appears under
devDependencies instead of dependencies, and run npm/yarn install to update
lockfile; confirm in src/utils/shopify-queries.ts that imports are "import type"
to justify the change.

Comment on lines +247 to +252
<Link
// Cast to `any` is intentional: sidebar links are built from typed
// route literals, but collections come from a dynamic server list,
// so the union of safe paths is wider than inference can handle here.
to={to as any}
params={params as any}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf "Route definition for /shop/collections/\$handle:\n"
rg -n --fixed-strings "createFileRoute('/shop/collections/\$handle')" src || true

printf "\nSidebar link usages:\n"
rg -n --fixed-strings 'to="/shop/collections/$handle"' src || true

Repository: TanStack/tanstack.com

Length of output: 218


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Context around lines 247-252 in ShopLayout.tsx ==="
sed -n '240,260p' src/components/shop/ShopLayout.tsx

echo ""
echo "=== All createFileRoute definitions in src ==="
rg -n "createFileRoute\(" src | head -20

echo ""
echo "=== Context around line 180 (the sidebar link usage) ==="
sed -n '175,185p' src/components/shop/ShopLayout.tsx

Repository: TanStack/tanstack.com

Length of output: 3205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for all /shop routes ==="
rg -n "createFileRoute.*shop" src/routes

echo ""
echo "=== Check if there's a /shop/ subdirectory in routes ==="
find src/routes -type d -name "*shop*" 2>/dev/null || echo "No /shop directory found"

echo ""
echo "=== Look for any reference to collections route definition ==="
rg -n "collections" src/routes

echo ""
echo "=== Full SidebarLink component definition to understand the type safety ==="
sed -n '230,280p' src/components/shop/ShopLayout.tsx

Repository: TanStack/tanstack.com

Length of output: 2503


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check for any references to /shop/collections in the codebase ==="
rg -n "/shop/collections" src

echo ""
echo "=== Look for any dynamic route handling or collection detail views ==="
rg -n "collection" src/components src/routes --ignore-case | grep -i "detail\|view\|page\|component" || true

echo ""
echo "=== Check where collections are used ==="
rg -n "collections" src/components/shop/ShopLayout.tsx

echo ""
echo "=== Full ShopLayout export to understand component purpose ==="
sed -n '1,50p' src/components/shop/ShopLayout.tsx

Repository: TanStack/tanstack.com

Length of output: 3714


Define the /shop/collections/$handle route or remove it from navigation.

The as any casts suppress type safety for a route that doesn't exist. Clicking collection sidebar links navigates to /shop/collections/$handle, but no route handler is defined for that path. Either add the route and implement collection detail pages, or remove these links from the sidebar since they lead nowhere. The developer comment acknowledges this as a workaround for dynamic server data, but intentional doesn't mean safe—the router can't protect against missing routes when they're cast to any.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/shop/ShopLayout.tsx` around lines 247 - 252, The sidebar Link
in ShopLayout.tsx is casting routes to any to generate links to
/shop/collections/$handle despite no route handler existing; either add a proper
route and page component for the dynamic collection path (e.g., create a
CollectionDetail component and register a route like '/shop/collections/:handle'
in your router/route config so Link's to/params point to a real route), or stop
rendering collection links in ShopLayout (filter out collection entries before
mapping to Link and remove the to={to as any}/params={params as any} workaround)
so navigation doesn't point to a non-existent route.

Comment thread src/routes/shop.cart.tsx
Comment on lines +41 to +53
<dl className="flex flex-col gap-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Subtotal</dt>
<dd>{formatMoney(subtotal.amount, subtotal.currencyCode)}</dd>
</div>
<div className="flex justify-between border-t border-gray-200 dark:border-gray-800 pt-2 font-semibold">
<dt>Total</dt>
<dd>{formatMoney(total.amount, total.currencyCode)}</dd>
</div>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
Shipping and taxes calculated at checkout.
</p>
</dl>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Move the checkout note outside the description list.

The standalone <p> inside this <dl> is invalid description-list markup and can be announced inconsistently by assistive tech. Keep only term/value groups inside the dl and render the note after it.

Proposed fix
           <dl className="flex flex-col gap-2 text-sm">
             <div className="flex justify-between">
               <dt className="text-gray-600 dark:text-gray-400">Subtotal</dt>
               <dd>{formatMoney(subtotal.amount, subtotal.currencyCode)}</dd>
             </div>
             <div className="flex justify-between border-t border-gray-200 dark:border-gray-800 pt-2 font-semibold">
               <dt>Total</dt>
               <dd>{formatMoney(total.amount, total.currencyCode)}</dd>
             </div>
-            <p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
-              Shipping and taxes calculated at checkout.
-            </p>
           </dl>
+          <p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
+            Shipping and taxes calculated at checkout.
+          </p>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<dl className="flex flex-col gap-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Subtotal</dt>
<dd>{formatMoney(subtotal.amount, subtotal.currencyCode)}</dd>
</div>
<div className="flex justify-between border-t border-gray-200 dark:border-gray-800 pt-2 font-semibold">
<dt>Total</dt>
<dd>{formatMoney(total.amount, total.currencyCode)}</dd>
</div>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
Shipping and taxes calculated at checkout.
</p>
</dl>
<dl className="flex flex-col gap-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Subtotal</dt>
<dd>{formatMoney(subtotal.amount, subtotal.currencyCode)}</dd>
</div>
<div className="flex justify-between border-t border-gray-200 dark:border-gray-800 pt-2 font-semibold">
<dt>Total</dt>
<dd>{formatMoney(total.amount, total.currencyCode)}</dd>
</div>
</dl>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
Shipping and taxes calculated at checkout.
</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.cart.tsx` around lines 41 - 53, The <dl> in the cart UI
contains a stray <p> ("Shipping and taxes calculated at checkout.") which is
invalid inside a description list; move that note outside the <dl> so the <dl
className="flex flex-col gap-2 text-sm"> only contains term/value groups (the
two <div className="flex justify-between"> and the total row <div
className="flex justify-between border-t ...">). Render the paragraph
immediately after the closing </dl> (preserving its className and text) to
ensure correct semantics and consistent screen-reader announcements.

Comment on lines +34 to +36
const [selected, setSelected] = React.useState<Record<string, string>>(() =>
Object.fromEntries(product.options.map((o) => [o.name, o.values[0]!])),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does React rerun a useState initializer when props or loader data change without unmounting the component?

💡 Result:

No, React does not rerun a useState initializer when props or loader data change without unmounting the component. The initializer function (or initial value) is only called during the initial render (mount). On subsequent re-renders caused by prop changes, state updates, or parent re-renders, React preserves the existing state value and ignores the initializer argument. This applies whether the initializer is a plain value, a function depending on props, or loader data. If the initializer is a non-function value derived from props (e.g. useState(props.data)), that expression is evaluated on every render, but React discards the result and uses the preserved state. To avoid wasteful computations, use a function initializer: useState( => computeInitialValue(props)). Loader data in frameworks like React Router behaves similarly—it's external input causing re-renders, not remounts, so useState initializers are not rerun without unmounting.

Citations:


🏁 Script executed:

# First, let's read the full file to see the complete context
wc -l src/routes/shop.products.\$handle.tsx

Repository: TanStack/tanstack.com

Length of output: 105


🏁 Script executed:

# Read the full component file
cat -n src/routes/shop.products.\$handle.tsx

Repository: TanStack/tanstack.com

Length of output: 7671


🏁 Script executed:

# Check if there's already a useEffect that handles product changes
rg -A 5 "useEffect" src/routes/shop.products.\$handle.tsx

Repository: TanStack/tanstack.com

Length of output: 272


🏁 Script executed:

# Look at the loader to understand how product data flows
rg -B 5 -A 20 "export.*loader|export const loader" src/routes/shop.products.\$handle.tsx

Repository: TanStack/tanstack.com

Length of output: 47


🏁 Script executed:

# Check if there's a key prop on the component or route that might force remount
rg -B 3 -A 3 "key=|<Route" src/routes/shop.products.\$handle.tsx

Repository: TanStack/tanstack.com

Length of output: 775


🏁 Script executed:

# Check if there's a key prop or other route configuration that forces remount
rg -B 5 -A 5 "key=" src/routes/shop.products.\$handle.tsx

Repository: TanStack/tanstack.com

Length of output: 908


🏁 Script executed:

# Look at React Router/TanStack Router configuration to understand remount behavior
find . -name "*.tsx" -o -name "*.ts" | xargs rg -l "createFileRoute" | head -3 | xargs -I {} sh -c 'echo "File: {}"; head -50 {}'

Repository: TanStack/tanstack.com

Length of output: 2249


🏁 Script executed:

# Check if there are integration tests that navigate between products
rg -i "shop.*product|product.*navigate|navigate.*product" . --type tsx --type ts | grep -i test | head -10

Repository: TanStack/tanstack.com

Length of output: 92


🏁 Script executed:

# Check TanStack Router documentation or test files to understand component remount behavior
find . -type f \( -name "*.md" -o -name "*.test.ts" -o -name "*.test.tsx" \) | xargs rg -l "react-router|createFileRoute" 2>/dev/null | head -5

Repository: TanStack/tanstack.com

Length of output: 264


🏁 Script executed:

# Look for any documentation about route instance reuse
rg -i "route.*instance|component.*reuse|unmount|remount" . --type md | head -20

Repository: TanStack/tanstack.com

Length of output: 1257


🏁 Script executed:

# Search for package.json to check TanStack Router version and documentation
cat package.json | grep -A 2 -B 2 "react-router"

Repository: TanStack/tanstack.com

Length of output: 378


🏁 Script executed:

# Verify TanStack Router behavior by checking if there are examples of routes with params
rg -B 5 -A 15 "createFileRoute.*\\\$" src/routes/ --type tsx | head -50

Repository: TanStack/tanstack.com

Length of output: 92


🏁 Script executed:

# Check if there are other routes with similar param-based navigation that have state reset logic
rg -B 3 -A 10 "useEffect.*product\|useEffect.*params\|useEffect.*useLoaderData" src/routes/ --type tsx | head -40

Repository: TanStack/tanstack.com

Length of output: 92


🏁 Script executed:

# Look for other parameterized routes
rg -B 5 -A 15 'createFileRoute.*\$' src/routes/ | head -80

Repository: TanStack/tanstack.com

Length of output: 5703


🏁 Script executed:

# Check if any components with loader data have useEffect that depends on loader data
rg -B 2 -A 8 'useLoaderData|useEffect' src/routes/ | grep -A 8 -B 2 'useEffect.*\[.*\]' | head -50

Repository: TanStack/tanstack.com

Length of output: 47


🏁 Script executed:

# Look up TanStack Router behavior documentation in the blog
cat src/blog/tanstack-router-signal-graph.md | head -100

Repository: TanStack/tanstack.com

Length of output: 5726


Reset selected options when the loaded product changes.

useState only snapshots product.options on the first mount. If this route instance is reused for another product (e.g., user navigates from /shop/products/product-a to /shop/products/product-b), the previous option selections persist, and findMatchingVariant will attempt to match against the wrong product's options, leading to incorrect variant selection, wrong prices, or cart errors.

Proposed fix
 function ProductPage() {
   const { product } = Route.useLoaderData()
   const [selected, setSelected] = React.useState<Record<string, string>>(() =>
     Object.fromEntries(product.options.map((o) => [o.name, o.values[0]!])),
   )
+
+  React.useEffect(() => {
+    setSelected(
+      Object.fromEntries(product.options.map((o) => [o.name, o.values[0]!])),
+    )
+  }, [product.id, product.options])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.products`.$handle.tsx around lines 34 - 36, The selected
state is only initialized once and must be reset when a new product is loaded;
add an effect that watches the product identity (e.g., product.id or
product.handle) and calls setSelected with
Object.fromEntries(product.options.map(o => [o.name, o.values[0]!])) so the
selection reflects the current product before calling findMatchingVariant;
update the file to include this useEffect alongside the existing
selected/setSelected state initialization.

Comment on lines +109 to +114
{product.descriptionHtml ? (
<div
className="prose dark:prose-invert max-w-none mt-4"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does Shopify Storefront API Product.descriptionHtmlguarantee sanitization of scripts and unsafe attributes, or should storefronts sanitize it before rendering with ReactdangerouslySetInnerHTML?

💡 Result:

No, the Shopify Storefront API Product.descriptionHtml does not guarantee sanitization of scripts and unsafe attributes. Storefronts should sanitize it before rendering with React dangerouslySetInnerHTML to prevent XSS risks.

Citations:


Add HTML sanitization before rendering product.descriptionHtml.

Shopify Storefront API does not guarantee sanitization of Product.descriptionHtml. Using dangerouslySetInnerHTML without sanitization creates an XSS vulnerability if unsafe markup reaches the payload. Sanitize the HTML before rendering (e.g., using a library like sanitize-html or DOMPurify).

🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 112-112: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/shop.products`.$handle.tsx around lines 109 - 114, The
product.descriptionHtml is rendered with dangerouslySetInnerHTML without
sanitization; sanitize it first (e.g., import sanitize-html or DOMPurify) and
use the sanitized string when setting dangerouslySetInnerHTML. Concretely,
create a sanitized variable (e.g., const safeDescription =
sanitizeHtml(product.descriptionHtml, { allowedTags: [...], allowedAttributes:
{...} }) or DOMPurify.sanitize(product.descriptionHtml)), then replace
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} with
dangerouslySetInnerHTML={{ __html: safeDescription }} in the component rendering
the product (the JSX block that references product.descriptionHtml).

Comment on lines +55 to +80
const response = await fetch(
`https://${domain}/api/${version}/graphql.json`,
{
method: 'POST',
headers,
body: JSON.stringify({ query: input.query, variables: input.variables }),
},
)

if (!response.ok) {
throw new ShopifyError(
`Shopify API error: ${response.status} ${response.statusText}`,
)
}

const json = (await response.json()) as ShopifyResponse<TData>

if (json.errors?.length) {
throw new ShopifyError(
json.errors.map((e) => e.message).join('\n'),
json.errors,
)
}

if (!json.data) {
throw new ShopifyError('Shopify API returned no data and no errors.')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bound and normalize upstream failures here.

This helper sits on every shop request path. Right now a stalled Shopify connection can hang indefinitely, and transport / JSON parse failures escape as generic exceptions instead of ShopifyError.

Proposed fix
-  const response = await fetch(
-    `https://${domain}/api/${version}/graphql.json`,
-    {
-      method: 'POST',
-      headers,
-      body: JSON.stringify({ query: input.query, variables: input.variables }),
-    },
-  )
+  const controller = new AbortController()
+  const timeoutId = setTimeout(() => controller.abort(), 10_000)
+
+  let response: Response
+  try {
+    response = await fetch(`https://${domain}/api/${version}/graphql.json`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({ query: input.query, variables: input.variables }),
+      signal: controller.signal,
+    })
+  } catch (error) {
+    throw new ShopifyError(
+      error instanceof Error
+        ? `Shopify request failed: ${error.message}`
+        : 'Shopify request failed.',
+    )
+  } finally {
+    clearTimeout(timeoutId)
+  }
 
   if (!response.ok) {
     throw new ShopifyError(
       `Shopify API error: ${response.status} ${response.statusText}`,
     )
   }
 
-  const json = (await response.json()) as ShopifyResponse<TData>
+  let json: ShopifyResponse<TData>
+  try {
+    json = (await response.json()) as ShopifyResponse<TData>
+  } catch {
+    throw new ShopifyError('Shopify API returned invalid JSON.')
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/shopify/fetch.ts` around lines 55 - 80, The current fetch flow can
hang or surface non-Shopify exceptions; wrap the network/parse steps around a
timeout and try/catch and rethrow as ShopifyError with underlying error details:
when performing the fetch to `https://${domain}/api/${version}/graphql.json`
(the fetch call) use an AbortController with a sensible timeout and abort the
request on timeout, and surround both the fetch and the subsequent `await
response.json()` (parsed as `ShopifyResponse<TData>`) in a try/catch that
converts any thrown error into a `ShopifyError` (include the original error
message/stack as context), leaving the existing response.ok and json.errors
checks intact so upstream failures stay normalized.

Comment on lines +108 to +120
export const getProduct = createServerFn({ method: 'POST' })
.inputValidator(v.object({ handle: v.string() }))
.handler(async ({ data }): Promise<ProductDetail | null> => {
setBrowseCacheHeaders()
const result = await shopifyServerFetch<
ProductQueryResult,
{ handle: string }
>({
query: PRODUCT_QUERY,
variables: { handle: data.handle },
})
return result.product
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other server functions with input validators handle HTTP method
rg -n "createServerFn.*method:" --type ts -A2 | head -60

Repository: TanStack/tanstack.com

Length of output: 3689


🏁 Script executed:

#!/bin/bash
# Check if there are any GET methods used with createServerFn
rg "createServerFn.*method.*GET" --type ts

Repository: TanStack/tanstack.com

Length of output: 3804


🏁 Script executed:

#!/bin/bash
# Find and examine the setBrowseCacheHeaders function
rg -n "setBrowseCacheHeaders" --type ts -B2 -A5

Repository: TanStack/tanstack.com

Length of output: 2758


🏁 Script executed:

#!/bin/bash
# Check other product/data retrieval functions in shop.functions.ts
cat -n src/utils/shop.functions.ts | head -150

Repository: TanStack/tanstack.com

Length of output: 5444


Change getProduct to use GET method to enable edge caching.

getProduct is a read-only browse operation like getShop, getProducts, and getCollections, all of which use GET. However, getProduct uses POST, which prevents CDN edge caching regardless of setBrowseCacheHeaders() on line 111. POST responses are not cached by default. The input—a simple string handle parameter—can be passed as a query parameter instead of a request body, making GET feasible without sacrificing input validation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/shop.functions.ts` around lines 108 - 120, Change getProduct to use
method: 'GET' so responses can be cached at the edge; update the handler to read
the handle from the request query instead of the POST body and validate it
there. Specifically, modify createServerFn({ method: 'POST' }) to
createServerFn({ method: 'GET' }), adjust the handler signature to accept and
validate the request query (instead of destructuring data) so you pass
variables: { handle: query.handle } into shopifyServerFetch, keep calling
setBrowseCacheHeaders(), and preserve the same return of result.product; ensure
the input validation for the handle is applied to the query parameter (e.g.,
validate query.handle with v.string()).

Comment on lines +190 to +197
/* ──────────────────────────────────────────────────────────────────────────
* Cart — queries + mutations
*
* The cart lives in Shopify. We hit the Storefront API directly from the
* browser with the public token (no server hop needed), and manage the cart
* ID client-side via localStorage. Checkout redirects to `cart.checkoutUrl`
* which is Shopify-hosted.
* ────────────────────────────────────────────────────────────────────────── */
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading comment: cart operations run server-side, not client-side.

The comment states "We hit the Storefront API directly from the browser with the public token... and manage the cart ID client-side via localStorage." However, per the PR description and shop.functions.ts, cart operations run via server functions with the private token, and the cart ID is stored in an httpOnly cookie—not localStorage.

📝 Suggested fix
 /* ──────────────────────────────────────────────────────────────────────────
  * Cart — queries + mutations
  *
- * The cart lives in Shopify. We hit the Storefront API directly from the
- * browser with the public token (no server hop needed), and manage the cart
- * ID client-side via localStorage. Checkout redirects to `cart.checkoutUrl`
- * which is Shopify-hosted.
+ * The cart lives in Shopify. All cart operations go through server functions
+ * using the private Storefront API token, with the cart ID stored in an
+ * httpOnly cookie. Checkout redirects to `cart.checkoutUrl` which is
+ * Shopify-hosted.
  * ────────────────────────────────────────────────────────────────────────── */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/shopify-queries.ts` around lines 190 - 197, Update the misleading
header comment in the Cart section of src/utils/shopify-queries.ts to reflect
that cart operations are performed server-side via shop.functions.ts (using the
private Storefront token) and that the cart ID is stored in an httpOnly cookie
rather than localStorage; locate the block around the "Cart — queries +
mutations" comment and replace the browser/localStorage wording with a concise
statement that cart requests go through the server functions (e.g.,
shop.functions.ts) and cookies are used for cart ID storage.

The cart refactor moved all Shopify Storefront API calls server-side
(createServerFn), so VITE_SHOPIFY_* env vars are no longer consumed by
any code path. Vite still statically substitutes import.meta.env with
every VITE_* var set at build time, so if the deploy environment
defines VITE_SHOPIFY_STORE_DOMAIN with the same value as the server
SHOPIFY_STORE_DOMAIN, Netlify's secrets scanner matches the value
inside the client bundle and fails the build.

Removing the VITE_* entries from the client env schema (nothing
references them) plus unsetting VITE_SHOPIFY_* in Netlify's env config
resolves the scan without an allowlist.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/utils/env.ts (1)

23-26: Add fail-fast validation for Shopify env completeness and non-empty values.

Line 23-Line 26 currently accept optional raw strings, so empty values (or no token at all) can pass parsing and fail later as runtime Shopify errors. Consider enforcing a startup guard when Shopify is configured.

Proposed hardening (fail fast at startup)
 const parsedServerEnv = import.meta.env.SSR
   ? v.parse(serverEnvSchema, process.env)
   : {}

+if (import.meta.env.SSR) {
+  const storeDomain = parsedServerEnv.SHOPIFY_STORE_DOMAIN?.trim()
+  const apiVersion = parsedServerEnv.SHOPIFY_API_VERSION?.trim()
+  const privateToken = parsedServerEnv.SHOPIFY_PRIVATE_STOREFRONT_TOKEN?.trim()
+  const publicToken = parsedServerEnv.SHOPIFY_PUBLIC_STOREFRONT_TOKEN?.trim()
+
+  if (storeDomain && !privateToken && !publicToken) {
+    throw new Error(
+      'Shopify is configured but no storefront token is set. Provide SHOPIFY_PRIVATE_STOREFRONT_TOKEN or SHOPIFY_PUBLIC_STOREFRONT_TOKEN.',
+    )
+  }
+
+  if (apiVersion && !/^\d{4}-\d{2}$/.test(apiVersion)) {
+    throw new Error(
+      `Invalid SHOPIFY_API_VERSION '${apiVersion}'. Expected format YYYY-MM (example: 2026-01).`,
+    )
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/env.ts` around lines 23 - 26, The environment validators for
SHOPIFY_STORE_DOMAIN, SHOPIFY_API_VERSION, SHOPIFY_PUBLIC_STOREFRONT_TOKEN, and
SHOPIFY_PRIVATE_STOREFRONT_TOKEN currently allow empty strings; update the
validation in src/utils/env.ts to enforce non-empty values and a fail-fast
startup guard when any Shopify configuration is present: change
v.optional(v.string()) to require non-empty strings (e.g., v.string().min(1) or
equivalent) for the token and domain keys, make SHOPIFY_API_VERSION default
still but validate format if present, and add a startup check (e.g., in the
module init or a validateShopifyEnv function) that throws/logs and exits
immediately if Shopify is configured but required values (SHOPIFY_STORE_DOMAIN
and at least one non-empty token) are missing or blank.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/utils/env.ts`:
- Around line 23-26: The environment validators for SHOPIFY_STORE_DOMAIN,
SHOPIFY_API_VERSION, SHOPIFY_PUBLIC_STOREFRONT_TOKEN, and
SHOPIFY_PRIVATE_STOREFRONT_TOKEN currently allow empty strings; update the
validation in src/utils/env.ts to enforce non-empty values and a fail-fast
startup guard when any Shopify configuration is present: change
v.optional(v.string()) to require non-empty strings (e.g., v.string().min(1) or
equivalent) for the token and domain keys, make SHOPIFY_API_VERSION default
still but validate format if present, and add a startup check (e.g., in the
module init or a validateShopifyEnv function) that throws/logs and exits
immediately if Shopify is configured but required values (SHOPIFY_STORE_DOMAIN
and at least one non-empty token) are missing or blank.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 10d731f9-82a1-494e-9a9a-41db2b284da3

📥 Commits

Reviewing files that changed from the base of the PR and between bedc678 and 7bda760.

📒 Files selected for processing (1)
  • src/utils/env.ts

Netlify's secrets scanner treats every env var value as sensitive and
fails the build when any of those values appear in the output, with no
semantic awareness of which ones are public. SHOPIFY_STORE_DOMAIN
(tanstack-2.myshopify.com) and SHOPIFY_API_VERSION (2026-01) are
public by design — Shopify prints the domain on every hosted-checkout
URL, order email, and receipt, and the version is a platform-wide
identifier.

Moving both to source constants in src/server/shopify/fetch.ts:
  - Keeps them out of the env-var watchlist entirely (no scan false
    positive, no per-site SECRETS_SCAN_OMIT_KEYS config needed)
  - Makes the store identity explicit in the codebase where the fetch
    helper lives
  - Leaves actual secrets (SHOPIFY_PRIVATE_STOREFRONT_TOKEN) in env

After this change, the only Shopify env var the deploy needs is
SHOPIFY_PRIVATE_STOREFRONT_TOKEN.
@tannerlinsley tannerlinsley merged commit e9aa470 into main Apr 15, 2026
8 checks passed
@tannerlinsley tannerlinsley deleted the feat/shop-headless-shopify branch April 15, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant