@storesynk/next
v0.1.0-beta.16
Published
Next.js (App Router) bindings for Storesynk — server-rendered Shopify data inside Storesynk tags, adopted flash-free by the client runtime.
Maintainers
Readme
@storesynk/next
Next.js (App Router) bindings for Storesynk: your product pages are server-rendered with real Shopify data — titles, prices, images, and descriptions are in the built HTML (SEO- and no-JS-complete) — and the client runtime adopts that output without re-fetching or flashing.
Works with static generation (generateStaticParams) and per-request SSR. The markup you
write inside the wrappers is exactly the same Storesynk tags you'd use on a plain HTML page.
Requires Next 15+ / React 19+ and the App Router. Next 16 is supported, including its default Turbopack builds — the published package ships compiled output, which Turbopack resolves without configuration.
Building with webpack (Next 15's default, or next build --webpack)? Add one externals
rule. @shopify/hydrogen, which @storesynk/core builds on, lazily import()s Shopify's
standard-events module straight from cdn.shopify.com; Turbopack treats an https: specifier
as an external, but webpack rejects the scheme unless told to leave it as a native import:
// next.config.ts
webpack: (config) => {
config.externals = [
({ request }, callback) =>
request?.startsWith('https://') ? callback(null, `import ${request}`) : callback(),
...(Array.isArray(config.externals) ? config.externals : [config.externals].filter(Boolean)),
];
return config;
},Install
npm install @storesynk/nextSetup
Scaffold the config file with the init command (explicit — not a postinstall, so it works under pnpm/npm/yarn and CI):
npx @storesynk/next init # writes an annotated storesynk.config.ts and prints the next stepsThen set the two store credentials as environment variables (they're read server-side; the token is the browser-safe public one and ends up in the HTML anyway):
# .env.local
SHOPIFY_STORE_DOMAIN="your-shop.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)Only these two live in env. Everything else — locale (country/language), customer
accounts, and the pixels / cart behavior groups — goes in the storesynk.config.ts
the init command created (the Next counterpart to the storesynk({...}) block in an Astro
project's astro.config):
// storesynk.config.ts
import { configureStoresynk } from '@storesynk/next';
configureStoresynk({
// domain/token are inherited from env — this file is just behavior:
country: 'DE',
// Optional: print every price in this Shopify-style template instead of the
// shop's admin setting (which checkout and emails use).
moneyFormat: '{{amount_with_comma_separator}} €',
cart: { openOnAdd: true, closeOnOutside: true },
pixels: { enabled: true, server: ['meta', 'ga'] },
});Import it once, for its side effect, at the very top of app/layout.tsx — above your
other imports — so it runs at module scope before any Storesynk component renders:
// app/layout.tsx
import '../storesynk.config';
// …the rest of your layout(Put credentials in the config call instead of env by passing domain/token explicitly —
they override env when present. The scaffolded file lists every option, commented, with its
default.)
<StoresynkStore> automatically loads the @storesynk/elements runtime in the browser
(client-only, post-hydration). Pass loadRuntime={false} and place <StoresynkRuntime />
(from @storesynk/next/client) yourself if you need control over where it mounts.
Usage
// app/products/[handle]/page.tsx
import { StoresynkStore, StoresynkProduct, StoresynkProductJsonLd, productMetadata } from '@storesynk/next';
import { getProductHandles } from '@storesynk/next/runtime';
import type { Metadata } from 'next';
export async function generateStaticParams() {
return (await getProductHandles()).map((handle) => ({ handle }));
}
export async function generateMetadata({ params }: { params: Promise<{ handle: string }> }): Promise<Metadata> {
const { handle } = await params;
return productMetadata(decodeURIComponent(handle), { siteName: 'Acme' });
}
export default async function Page({ params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params;
const decoded = decodeURIComponent(handle);
return (
<StoresynkStore>
<StoresynkProductJsonLd handle={decoded} />
<StoresynkProduct handle={decoded}>
<show-title>Product Title</show-title>
<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>
</StoresynkStore>
);
}At build/SSR time <StoresynkProduct> fetches the product (one request per handle per build,
shared across pages), fills every displayer tag, image, and sale badge with the exact output
the client runtime would produce, and embeds the raw product as a JSON payload:
<storesynk-product handle="classic-snowboard">
<script type="application/json" data-storesynk-product>{…}</script>
<show-title>The Classic Snowboard</show-title>
…In the browser, the runtime detects the payload and hydrates its state from it — no
product/shop queries, no repaint — while everything interactive (options, quantity, cart,
checkout) works exactly as on a static Storesynk page. <StoresynkStore> does the same for
the shop (money formatting), so prices are formatted on the very first client update. Soft
navigations re-insert the payloads with the new page, so adoption stays refetch-free there
too.
How this coexists with React
Storesynk's engine mutates the DOM it owns (displayers rewrite text, lists clone templates) — DOM that React must therefore never reconcile. The wrappers make that safe:
- Children of
<StoresynkProduct>/<StoresynkList>/<StoresynkStatic>must be static host elements — plain tags, no client components, no React state or event handlers. They are rendered once to an HTML string on the server and injected viadangerouslySetInnerHTML, so React treats the whole subtree as an opaque string. Interactivity comes from the Storesynk elements themselves (<add-to-cart>,<change-option>, …), not from React. - Standalone Storesynk tags as plain JSX (a nav
<open-cart>with<show-cart-count>, say) are fine in Server Components, which render once and never re-render on the client. Don't put them inside a re-rendering client component — if you must, wrap them in<StoresynkStatic>. - Markup whose structure the engine rewrites (the
<storesynk-cart>drawer: its template child is removed and cloned per line item) should always go through<StoresynkStatic>.
The elements runtime loads after hydration, so engine updates can never cause hydration mismatches.
Components & helpers
<StoresynkStore country? language? trackEvents? loadRuntime? serverCart?>— renders<storesynk-store>with the configured credentials + an embedded shop payload. Children are ordinary React content; put it in your root layout so the cart survives navigation.serverCartswitches the cart to server-owned: the cart id lives in an HttpOnlyshopify_cartIdcookie (the headless convention, so server code written against templates like vercel-shop shares the same cart) and every mutation runs as a server action with the configured credentials — the browser never learns the id, and your own server code (agents, RSC cart rendering) can read the same cart. The standardwindow.Shopify.actions/shopify:cart:*protocol is unchanged; only the I/O moves. Mutations invalidate acartcache tag (updateTagon Next 16+,revalidateTagfallback) — tag your own cart reads with it to get read-your-writes. Default: the client-side Storefront-API cart (id in localStorage), same as every other surface.<StoresynkProduct handle revalidate? selectedOptions?>— renders<storesynk-product>with the filled children + an embedded product payload (including any metafields the markup displays).revalidatemakes the client refetch in the background and repaint only if the live data differs.selectedOptions(usuallyawait searchParams) makes a shared variant URL server-render that variant — keys match option names case-insensitively, non-option params are ignored, garbage falls back to the default variant; the reservedvariantparam carries a numeric variant id (?variant=41820371452004, the Liquid/Hydrogen shape) and wins over option params — and stampssync-urlon the host, so the client adopts the same selection from the URL and mirrors later option clicks back into it (?color=red,history.replaceState; putsync-url="variant"on a hand-authored host to write?variant=<id>links instead). Under Cache Components, awaitingsearchParamsmoves the region out of the static shell — for static/PPR pages passsyncUrlalone instead: the shell keeps the default variant and the client corrects a shared variant URL at adoption. If the product isn't found, the children render untouched (and the build log warns).<StoresynkList collection limit?>— server-renders a collection grid: the first element child is the item template (the same contract as<storesynk-list>), one filled clone per product with crawlable/products/<handle>links and per-item payloads; the client engine adopts the items with zero refetch.<StoresynkProductJsonLd handle url?>— schema.org Product JSON-LD (price, availability, images) as a body-level script.productMetadata(handle, { title?, description?, url?, siteName? })— aMetadataobject forgenerateMetadata:<title>, meta description, Open Graph tags. (og:type/og:price:* render viametadata.otherasname=meta tags — a Next Metadata limitation; the JSON-LD carries the structured data rich results consume.)<StoresynkStatic>— stringify-and-inject wrapper for Storesynk markup that must live where React re-renders (see above).@storesynk/next/runtime— the data helpers (getProduct,getShop,getCollectionProducts,getProductMetafields,getProductHandles) if you need the same cached fetches in your own server code.installServerCartTransport()/<StoresynkServerCart />— the pieces behindserverCart, exported forloadRuntime={false}-style custom setups. Registration must happen before the elements runtime boots (module scope of a client module is the safe spot). Built on core'sCartTransportseam (registerCartTransportFactory), which you can also implement yourself to back the standard actions with any cart system — e.g. an existing app's own server-actions cart.
Localization
configureStoresynk({ country, language }) applies Shopify's @inContext to every
server-side fetch (and the client runtime uses the same context via the store attributes), so
SSR'd prices match the configured market.
Caching & invalidation (Next 16 cacheComponents: true)
Caching is package-owned: every data helper behind the wrappers runs in its own
"use cache" scope with data-derived cache tags — you write no cache scopes for
correctness, and Shopify webhooks invalidate exactly the surfaces that rendered the
changed entity. The tag taxonomy:
| 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) |
| getRecommendationRoots (related / upsell picks) | products, shop, product-<numericId> |
List operations tag every returned product's numeric id, so a product update refreshes each grid it appears in without purging the catalog. Wire the invalidation side with the bundled handler:
// app/api/webhooks/shopify/route.ts
import { createStoresynkWebhookHandler } from '@storesynk/next';
export const POST = createStoresynkWebhookHandler();Register the products/*, collections/*, metaobjects/*, and shop/update topics in
Shopify pointing at that route, with SHOPIFY_WEBHOOK_SECRET set. Entries live under the
cacheLife profile from storesynk.config.ts. The default 'max' is webhook-driven only:
without the webhook route a cached read never expires, so products and offers edited in the
admin after the first render never show up. Until the webhook route is wired, set
cacheLife: 'minutes' or longer (npx @storesynk/next init scaffolds 'minutes', which
picks up admin edits within about a minute). Do not go under five minutes: Next treats
caches that expire sooner (such as 'seconds') as runtime data, and next build then fails
on prerendered routes with "encountered uncached or runtime data during prerendering".
revalidate on <StoresynkProduct> remains a browser-side self-heal on top.
Related / upsell freshness has two lanes. Product picks live on the product
($app:recommendations), and Shopify fires products/update for a product-level metafield
write, so the webhook route expires product-<handle> and the picks refresh with the product.
The shop-level settings (related_settings / upsell_settings, including the upsell discount
label, cart seeds and free-shipping goals) are shop metafields, for which Shopify fires no
webhook at all; the package therefore reads them on their own 'minutes' lifetime
(RECOMMENDATION_SETTINGS_CACHE_LIFE) and merges them over the long-lived shop entry, so a
merchant's settings change shows within about a minute on every store, webhooks or not.
A <StoresynkProduct> slot that authors an offer widget (<storesynk-bundle>,
<storesynk-mix-match>, <storesynk-addons>, <storesynk-volume-discount>,
<storesynk-customer-pricing>) renders at request time: the widget fills compare offer
schedules against the clock, which Next rejects during prerendering, so the wrapper awaits
connection() before the fill. A fixed-handle page with a widget is therefore never
prerendered (its data reads stay cached as above); a slot without a widget is unaffected.
A product that is merely REFERENCED by a BOGO offer (every buy-side product once an offer is
live) never forces request-time rendering: the ss-offer="bogo" badge stamp on list cards,
collection cards and the product host runs without a clock during a prerender. An active
offer with no schedule is stamped in the built HTML (byte-identical to the client); a
scheduled offer is stamped by the client after adoption, so <StoresynkList> and
<StoresynkCollection> always prerender, whatever offers the store runs.
Wrapping a Storesynk region in your own "use cache" scope (fragment-output caching) is
optional: the package's tags propagate to the enclosing scope, so webhook invalidation still
reaches it. Leave offer widgets out of such a scope, since connection() cannot run inside
"use cache". The fresh prop bypasses the persistent cache entirely (deduped per
request) for draft/preview reads.
One sharp edge:
- Dev-only hydration noise: dev restores cached trees with the
dangerouslySetInnerHTMLprop in serialized shape and warns on the second request of a cached route. The hosts shipsuppressHydrationWarning(they are React-opaque by design); production is unaffected. A related React DevTools extension assertion ("The children should not have changed if we pass in the same set",installHook.jsstack) can also appear — extension-internal, harmless, not suppressible from app code.
Per-request market from a cookie (dynamic routes)
With persistLocale="cookie" on <StoresynkStore>, the client's Markets pickers persist
the buyer's choice as a storesynk_locale=<COUNTRY>/<LANGUAGE> cookie. A dynamic route
opts into rendering in that market by resolving it explicitly and passing it down —
getRequestLocale() never runs implicitly, because calling cookies() makes a route
dynamic and an invisible default would silently un-static every page:
// app/layout.tsx — safe everywhere: empty (null) on force-static routes
const locale = await getRequestLocale();
<StoresynkStore locale={locale} persistLocale="cookie">…</StoresynkStore>
// app/live/[handle]/page.tsx
export const dynamic = 'force-dynamic';
const locale = await getRequestLocale();
return <ProductPdp handle={handle} locale={locale} />; // → StoresynkProduct locale={…}productMetadata(handle, { locale }) and <StoresynkProductJsonLd locale={…}> accept the
same value. Prices, translations, and currency symbols render server-side in the buyer's
market, and the emitted store attributes match the client's effective locale — adoption
with zero refetch and no default-currency flash. Static routes keep the config defaults
(the client repaints there via revalidate semantics instead). The server only ever
reads the cookie; the client picker writes it, so first visits render the default
market.
Server-rendered coverage
Displayers, images/media (including video, external video, and 3D models), sale badges, option controls (swatch/select/radio with full ARIA state), thumbnails, product links, selling-plan initial state, scalar/rich-text metafields, and collection grids. Remaining client-only: money/file/list-type metafields (locale- or engine-dependent), predictive search, and the cart (per-user by design).
