@storesynk/next server-renders real Shopify data into the same Storesynk tags you write on a plain HTML page. The client runtime adopts that output without refetching or flashing.
Requires Next 15+ / React 19+ and the App Router; Next 16 with Turbopack is supported. On the Pages Router or any classic-SSR stack, use @storesynk/react.
Install with the @beta tag (required while pre-1.0), then scaffold the config:
npm install @storesynk/next@betanpx @storesynk/next initCredentials are server-side env vars:
SHOPIFY_STORE_DOMAIN="your-store.myshopify.com"SHOPIFY_STOREFRONT_ACCESS_TOKEN="your-public-storefront-token" # public token only - never an Admin token# (STORESYNK_STORE_DOMAIN / STORESYNK_PUBLIC_TOKEN are accepted as explicit overrides)Everything else goes in the storesynk.config.ts that init created:
import { configureStoresynk } from '@storesynk/next';
configureStoresynk({ // domain/token are inherited from env - this file is just behavior: country: 'DE', cart: { openOnAdd: true, closeOnOutside: true }, pixels: { enabled: true, server: ['meta', 'ga'] },});Import it for its side effect at the top of app/layout.tsx, above other imports:
import '../storesynk.config';// …the rest of your layoutPassing domain/token to configureStoresynk overrides env. A missing credential throws at build time.
A product page with the RSC wrappers
Section titled “A product page with the RSC wrappers”The wrappers are async Server Components; <StoresynkStore> goes in the root layout:
import { StoresynkProduct, StoresynkProductJsonLd, productMetadata } from '@storesynk/next';import { getProductHandles } from '@storesynk/next/runtime';import type { Metadata } from 'next';
export const generateStaticParams = async () => (await getProductHandles()).map((handle) => ({ handle }));
export const generateMetadata = async ({ params,}: { params: Promise<{ handle: string }>;}): Promise<Metadata> => { const { handle } = await params; return productMetadata(decodeURIComponent(handle), { siteName: 'Acme' });};
const Page = async ({ params }: { params: Promise<{ handle: string }> }) => { const { handle } = await params; const decoded = decodeURIComponent(handle); return ( <> <StoresynkProductJsonLd handle={decoded} /> <StoresynkProduct handle={decoded} revalidate> <h1><show-title></show-title></h1> <div className="price-row"> <show-price></show-price> <show-compare-price></show-compare-price> </div> <show-media main=""><img alt="" /></show-media> <show-description></show-description> <add-to-cart><button type="button">Add to cart</button></add-to-cart> </StoresynkProduct> </> );};export default Page;<StoresynkProduct> fetches once per handle per build, fills every displayer, and embeds a direct-child data-storesynk-product payload the browser hydrates from. See SSR & adoption.
Components and helpers
Section titled “Components and helpers”From @storesynk/next:
<StoresynkStore country? language? trackEvents? loadRuntime? serverCart? persistLocale? className?>:<storesynk-store>with credentials and a shop payload.loadRuntime={false}lets you place<StoresynkRuntime />(from@storesynk/next/client) yourself.<StoresynkProduct handle revalidate? selectedOptions? syncUrl? locale? fresh? className?>: the PDP wrapper.selectedOptions(usuallyawait searchParams) server-renders a shared variant URL; under Cache Components that leaves the static shell, so static pages passsyncUrlalone.<StoresynkList collection limit? className?>: a grid from a first-child card template.<StoresynkCollection handle? pageSize? state? revalidate? locale? fresh? className? …>: the filterable listing;statecomes fromparseListingParams(searchParams).<StoresynkLocalePicker locale? fresh?>: the Markets pickers.<StoresynkProductJsonLd handle url? locale?>: Product JSON-LD script.productMetadata(handle, { title?, description?, url?, siteName?, locale? }):MetadataforgenerateMetadata.<StoresynkStatic className?>: wrapper for markup where React re-renders.createStoresynkWebhookHandler(),getRequestLocale(),getRequestCustomer(),parseListingParams,requireGate(SSR enforcement of page gates).
From @storesynk/next/runtime: getProduct, getShop, getCollectionProducts, getCollectionListing, getProductMetafields, getLocalization, getAllProducts, getProductHandles.
How it coexists with React
Section titled “How it coexists with React”React must never reconcile DOM the engine mutates:
- Children of
<StoresynkProduct>/<StoresynkList>/<StoresynkStatic>are static host elements: plain tags, no client components, no React state or handlers. - Standalone tags as plain JSX are fine in Server Components. In a re-rendering client component, wrap them in
<StoresynkStatic>. - Markup the engine restructures, like the
<storesynk-cart>drawer, always goes through<StoresynkStatic>.
A portal to document.body leaves <storesynk-store>, so tags render empty.
Caching and invalidation ("use cache")
Section titled “Caching and invalidation ("use cache")”With Next 16’s cacheComponents: true, every data helper runs in its own "use cache" scope with these tags, so webhooks invalidate exactly the affected surfaces:
| Operation | Tags |
|---|---|
getProduct(handle) | products, product-<handle>, product-<numericId> |
getProductMetafields / volume / customer-pricing | products, metaobjects, product-<numericId> |
getCollectionProducts / getCollectionListing(handle) | products, collections, collection-<handle>, + product-<id> per listed product |
getAllProducts / getCollectionListing(null) | products, products-index, + product-<id> per product |
getShop / getLocalization / getAccountUrl | shop (+ localization) |
The bundled handler:
import { createStoresynkWebhookHandler } from '@storesynk/next';export const POST = createStoresynkWebhookHandler();Register the products/*, collections/*, metaobjects/*, and shop/update topics at that route, with SHOPIFY_WEBHOOK_SECRET set.
Entries use the cacheLife profile from storesynk.config.ts (default 'max', webhook-driven only). Without webhooks, set cacheLife: 'days' or another TTL, or cached content stays frozen until the next redeploy. Your own "use cache" scopes are safe. fresh bypasses the cache for preview reads.
Server-owned cart
Section titled “Server-owned cart”<StoresynkStore serverCart> moves the cart id from localStorage into an HttpOnly shopify_cartId cookie and runs every mutation as a server action, so your server code can read the same cart. Mutations invalidate a cart cache tag. For custom setups: installServerCartTransport() and <StoresynkServerCart />, registered at module scope of a client module.
Per-request market from a cookie
Section titled “Per-request market from a cookie”With persistLocale="cookie", the Markets pickers write a storesynk_locale=<COUNTRY>/<LANGUAGE> cookie. A dynamic route opts in explicitly, because cookies() makes a route dynamic:
export const dynamic = 'force-dynamic';const locale = await getRequestLocale();return <StoresynkProduct handle={handle} locale={locale} fresh>…</StoresynkProduct>;productMetadata, <StoresynkProductJsonLd>, <StoresynkCollection>, and <StoresynkLocalePicker> accept the same locale.
Gotchas
Section titled “Gotchas”- Per-request freshness is explicit. A dynamic route must pass
freshon every wrapper andproductMetadata, or it serves the first request’s snapshot for the process lifetime. - Storesynk’s
revalidateis not ISR’srevalidate. The prop makes the client background-refetch;export const revalidate = Nis a different mechanism. - Dev-only hydration noise on the second request of a cached route is expected, as is a React DevTools assertion (
installHook.js). - Prerender-guard warnings under Cache Components: a route that awaits
searchParamsorgetRequestLocale()top-level needsexport const instant = false, and every fetching region needs a"use cache"scope, even a<StoresynkLocalePicker>in a layout. - Blank tags in a client component or portal: Troubleshooting: SSR.