Run on the nodesandbox runtime: harness toolchain, Hono server entry

The sandbox loads Vite's native tools as WASI (NAPI_RS_FORCE_WASI=error)
and only ships glue for rolldown 1.1.x. Vite 8.3 pulls rolldown 1.2.x,
whose binding needs emnapi 2.0 alpha, so boot failed with 'WASI binding
not found'. Match the runtime-host harness TanStack sample instead:

- Pin vite 8.1.3, rolldown + @rolldown/binding-wasm32-wasi 1.1.4,
  @vitejs/plugin-react 6.0.0, tailwindcss + @tailwindcss/vite 4.3.1
  (exact versions: the sandbox resolves package.json, not yarn.lock)
- Drop Nitro (it brings rolldown 1.2.x) and the Vercel config
- Hono now sits in src/server.ts, Start's server entry, routing /api/*
  ahead of start.fetch, as in the harness
- dev runs 'vite dev --host 0.0.0.0 --port 3000'; config is .mjs
- Await product recommendations in the loader to fix a hydration
  mismatch between the SSR skeleton and the settled client render

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rami Bitar
2026-10-03 14:51:11 -04:00
co-authored by Claude Opus 5.5
parent 099ba454e0
commit e09bc0e1fe
11 changed files with 450 additions and 389 deletions
-3
View File
@@ -1,9 +1,6 @@
.DS_Store .DS_Store
node_modules/* node_modules/*
dist dist
.output
.nitro
.vercel
.yarn/install-state.gz .yarn/install-state.gz
.env*.local .env*.local
src/routeTree.gen.ts src/routeTree.gen.ts
+21 -16
View File
@@ -1,14 +1,16 @@
# Vite Shopify Storefront # Vite Shopify Storefront
A server-rendered Shopify storefront built with TanStack Start (Vite + TanStack A server-rendered Shopify storefront built with Vite, TanStack Start (TanStack
Router), TanStack Query, Tailwind CSS v4, Hono and `@shopify/hydrogen` (used Router + TanStack Query), Hono and Tailwind CSS v4. `@shopify/hydrogen` is used
only as a Storefront API client and typed `gql()`). It's a port of only as the Storefront API client and for typed `gql()` documents. It's a port
`nextjs-templates/shopify-storefront` without the AI store assistant. of `nextjs-templates/shopify-storefront` without the AI store assistant.
Runs in the nodesandbox runtime: `npm install` → `npm run dev` → port 3000.
## Getting started ## Getting started
```sh ```sh
cp .env.example .env.local # defaults to the tokenless mock.shop cp .env.example .env.local # optional; without it the store is mock.shop
corepack enable # uses the Yarn 4 pinned in package.json corepack enable # uses the Yarn 4 pinned in package.json
yarn install yarn install
yarn dev # http://localhost:3000 yarn dev # http://localhost:3000
@@ -16,26 +18,29 @@ yarn dev # http://localhost:3000
| Script | What it does | | Script | What it does |
| ---------------- | --------------------------------------------------------- | | ---------------- | --------------------------------------------------------- |
| `yarn dev` | Vite dev server with SSR and the `/api` routes | | `yarn dev` | Vite dev server with SSR and the Hono `/api` routes |
| `yarn build` | Production build via Nitro (`.output/`, or Vercel's Build Output on Vercel) | | `yarn build` | Production build to `dist/` |
| `yarn start` | Run the Node build from `.output/` |
| `yarn typecheck` | `tsc` plus `hydrogen gql check` for the GraphQL documents | | `yarn typecheck` | `tsc` plus `hydrogen gql check` for the GraphQL documents |
## Deploying to Vercel ## nodesandbox compatibility
`vercel.json` pins the **TanStack Start** preset. Without it Vercel's The sandbox runs Vite's native tools as WebAssembly (`NAPI_RS_FORCE_WASI`), and
auto-detection sees `@shopify/hydrogen` and picks the Hydrogen preset. It also its installer resolves from `package.json` ranges rather than `yarn.lock`. So:
installs with Corepack so Vercel uses Yarn 4. Set the `VITE_SHOPIFY_*` variables
in the project; the production domain is picked up for canonical URLs - `vite` is pinned to **8.1.3**, which uses rolldown 1.1.x; `rolldown` and
automatically, or set `VITE_SITE_URL`. `@rolldown/binding-wasm32-wasi` are pinned to **1.1.4**. Rolldown 1.2.x's
WASI binding needs glue the runtime doesn't support ("WASI binding not
found"). Bump these only together with the runtime.
- `@vitejs/plugin-react` (6.0.0) and Tailwind (4.3.1) match the harness samples.
- No Nitro: it brings its own rolldown 1.2.x.
## Layout ## Layout
``` ```
src/ src/
server.ts Start's server entry: Hono routes /api/*, the rest goes to Start
router.tsx Router + per-request QueryClient, SSR query integration router.tsx Router + per-request QueryClient, SSR query integration
routes/ File routes; loaders prefetch into TanStack Query routes/ File routes; loaders prefetch into TanStack Query
api/$.ts Mounts the Hono app for every /api/* request
server/ server/
app.ts Hono API app.ts Hono API
account.ts login, logout, register, recover, reset, activate, me account.ts login, logout, register, recover, reset, activate, me
@@ -56,7 +61,7 @@ src/
| `app/api/account/*` route handlers | `src/server/account.ts` (Hono) | | `app/api/account/*` route handlers | `src/server/account.ts` (Hono) |
| `next/headers` cookies | `hono/cookie`, and `getCookie` in server functions | | `next/headers` cookies | `hono/cookie`, and `getCookie` in server functions |
| `next/image` | `src/components/ui/image.tsx` (Shopify CDN `srcset`) | | `next/image` | `src/components/ui/image.tsx` (Shopify CDN `srcset`) |
| `NEXT_PUBLIC_*` env | `VITE_*` env | | `NEXT_PUBLIC_*` env | `VITE_*` env (`VITE_SHOPIFY_DOMAIN` defaults to mock.shop) |
Cart and catalogue reads after the first page load go straight from the browser Cart and catalogue reads after the first page load go straight from the browser
to the Storefront API with the public token. Account requests stay on the to the Storefront API with the public token. Account requests stay on the
+7 -7
View File
@@ -4,9 +4,8 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev --host 0.0.0.0 --port 3000",
"build": "vite build", "build": "vite build",
"start": "node .output/server/index.mjs",
"typecheck": "tsc --noEmit && hydrogen gql check" "typecheck": "tsc --noEmit && hydrogen gql check"
}, },
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e", "packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e",
@@ -14,6 +13,7 @@
"@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0",
"@remixicon/react": "^4.9.0", "@remixicon/react": "^4.9.0",
"@rolldown/binding-wasm32-wasi": "1.1.4",
"@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607", "@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607",
"@tanstack/query-core": "^5.104.1", "@tanstack/query-core": "^5.104.1",
"@tanstack/react-query": "^5.104.1", "@tanstack/react-query": "^5.104.1",
@@ -27,21 +27,21 @@
"graphql": "^17.0.2", "graphql": "^17.0.2",
"hono": "^4.13.12", "hono": "^4.13.12",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"nitro": "3.0.260903-beta",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"rolldown": "1.1.4",
"tailwind-merge": "^3.7.0", "tailwind-merge": "^3.7.0",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "4.3.1",
"@types/node": "^26.6.4", "@types/node": "^26.6.4",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "6.0.0",
"tailwindcss": "^4.3.3", "tailwindcss": "4.3.1",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^8.3.2" "vite": "8.1.3"
} }
} }
-18
View File
@@ -1,18 +0,0 @@
import { createFileRoute } from '@tanstack/react-router';
import api from '@/server/app';
// Every /api/* request goes to the Hono app (src/server/app.ts), which owns
// the customer-account endpoints and their session cookie.
const handle = ({ request }: { request: Request }) => api.fetch(request);
export const Route = createFileRoute('/api/$')({
server: {
handlers: {
GET: handle,
POST: handle,
PUT: handle,
PATCH: handle,
DELETE: handle,
},
},
});
+6 -2
View File
@@ -18,9 +18,13 @@ export const Route = createFileRoute('/products/$handle')({
.ensureQueryData(productQueryOptions(handle)) .ensureQueryData(productQueryOptions(handle))
.catch(() => null); .catch(() => null);
// Warm the recommendations without holding up the page. // Await the recommendations too: a fetch still in flight during SSR renders
// the loading skeleton on the server but the settled result on the client,
// which is a hydration mismatch. A failure just leaves the section out.
if (product) { if (product) {
queryClient.prefetchQuery(productRecommendationsQueryOptions(product.id)); await queryClient
.ensureQueryData(productRecommendationsQueryOptions(product.id))
.catch(() => undefined);
} }
return { product }; return { product };
+19
View File
@@ -0,0 +1,19 @@
// TanStack Start's custom server entry. Hono owns request routing: /api/* goes
// to the account API (src/server/app.ts), and everything else falls through to
// TanStack Start for SSR, routes and server functions.
import start from '@tanstack/react-start/server-entry';
import { Hono } from 'hono';
import api from '@/server/app';
const server = new Hono();
server.route('/', api);
// Unknown API paths stay JSON 404s rather than rendering the app's 404 page.
server.all('/api/*', (c) => c.json({ error: 'Not found' }, 404));
server.all('*', (c) => start.fetch(c.req.raw));
export default {
fetch(request: Request): Promise<Response> {
return Promise.resolve(server.fetch(request));
},
};
+1 -1
View File
@@ -1,4 +1,4 @@
// The API surface, mounted by the Start server route in routes/api/$.ts. // The API surface, mounted ahead of TanStack Start in src/server.ts.
import { Hono } from 'hono'; import { Hono } from 'hono';
import { account } from './account'; import { account } from './account';
+1 -1
View File
@@ -17,6 +17,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src", "vite.config.ts"], "include": ["src"],
"exclude": ["node_modules", "dist", ".output"] "exclude": ["node_modules", "dist", ".output"]
} }
-6
View File
@@ -1,6 +0,0 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "tanstack-start",
"installCommand": "corepack enable && yarn install",
"buildCommand": "yarn build"
}
+2 -11
View File
@@ -1,27 +1,18 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { nitro } from 'nitro/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath, URL } from 'node:url'; import { fileURLToPath, URL } from 'node:url';
// Absolute origin for canonical and Open Graph URLs. Inlined at build time so // Absolute origin for canonical and Open Graph URLs. Inlined at build time so
// the server render and the client agree on it. Vercel provides the production // the server render and the client agree on it; set VITE_SITE_URL in production.
// domain; set VITE_SITE_URL to override it anywhere. const siteUrl = process.env.VITE_SITE_URL ?? 'http://localhost:3000';
const siteUrl =
process.env.VITE_SITE_URL ??
(process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000');
export default defineConfig({ export default defineConfig({
server: { port: 3000 }, server: { port: 3000 },
plugins: [ plugins: [
// Start must come before the React plugin. // Start must come before the React plugin.
tanstackStart(), tanstackStart(),
// Builds the server for the host: Vercel's Build Output when deployed
// there, a standalone Node server (.output/) everywhere else.
nitro(),
react(), react(),
tailwindcss(), tailwindcss(),
], ],
+393 -324
View File
File diff suppressed because it is too large Load Diff