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
node_modules/*
dist
.output
.nitro
.vercel
.yarn/install-state.gz
.env*.local
src/routeTree.gen.ts
+21 -16
View File
@@ -1,14 +1,16 @@
# Vite Shopify Storefront
A server-rendered Shopify storefront built with TanStack Start (Vite + TanStack
Router), TanStack Query, Tailwind CSS v4, Hono and `@shopify/hydrogen` (used
only as a Storefront API client and typed `gql()`). It's a port of
`nextjs-templates/shopify-storefront` without the AI store assistant.
A server-rendered Shopify storefront built with Vite, TanStack Start (TanStack
Router + TanStack Query), Hono and Tailwind CSS v4. `@shopify/hydrogen` is used
only as the Storefront API client and for typed `gql()` documents. It's a port
of `nextjs-templates/shopify-storefront` without the AI store assistant.
Runs in the nodesandbox runtime: `npm install` → `npm run dev` → port 3000.
## Getting started
```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
yarn install
yarn dev # http://localhost:3000
@@ -16,26 +18,29 @@ yarn dev # http://localhost:3000
| Script | What it does |
| ---------------- | --------------------------------------------------------- |
| `yarn dev` | Vite dev server with SSR and the `/api` routes |
| `yarn build` | Production build via Nitro (`.output/`, or Vercel's Build Output on Vercel) |
| `yarn start` | Run the Node build from `.output/` |
| `yarn dev` | Vite dev server with SSR and the Hono `/api` routes |
| `yarn build` | Production build to `dist/` |
| `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
auto-detection sees `@shopify/hydrogen` and picks the Hydrogen preset. It also
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
automatically, or set `VITE_SITE_URL`.
The sandbox runs Vite's native tools as WebAssembly (`NAPI_RS_FORCE_WASI`), and
its installer resolves from `package.json` ranges rather than `yarn.lock`. So:
- `vite` is pinned to **8.1.3**, which uses rolldown 1.1.x; `rolldown` and
`@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
```
src/
server.ts Start's server entry: Hono routes /api/*, the rest goes to Start
router.tsx Router + per-request QueryClient, SSR query integration
routes/ File routes; loaders prefetch into TanStack Query
api/$.ts Mounts the Hono app for every /api/* request
server/
app.ts Hono API
account.ts login, logout, register, recover, reset, activate, me
@@ -56,7 +61,7 @@ src/
| `app/api/account/*` route handlers | `src/server/account.ts` (Hono) |
| `next/headers` cookies | `hono/cookie`, and `getCookie` in server functions |
| `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
to the Storefront API with the public token. Account requests stay on the
+7 -7
View File
@@ -4,9 +4,8 @@
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite dev",
"dev": "vite dev --host 0.0.0.0 --port 3000",
"build": "vite build",
"start": "node .output/server/index.mjs",
"typecheck": "tsc --noEmit && hydrogen gql check"
},
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e",
@@ -14,6 +13,7 @@
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@remixicon/react": "^4.9.0",
"@rolldown/binding-wasm32-wasi": "1.1.4",
"@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607",
"@tanstack/query-core": "^5.104.1",
"@tanstack/react-query": "^5.104.1",
@@ -27,21 +27,21 @@
"graphql": "^17.0.2",
"hono": "^4.13.12",
"lucide-react": "^0.562.0",
"nitro": "3.0.260903-beta",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"rolldown": "1.1.4",
"tailwind-merge": "^3.7.0",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@tailwindcss/vite": "4.3.1",
"@types/node": "^26.6.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"tailwindcss": "^4.3.3",
"@vitejs/plugin-react": "6.0.0",
"tailwindcss": "4.3.1",
"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))
.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) {
queryClient.prefetchQuery(productRecommendationsQueryOptions(product.id));
await queryClient
.ensureQueryData(productRecommendationsQueryOptions(product.id))
.catch(() => undefined);
}
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 { account } from './account';
+1 -1
View File
@@ -17,6 +17,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"],
"include": ["src"],
"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 { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { nitro } from 'nitro/vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath, URL } from 'node:url';
// 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
// domain; set VITE_SITE_URL to override it anywhere.
const siteUrl =
process.env.VITE_SITE_URL ??
(process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000');
// the server render and the client agree on it; set VITE_SITE_URL in production.
const siteUrl = process.env.VITE_SITE_URL ?? 'http://localhost:3000';
export default defineConfig({
server: { port: 3000 },
plugins: [
// Start must come before the React plugin.
tanstackStart(),
// Builds the server for the host: Vercel's Build Output when deployed
// there, a standalone Node server (.output/) everywhere else.
nitro(),
react(),
tailwindcss(),
],
+393 -324
View File
File diff suppressed because it is too large Load Diff