Skip to content

Next.js

Server-render Shopify data in the App Router with @storesynk/next.

@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:

Terminal window
npm install @storesynk/next@beta
npx @storesynk/next init

Credentials are server-side env vars:

.env.local
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:

storesynk.config.ts
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:

app/layout.tsx
import '../storesynk.config';
// …the rest of your layout

Passing domain/token to configureStoresynk overrides env. A missing credential throws at build time.

The wrappers are async Server Components; <StoresynkStore> goes in the root layout:

app/products/[handle]/page.tsx
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.

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 (usually await searchParams) server-renders a shared variant URL; under Cache Components that leaves the static shell, so static pages pass syncUrl alone.
  • <StoresynkList collection limit? className?>: a grid from a first-child card template.
  • <StoresynkCollection handle? pageSize? state? revalidate? locale? fresh? className? …>: the filterable listing; state comes from parseListingParams(searchParams).
  • <StoresynkLocalePicker locale? fresh?>: the Markets pickers.
  • <StoresynkProductJsonLd handle url? locale?>: Product JSON-LD script.
  • productMetadata(handle, { title?, description?, url?, siteName?, locale? }): Metadata for generateMetadata.
  • <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.

React must never reconcile DOM the engine mutates:

  1. Children of <StoresynkProduct> / <StoresynkList> / <StoresynkStatic> are static host elements: plain tags, no client components, no React state or handlers.
  2. Standalone tags as plain JSX are fine in Server Components. In a re-rendering client component, wrap them in <StoresynkStatic>.
  3. 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.

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:

OperationTags
getProduct(handle)products, product-<handle>, product-<numericId>
getProductMetafields / volume / customer-pricingproducts, 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 / getAccountUrlshop (+ localization)

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 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.

<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.

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:

app/live/[handle]/page.tsx
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.

  • Per-request freshness is explicit. A dynamic route must pass fresh on every wrapper and productMetadata, or it serves the first request’s snapshot for the process lifetime.
  • Storesynk’s revalidate is not ISR’s revalidate. The prop makes the client background-refetch; export const revalidate = N is 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 searchParams or getRequestLocale() top-level needs export 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.