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/astrois the Astro integration (npm i @storesynk/astro@beta). It exposes the integrationstoresynk({ domain, token, country?, language?, appNamespace?, injectRuntime?, trackEvents? })(inastro.config.mjs;appNamespaceis the Storesynk app’sapp--<numeric-app-id>metafield namespace, forwarded asapp-namespaceon the rendered<storesynk-store>- required for the bundle widget) and.astrowrappers 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’sclassis 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/elementsruntime on every page (disable withinjectRuntime: false). Itsruntimesubpath exposes data helpers for dynamic routes:getProductHandles(max)(cursor-paginated) andgetCollectionProducts(...). There is no separate bundle script for the Astro package - it is a build-time integration, not a browser bundle.@storesynk/nextis 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 theinitscaffold writes and the missing-credentials error prints;STORESYNK_STORE_DOMAIN/STORESYNK_PUBLIC_TOKENare still accepted as a fallback); everything else is set programmatically via a rootstoresynk.config.tscallingconfigureStoresynk({ country?, language?, persistLocale?, customerClientId?, …, pixels?, cart? })(the one sharedStoresynkConfig,domain/tokenoptional - they fall back to env), imported for its side effect at the top ofapp/layout.tsx. Scaffold it withnpx @storesynk/next init. There is noappNamespaceoption and noSTORESYNK_APP_NAMESPACE/STORESYNK_COUNTRY/STORESYNK_LANGUAGEenv var - the app metafield namespace is hard-coded in the package for now (forwarded asapp-namespaceautomatically). 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 -revalidatestampsrevalidateon 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); plusproductMetadata(handle, { title?, description?, url?, siteName? })returning a NextMetadataforgenerateMetadata(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 unlessloadRuntime={false}). Itsruntimesubpath (@storesynk/next/runtime) exposesgetProduct,getShop,getCollectionProducts,getProductMetafields,getProductHandles(max),getCollectionListing(handle, first?, query?, locale?, opts?)(one page + facets for the server-driven listing;handlenull/''= catalog mode), andgetLocalization(locale?, opts?)(the shop’s Markets countries/languages). The package also re-exportsparseListingParams(decode a route’ssearchParamsinto the listing’s URL state - filters/sort/page - to feed<StoresynkCollection state>) and the typesI18nState,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 HttpOnlyshopify_cartIdcookie (readable/writable server-side, so RSC/route handlers and the client stay on one cart) instead of the default clientlocalStorage['_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>stampssync-urlon the host so the chosen variant mirrors into the URL client-side (seestoresynk-productabove). 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 viadangerouslySetInnerHTML, so React never reconciles the subtree the engine mutates. Storesynk tags inside a re-rendering client component must be wrapped in<StoresynkStatic>.classNamepass-through: every wrapper acceptsclassName?(mirroring Astro’sclass) and serializes it asclasson its rendered host tag - grid/layout classes go there; classes on tags inside the slot template pass through untouched as well.@storesynk/reactis 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/serverhelpers (renderStore,renderProduct,renderList,renderCollection,renderLocalePicker,renderProductJsonLd) run in the framework’s data layer (loader /createServerFn) and return a serializablePrepared*object; the synchronous components in@storesynk/react(<StoresynkStore>,<StoresynkProduct>,<StoresynkList>,<StoresynkCollection>,<StoresynkLocalePicker>,<StoresynkProductJsonLd>,<StoresynkStatic>,<StoresynkRuntime>) take it via apreparedprop and inject it with onedangerouslySetInnerHTML. Both renders paint the same prepared bytes, so hydration can’t mismatch and adoption is refetch-free. The/serversubpath also exposesproductSeo(a head-API-neutral SEO object you map to your router’s head - unlike Next’sproductMetadata),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 withfresh: true),parseListingParams, and the raw fetch helpers (getProduct,getShop,getProductHandles,getCollectionProducts,getProductMetafields,getCollectionListing,getLocalization). Config is the samestoresynk.config.ts+configureStoresynk({...})+npx @storesynk/react initas Next (credentials fromSHOPIFY_STORE_DOMAIN/SHOPIFY_STOREFRONT_ACCESS_TOKENenv,STORESYNK_*overrides; passdomain/tokentoconfigureStoresynkon edge runtimes withoutprocess.env, e.g. Cloudflare Workers). It uses the identical payload/ss-adopt/revalidate/ i18n/coverage protocol below; templates handed to therender*helpers are the same static host elements as Next’s wrapper children. Not included vs@storesynk/next: the server-owned cart (Next’s HttpOnlyshopify_cartIdcookie via server actions has no framework-neutral equivalent - the default client cart works everywhere). See build-patterns §4g.@storesynk/core/serveris 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.tsfills 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 requestedstate(default or a filtered/sorted/paged view) and embeds thedata-storesynk-listingpayload;renderLocalePickers(html, { localization, country?, language? })fills the<change-country>/<change-language>selects and embeds thedata-storesynk-localizationpayload;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, andshow-mediafor MediaImage and now Video / ExternalVideo / Model3d;show-thumbnailtemplate expansion (indexed clones atwidth=200, withss-active/aria-currenton the active one). - Options -
change-optionin all three modes (swatch /<select>/ radio) withss-active,ss-unavailable,role="radiogroup", rovingtabindex, andaria-checked. The client_populatereuses these values in place, so it adopts with no repaint. Achange-optionwhose lookup misses - aname/groupa product doesn’t have, including every group on an optionless (single-variant) product - is stampeddisplay:nonein the SSR markup, exactly as the client hides it, so positionalgroup="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-optiongets[ss-active]on the initial mode (therecurringoption by default;singleunderdefault-option="single"), and - when subscribingchange-selling-planis fully rendered: oneselling-plan-optionclone per real plan with itsvalue(real plan id),[ss-active]on the preselected first plan,show-selling-plan-name(honoring thelabelsattribute), andshow-selling-plan-priceformatted via the same money formatter (shopmoneyFormat, or theIntlpath for foreign-presentment adjustments) - byte- identical to the clientChangeSellingPlanrender, so it adopts with no repaint. The a11y attributes are part of that parity: eachpurchase-option/selling-plan-optiongets the same Clickable stamping the client applies (role="button"+tabindex="0"on a bare pill, or nothing extra when the template nests a<button>) plusaria-pressedon 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-wrapperfor portable types: scalar,rich_text_field,dimension/volume/weight, and url/link. Still client-side:money,file_reference, and anylist.*metafield type (these need the viewer’s locale or the client list engine). - Lists -
storesynk-list/ the collection grid viarenderListItems+ thess-adoptmarker (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 viarenderCollectionListing: theproduct-listgrid (one filled card + its own product payload per product, host stampedss-adopt),change-filter/filter-listfacet values (counts, swatch styles, active states),active-filter-listchips,show-result-count, theclear-filters/load-more/change-priceempty/state stamps, and thechange-sortselection. Only the requested view is rendered - passstate(fromparseListingParams) to server-render a filtered/sorted/paged shared URL too. Adata-storesynk-listingpayload (facets + pageInfo + locale + active/sortKey/ reverse/pages) rides as a direct child ofstoresynk-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’srevalidateAdoptedflag, 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 viarenderLocalePickers: options (country names, currency labels whenshow-currency, language endonyms), the active selection, and the multi-market unhide (single-market shops stayhidden+ss-empty, like the client). Adata-storesynk-localizationpayload 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 filledbundle-offerclone per live bundle (allshow-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 withss-bundle-key, the host markedss-adopt, and the authored template kept as a hidden first child (hidden+ inlinedisplay: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, onevolume-tierrow per tier with allshow-tier-*displayers filled and the add button’s states), the clone stamped withss-volume-key, the host and itsvolume-tier-listmarkedss-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 filledmix-match-offerclone per live bundle, each stamped withss-mix-match-key; per-section clones withshow-section-*filled,ss-open/ss-meton themix-match-section, thetoggle-sectioncontrol’saria-expanded/aria-controlsand the panel’sid+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], themix-match-selection-listempty with its pick template kept hidden, andadd-mix-match-to-cart[ss-incomplete]+aria-disabled. The host, eachmix-match-section-list,mix-match-item-list, andmix-match-selection-listgetss-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 sameresolveLiveAddonspipeline + overlap rule as the client and clones the template once for the winning offer, stamped withss-addons-key;show-addons-title/show-addons-subtitlefilled; 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 carryingss-selected;select-addonrendered exactly as the client would (nested checkboxchecked, oraria-pressed/aria-checked,aria-disabledwhen ticking ON is blocked); and theshow-addon-price/show-addon-original-pricedeltas formatted via the same money formatter. The host and eachaddon-listgetss-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 viaprovideProduct(raw, metafields?), skips the product fetch and thesf-p-*session cache (build-time data must not poison the runtime cache). It firesstoresynk:product-viewedonly when the element has ahandle/product-id(a page’s main product) - adopted list/search cards stay silent, likeprovideProduct. The payload may carry a__storesynkMetafieldskey (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 theSHOP_QUERYis 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.