Server-render Shopify data from any classic-SSR React stack with @storesynk/react.
@storesynk/react server-renders Shopify data inside Storesynk tags from any classic-SSR React stack: TanStack Start, Remix / React Router, vanilla Vite SSR. It is the sibling of @storesynk/next without the RSC coupling.
How it differs from @storesynk/next
Section titled “How it differs from @storesynk/next”In classic SSR a component renders on the server and again at hydration, so it cannot be async. The async half runs in your framework’s data layer and returns a serializable prepared object that feeds a synchronous component:
loader / createServerFn component (server + hydration) renderProduct(<Template/>, {handle}) ──▶ <StoresynkProduct prepared={...} /> = fetch + transform + payload = one dangerouslySetInnerHTMLBoth renders paint the same bytes, so hydration cannot mismatch, and the client runtime adopts the payloads with zero refetch. The protocol, revalidate, i18n, and coverage are identical across all three SSR packages. See SSR & adoption.
npm install @storesynk/react@betanpx @storesynk/react init # scaffolds storesynk.config.tsSHOPIFY_STORE_DOMAIN="your-store.myshopify.com"SHOPIFY_STOREFRONT_ACCESS_TOKEN="your-public-storefront-token"Import the config for its side effect at the top of your root module (TanStack Start: the router/root route module; Remix: app/root.tsx). On runtimes without process.env, such as Cloudflare Workers without nodejs_compat, pass domain/token to configureStoresynk directly. Everything is fetch-based.
This is an SSR package, not a browser bundle. Don’t add a storesynk.js script tag; <StoresynkStore>/<StoresynkRuntime> load the runtime after hydration.
Templates are static host elements
Section titled “Templates are static host elements”Templates handed to the render* helpers are plain Storesynk tags (or a raw HTML string): no client components, no React state, no handlers.
// src/templates/product.tsx - static host elements onlyexport const ProductTemplate = () => ( <> <h1><show-title></show-title></h1> <show-media main=""><img alt="" /></show-media> <div className="price-row"><show-price></show-price><show-compare-price></show-compare-price></div> <change-option group="1"><show-option-label></show-option-label><option-value><show-option-title></show-option-title></option-value></change-option> <add-to-cart><button type="button">Add to cart</button></add-to-cart> </>);A shared [handle] route serves the whole catalog, so use positional group= pickers, never a hardcoded name="Color", which silently hides on products without that option.
TanStack Start
Section titled “TanStack Start”// src/routes/products.$handle.tsximport { createFileRoute } from '@tanstack/react-router';import { createServerFn } from '@tanstack/react-start';import { getWebRequest } from '@tanstack/react-start/server';import { StoresynkProduct, StoresynkStore } from '@storesynk/react';import { createRequestScope, renderProduct, renderStore } from '@storesynk/react/server';import { ProductTemplate } from '../templates/product';
const getPage = createServerFn({ method: 'GET' }) .validator((handle: string) => handle) .handler(async ({ data: handle }) => { // `request` carries everything per-request: the buyer's locale cookie, the // URL's variant selection / listing filters, the JSON-LD canonical. Explicit // options (`locale`, `state`, `selectedOptions`) always win over it. const opts = { request: getWebRequest(), fresh: true, scope: createRequestScope() }; return { store: await renderStore(opts), product: await renderProduct(<ProductTemplate />, { handle, ...opts }), }; });
export const Route = createFileRoute('/products/$handle')({ loader: ({ params }) => getPage({ data: params.handle }), component: () => { const { store, product } = Route.useLoaderData(); return ( <StoresynkStore prepared={store}> <StoresynkProduct prepared={product} /> </StoresynkStore> ); },});On document loads getWebRequest() is the page request, so filtered collection URLs and variant links server-render exactly. On client-side navigations the server fn sees its own RPC request; the helpers fall back to the default view and the client engine applies the URL state.
Remix / React Router
Section titled “Remix / React Router”export const loader = async ({ params, request }: LoaderFunctionArgs) => { const opts = { request, fresh: true, scope: createRequestScope() }; return { store: await renderStore(opts), product: await renderProduct(<ProductTemplate />, { handle: params.handle!, ...opts }), };};
const ProductPage = () => { const { store, product } = useLoaderData<typeof loader>(); return ( <StoresynkStore prepared={store}> <StoresynkProduct prepared={product} /> </StoresynkStore> );};export default ProductPage;In a real app, renderStore + <StoresynkStore prepared> live in the root route so the store and cart wrap every page.
Vanilla Vite SSR
Section titled “Vanilla Vite SSR”In your server render function, call the render* helpers with the incoming Request, fresh: true, and a createRequestScope(). Serialize the Prepared* objects into the page alongside your app state and feed them to the components on both renders.
What’s included
Section titled “What’s included”@storesynk/react/server (data layer) | @storesynk/react (components) |
|---|---|
renderStore | <StoresynkStore> |
renderProduct | <StoresynkProduct> |
renderList | <StoresynkList> |
renderCollection (URL filter/sort/page state derives from request; parseListingParams to override) | <StoresynkCollection> |
renderLocalePicker | <StoresynkLocalePicker> |
renderProductJsonLd | <StoresynkProductJsonLd> |
productSeo (head-API-neutral - map it to your router’s head/meta yourself) | - |
getRequestLocale / getRequestCustomer (accept a Cookie header / Request / Headers) | - |
checkGate (returns the gate decision for you to map to your router’s redirect/notFound) | - |
createRequestScope, parseListingParams, stringifySlot | - |
Raw fetch helpers: getProduct, getShop, getProductHandles, getCollectionProducts, getCollectionListing, getProductMetafields, getLocalization, getAllProducts, … | <StoresynkStatic> (no-data markup: cart drawer, client-only templates), <StoresynkRuntime> |
Every render* helper returns a serializable Prepared* object for the matching component’s prepared prop. The cart, predictive search, and money/file/list.* metafields hydrate client-side as on a static page.
Not included: the server-owned cart (Next-specific). The default client cart works everywhere.
Gotchas
Section titled “Gotchas”preparedmust come from arender*helper in the data layer. A component with a missing or emptypreparedrenders nothing, with no error.- Dynamic routes need
fresh: trueplus a shared scope. Fetches are module-cached per process; pass onecreateRequestScope()across every helper call in that request. <StoresynkStatic>pullsreact-dom/serverinto the client bundle. Reserve it for no-data markup like the<storesynk-cart>drawer.- React 18 works at runtime, but the bundled JSX typings want React 19’s
@types/react; React 18 consumers keep their ownJSX.IntrinsicElementsdeclarations. - Portals to
document.bodymove tags outside<storesynk-store>, so they render empty. Portal into the store’s subtree.