Skip to content

Server-side rendering & adoption

The same tags you write on a plain HTML page can be server-rendered with real Shopify data,

The same tags you write on a plain HTML page can be server-rendered with real Shopify data, then adopted flash-free by the client runtime. This is optional and additive - a page that never does SSR behaves exactly as before.

  • @storesynk/astro is the Astro integration (npm i @storesynk/astro@beta). It exposes the integration storesynk({ domain, token, country?, language?, appNamespace?, injectRuntime?, trackEvents? }) (in astro.config.mjs; appNamespace is the Storesynk app’s app--<numeric-app-id> metafield namespace, forwarded as app-namespace on the rendered <storesynk-store> - required for the bundle widget) and .astro wrappers imported from @storesynk/astro/components: <StoresynkStore class?>, <StoresynkProduct handle="…" revalidate? class?>, <StoresynkList collection limit? country? language? class?>, and <StoresynkSEO handle title? description? url? siteName?> (head-level SEO). Every wrapper’s class is forwarded to its rendered host tag (<storesynk-store> / <storesynk-product> / <storesynk-list>) - grid/layout classes go there. At build/SSR time the wrappers fetch, fill the slotted Storesynk tags, and embed payloads; the integration injects the @storesynk/elements runtime on every page (disable with injectRuntime: false). Its runtime subpath exposes data helpers for dynamic routes: getProductHandles(max) (cursor-paginated) and getCollectionProducts(...). There is no separate bundle script for the Astro package - it is a build-time integration, not a browser bundle.
  • @storesynk/next is the React/App Router sibling (npm i @storesynk/next@beta; no bundle script - an SSR/build-time package, not a browser bundle). Requires Next 15+ / React 19+ and the App Router. Only the two credentials come from environment variables (SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_ACCESS_TOKEN - the canonical names the init scaffold writes and the missing-credentials error prints; STORESYNK_STORE_DOMAIN / STORESYNK_PUBLIC_TOKEN are still accepted as a fallback); everything else is set programmatically via a root storesynk.config.ts calling configureStoresynk({ country?, language?, persistLocale?, customerClientId?, …, pixels?, cart? }) (the one shared StoresynkConfig, domain/token optional - they fall back to env), imported for its side effect at the top of app/layout.tsx. Scaffold it with npx @storesynk/next init. There is no appNamespace option and no STORESYNK_APP_NAMESPACE / STORESYNK_COUNTRY / STORESYNK_LANGUAGE env var - the app metafield namespace is hard-coded in the package for now (forwarded as app-namespace automatically). It exports async Server Component wrappers <StoresynkStore country? language? trackEvents? serverCart? loadRuntime? className?>, <StoresynkProduct handle revalidate? syncUrl? selectedOptions? locale? className?>, <StoresynkList collection limit? className?>, <StoresynkCollection handle? allHandle? pageSize? sortKey? reverse? infinite? noUrlSync? urlKey? state? revalidate? locale? fresh? className?> (the server-rendered, URL-state-aware collection listing - revalidate stamps revalidate on the host for the silent post-adoption freshness backstop; see below), <StoresynkLocalePicker locale? fresh?> (server-renders the Markets <change-country> / <change-language> select-mode region and embeds the localization payload), <StoresynkStatic className?> (stringify-and-inject escape hatch), and <StoresynkProductJsonLd handle url?> (a body-level Product JSON-LD script); plus productMetadata(handle, { title?, description?, url?, siteName? }) returning a Next Metadata for generateMetadata (the head half of Astro’s <StoresynkSEO>), and <StoresynkRuntime /> (from @storesynk/next/client, the 'use client' loader that imports the elements bundle after hydration - <StoresynkStore> renders it unless loadRuntime={false}). Its runtime subpath (@storesynk/next/runtime) exposes getProduct, getShop, getCollectionProducts, getProductMetafields, getProductHandles(max), getCollectionListing(handle, first?, query?, locale?, opts?) (one page + facets for the server-driven listing; handle null/'' = catalog mode), and getLocalization(locale?, opts?) (the shop’s Markets countries/languages). The package also re-exports parseListingParams (decode a route’s searchParams into the listing’s URL state - filters/sort/page - to feed <StoresynkCollection state>) and the types I18nState, ListingSearchState. It uses the identical payload/ss-adopt/revalidate/i18n/coverage protocol below. New wrapper props: <StoresynkStore serverCart> swaps the cart transport to Next server actions - the cart id lives in an HttpOnly shopify_cartId cookie (readable/writable server-side, so RSC/route handlers and the client stay on one cart) instead of the default client localStorage['_storesynk-cart-id']; use it when the server must read or mutate the cart. <StoresynkProduct selectedOptions={{…}}> server-selects an initial variant by option map (falls back to the default variant) and <StoresynkProduct syncUrl> stamps sync-url on the host so the chosen variant mirrors into the URL client-side (see storesynk-product above). React opacity rule: wrapper children must be static host elements (plain tags - no client components, no React state/handlers); they are rendered to an HTML string on the server and injected via dangerouslySetInnerHTML, so React never reconciles the subtree the engine mutates. Storesynk tags inside a re-rendering client component must be wrapped in <StoresynkStatic>. className pass-through: every wrapper accepts className? (mirroring Astro’s class) and serializes it as class on its rendered host tag - grid/layout classes go there; classes on tags inside the slot template pass through untouched as well.
  • @storesynk/react is the framework-agnostic React sibling of @storesynk/next (npm i @storesynk/react@beta; no bundle script - an SSR/build-time package) for classic-SSR React stacks (TanStack Start, Remix / React Router, vanilla Vite SSR) - anywhere that is not the Next.js RSC model. Because a classic-SSR component renders on the server and again at hydration it can’t be async, so the async work is split out: @storesynk/react/server helpers (renderStore, renderProduct, renderList, renderCollection, renderLocalePicker, renderProductJsonLd) run in the framework’s data layer (loader / createServerFn) and return a serializable Prepared* object; the synchronous components in @storesynk/react (<StoresynkStore>, <StoresynkProduct>, <StoresynkList>, <StoresynkCollection>, <StoresynkLocalePicker>, <StoresynkProductJsonLd>, <StoresynkStatic>, <StoresynkRuntime>) take it via a prepared prop and inject it with one dangerouslySetInnerHTML. Both renders paint the same prepared bytes, so hydration can’t mismatch and adoption is refetch-free. The /server subpath also exposes productSeo (a head-API-neutral SEO object you map to your router’s head - unlike Next’s productMetadata), getRequestLocale / getRequestCustomer (accept a Cookie header / Request / Headers), checkGate (returns the gate decision for you to map to your router’s redirect/notFound - it does not throw router control-flow), createRequestScope() (per-request fetch dedupe for dynamic routes with fresh: true), parseListingParams, and the raw fetch helpers (getProduct, getShop, getProductHandles, getCollectionProducts, getProductMetafields, getCollectionListing, getLocalization). Config is the same storesynk.config.ts + configureStoresynk({...}) + npx @storesynk/react init as Next (credentials from SHOPIFY_STORE_DOMAIN / SHOPIFY_STOREFRONT_ACCESS_TOKEN env, STORESYNK_* overrides; pass domain/token to configureStoresynk on edge runtimes without process.env, e.g. Cloudflare Workers). It uses the identical payload/ss-adopt/revalidate/ i18n/coverage protocol below; templates handed to the render* helpers are the same static host elements as Next’s wrapper children. Not included vs @storesynk/next: the server-owned cart (Next’s HttpOnly shopify_cartId cookie via server actions has no framework-neutral equivalent - the default client cart works everywhere). See build-patterns §4g.
  • @storesynk/core/server is the framework-agnostic engine underneath all three, and each fill is byte-identical to the client output (that identity is what makes adoption repaint-free): renderProductFragment(html, { product, shop }) fills product displayers, media, and the sale badge (and expands each <storesynk-bundle> widget - see the Bundles bullet below); fill-interactive.ts fills the interactive/optional tags (options, thumbnails, selling-plan initial state, metafields); renderListItems(templateHtml, { products, shop }) clones a list item template per product; renderCollectionListing(slotHtml, { listing, shop, locale?, state? }) fills a whole <storesynk-collection> region (grid, facet controls, active-filter chips, result count, sort selection) for the requested state (default or a filtered/sorted/paged view) and embeds the data-storesynk-listing payload; renderLocalePickers(html, { localization, country?, language? }) fills the <change-country> / <change-language> selects and embeds the data-storesynk-localization payload; productJsonLd(product, shop, { url }) builds schema.org Product structured data.

What is server-rendered now (filled in the built HTML, SEO- and no-JS-complete):

  • Displayers - every show-* displayer, show-option-label, show-active-option-title.
  • Media - show-image, and show-media for MediaImage and now Video / ExternalVideo / Model3d; show-thumbnail template expansion (indexed clones at width=200, with ss-active / aria-current on the active one).
  • Options - change-option in all three modes (swatch / <select> / radio) with ss-active, ss-unavailable, role="radiogroup", roving tabindex, and aria-checked. The client _populate reuses these values in place, so it adopts with no repaint. A change-option whose lookup misses - a name/group a product doesn’t have, including every group on an optionless (single-variant) product - is stamped display:none in the SSR markup, exactly as the client hides it, so positional group="1/2/3" PDPs render with the extras hidden from the first byte (no flash before hydration).
  • Selling plans - the purchase-option widget’s full initial state, matching the client’s subscribe-first default. change-purchase-option gets [ss-active] on the initial mode (the recurring option by default; single under default-option="single"), and - when subscribing
    • change-selling-plan is fully rendered: one selling-plan-option clone per real plan with its value (real plan id), [ss-active] on the preselected first plan, show-selling-plan-name (honoring the labels attribute), and show-selling-plan-price formatted via the same money formatter (shop moneyFormat, or the Intl path for foreign-presentment adjustments) - byte- identical to the client ChangeSellingPlan render, so it adopts with no repaint. The a11y attributes are part of that parity: each purchase-option / selling-plan-option gets the same Clickable stamping the client applies (role="button" + tabindex="0" on a bare pill, or nothing extra when the template nests a <button>) plus aria-pressed on the resolved control, in the same order - so a server-rendered subscription widget is keyboard-operable before hydration. A product without the widget (cards/members) server-renders one-time (no plan selected), same as the client.
  • Metafields - show-metafield / metafield-wrapper for portable types: scalar, rich_text_field, dimension/volume/weight, and url/link. Still client-side: money, file_reference, and any list.* metafield type (these need the viewer’s locale or the client list engine).
  • Lists - storesynk-list / the collection grid via renderListItems + the ss-adopt marker (below). A nested <storesynk-product> card template inside a list is filled per clone but the original template is left untouched.
  • Server-driven listing - a whole <storesynk-collection> region via renderCollectionListing: the product-list grid (one filled card + its own product payload per product, host stamped ss-adopt), change-filter / filter-list facet values (counts, swatch styles, active states), active-filter-list chips, show-result-count, the clear-filters / load-more / change-price empty/state stamps, and the change-sort selection. Only the requested view is rendered - pass state (from parseListingParams) to server-render a filtered/sorted/paged shared URL too. A data-storesynk-listing payload (facets + pageInfo + locale + active/sortKey/ reverse/pages) rides as a direct child of storesynk-collection; the client adopts any state matching its own URL read with zero refetch, and on a mismatch still seeds engine state from the payload (so count/facets/grid hold the server render under [ss-loading] instead of collapsing to a phantom-empty state until the fetch lands). The payload’s locale stamp is the authority - it supersedes the store’s revalidateAdopted flag, so a buyer-market SSR page the server already rendered for THIS market doesn’t needlessly refetch.
  • Locale pickers - the Markets <change-country> / <change-language> select mode via renderLocalePickers: options (country names, currency labels when show-currency, language endonyms), the active selection, and the multi-market unhide (single-market shops stay hidden + ss-empty, like the client). A data-storesynk-localization payload lets the store adopt the localization data instead of fetching. The pickers’ template (pill) mode stays client-rendered.
  • Bundles - <storesynk-bundle> is expanded server-side: one filled bundle-offer clone per live bundle (all show-bundle-* displayers, member cards filled from their own product - options/selects, availability, ss-variants-truncated - member quantity/subtotal displayers, and the add button’s states), each offer stamped with ss-bundle-key, the host marked ss-adopt, and the authored template kept as a hidden first child (hidden + inline display:none). The bundle’s source data rides the product payload’s __storesynkMetafields, so on load the client recomputes the live set (date windows may have moved since a static build) and hydrates the server DOM in place with zero refetch; on drift it re-renders from the kept template; a locale mismatch falls back to a live fetch.
  • Volume discounts - <storesynk-volume-discount> is expanded server-side the same way: the winning discount’s template is cloned once (heading, one volume-tier row per tier with all show-tier-* displayers filled and the add button’s states), the clone stamped with ss-volume-key, the host and its volume-tier-list marked ss-adopt, and the authored template kept as a hidden first child. The discovery data rides the product payload (__storesynkVolumeDiscount); on load the client re-selects the live discount (date windows may have moved), hydrates in place with zero refetch, re-prices the tiers against the adopted variant, and re-renders only on drift; a locale mismatch falls back to a live fetch.
  • Mix & match - <storesynk-mix-match> is expanded server-side in its zero-selection state (the only state a build/request can know): one filled mix-match-offer clone per live bundle, each stamped with ss-mix-match-key; per-section clones with show-section-* filled, ss-open/ss-met on the mix-match-section, the toggle-section control’s aria-expanded/aria-controls and the panel’s id + hidden; and one pool card per pool product filled through the same member-card pipeline as bundles (displayers, option controls, availability, ss-variants-truncated). The empty-selection summary renders as the client would: totals/savings/hint/free-shipping hidden + [ss-empty], the mix-match-selection-list empty with its pick template kept hidden, and add-mix-match-to-cart [ss-incomplete] + aria-disabled. The host, each mix-match-section-list, mix-match-item-list, and mix-match-selection-list get ss-adopt, and the authored template is kept as a hidden first child. The source metaobjects ride the product payload’s __storesynkMetafields, so the client recomputes the live set (date windows may have moved), adopts in place with zero refetch, and re-renders from the kept template only on drift. Handle mode is not server-rendered - a <storesynk-mix-match handle="…"> (standalone builder page) is left untouched and client-fetches, so plan for a loading state there.
  • Product add-ons - <storesynk-addons> is expanded server-side in its zero-interaction state with the preselected rows already ticked (that IS the client’s initial selection): the server runs the same resolveLiveAddons pipeline + overlap rule as the client and clones the template once for the winning offer, stamped with ss-addons-key; show-addons-title / show-addons-subtitle filled; one row card per resolved add-on row filled through the same member-card pipeline as bundles (displayers, option controls restricted to the row’s allowed variants, availability, ss-variants-truncated), ticked cards carrying ss-selected; select-addon rendered exactly as the client would (nested checkbox checked, or aria-pressed / aria-checked, aria-disabled when ticking ON is blocked); and the show-addon-price / show-addon-original-price deltas formatted via the same money formatter. The host and each addon-list get ss-adopt, and the authored template is kept as a hidden first child. The discovery data rides the product payload’s __storesynkAddons (its own dedicated query - shop-level refs + collection ids can’t ride the metafields batch, the volume-discount arrangement), so the client re-runs the resolution on load (date windows may have moved), adopts in place with zero refetch, and re-renders from the kept template only when the winner drifted.

The adoption payload protocol (constants live in @storesynk/core: PRODUCT_PAYLOAD_ATTR, SHOP_PAYLOAD_ATTR, *_SELECTOR, serializeJsonPayload):

  • <script type="application/json" data-storesynk-product>{raw product}</script> as a direct child of <storesynk-product> → the runtime hydrates via provideProduct(raw, metafields?), skips the product fetch and the sf-p-* session cache (build-time data must not poison the runtime cache). It fires storesynk:product-viewed only when the element has a handle/product-id (a page’s main product) - adopted list/search cards stay silent, like provideProduct. The payload may carry a __storesynkMetafields key (a map merged into the product); when present the runtime skips the client metafields query too.
  • <script type="application/json" data-storesynk-shop>{ShopData}</script> as a direct child of <storesynk-store>moneyFormat/currency seed synchronously, so money displayers are formatted on the very first client update and the SHOP_QUERY is skipped.
  • <script type="application/json" data-storesynk-listing>{facets + pageInfo + locale + active/sortKey/reverse/pages}</script> as a direct child of <storesynk-collection> → the client engine adopts the server-rendered listing state (facets/count/grid/sort) with zero refetch when it matches the URL read; a mismatch (URL-restored filters/sort/page, or a locale mismatch) still seeds engine state from it, so the server render holds under [ss-loading] rather than collapsing empty. One-shot - the engine removes the script after reading it so a later popstate/filter fetch can’t re-adopt.
  • <script type="application/json" data-storesynk-localization>{LocalizationData}</script> embedded by a server-rendered locale picker → <storesynk-store> adopts the countries/languages data (and removes the script) instead of running its localization fetch.
  • The payload is the raw GraphQL shape (connection nodes) - it feeds straight into the same provideProduct() normalization. A malformed payload is caught and the runtime falls back to a normal fetch (no error surfaced).

ss-adopt (adoption marker). A server renderer / the <StoresynkList> wrapper sets ss-adopt on the list host (e.g. <storesynk-list ss-adopt>) so the client ProductListBase keeps the server-rendered children (cloning the first, payload-stripped, as its template for later source changes) instead of clearing them. The renderCollectionListing transform (via <StoresynkCollection>) sets it on both the <storesynk-collection> host (which triggers the listing-payload adoption path) and the <product-list> grid (which keeps the server cards). The bundle transform sets it on <storesynk-bundle> (which keeps its hidden authored template as the re-render source) and on each <bundle-member-list> (which recovers its template from the first server card and re-attaches each card’s member context provider). The mix-match transform sets it on <storesynk-mix-match> (hidden authored template kept as the re-render source) and on each <mix-match-section-list>, <mix-match-item-list>, and <mix-match-selection-list> - each recovers its template from the first server clone and re-attaches its per-clone context provider (section / item / pick). The product-addons transform sets it on <storesynk-addons> (hidden authored template kept as the re-render source) and on each <addon-list> (which keeps the server row cards and re-arms each in place - provider + provideProduct). It is an engine/SSR-managed marker, never a hand-authored state - the validator allows it and never flags it.

SEO (<StoresynkSEO> / productJsonLd). <StoresynkSEO handle> renders head-level tags: <title>, meta description, Open Graph, and a <script type="application/ld+json"> Product graph (Offer/AggregateOffer with price + availability). Override title / description / url, and append a siteName to the default title. Place it in the page <head>, not the body.

Localization (i18n). country / language on <storesynk-store> (or the integration config) drive Shopify @inContext: the client createStorefrontClient auto-fills them into every query that declares $country/$language (an explicit caller variable wins), and createStorefront passes the store’s attributes through - so the client and server localize identically (same market pricing/translations on the SSR HTML and after adoption).

Revalidation (revalidate). <storesynk-product revalidate> (and <StoresynkProduct revalidate>): after adopting a payload, the element does a background refetch and re-provides context only if the data changed, preserving the user’s current selection. Use it to keep static builds fresh without a rebuild; without it, adopted data is served as-is until the next build. It is a no-op on non-adopted (normal client-fetch) products. <storesynk-collection revalidate> (and <StoresynkCollection revalidate>) applies the same contract to an adopted server-rendered listing: it silently re-runs the same listing query in the background - no [ss-loading], no dim - and re-provides only when products/facets/pageInfo drifted (a user-initiated fetch in flight wins via the token guard). It’s the client-side freshness backstop for long-lived (cacheLife("max")) cached renders whose primary invalidation is the Shopify webhook; a no-op on a non-adopted collection.

Why adoption doesn’t flash: money displayers leave their existing DOM content untouched while shop.moneyFormat is still loading (they no longer write raw {"amount":…} JSON), and all displayers no-op entirely while product context is null - so server-rendered text/prices survive until the client has real data to (identically) overwrite.


Imported from the Storesynk skill v0.1.0 (references/component-reference.md). To change this page, change it there.