@storesynk/astro
v0.1.0-beta.16
Published
Astro integration for Storesynk — server-rendered Shopify data inside Storesynk tags, adopted flash-free by the client runtime.
Maintainers
Readme
@storesynk/astro
Astro integration 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 in static builds (astro build) and SSR (any adapter). The markup you write inside the
wrappers is exactly the same Storesynk tags you'd use on a plain HTML page.
Supports Astro 5, 6, and 7. If you SSR with @astrojs/node, match the adapter major to
your Astro major: @astrojs/node@^9 for Astro 5, ^10 for Astro 6, ^11 for Astro 7
(Astro 6+ needs Node.js ≥ 22.12).
Install
npm install @storesynk/astroSetup
Scaffold an annotated options file with the init command (explicit — not a postinstall, so it works under pnpm/npm/yarn and CI):
npx @storesynk/astro init # writes storesynk.config.ts and prints the astro.config wiringIt creates a storesynk.config.ts that exports the integration options (every option listed,
commented, with its default). Spread it into the integration:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import storesynk from '@storesynk/astro';
import storesynkConfig from './storesynk.config';
export default defineConfig({
integrations: [storesynk(storesynkConfig)],
});Or configure inline if you prefer — the options are the same shape:
storesynk({
domain: 'your-shop.myshopify.com',
token: 'your-public-storefront-token', // public token only — never an Admin token
cart: { openOnAdd: true },
pixels: { enabled: true, server: ['meta', 'ga'] },
});The integration injects the @storesynk/elements runtime on every page (disable with
injectRuntime: false if you load it yourself) and carries the config to the server-side
components. The connection / locale / customer / pixels / cart options are the one shared
StoresynkConfig (same shape as @storesynk/next and the no-build data-storesynk-config
block); injectRuntime is Astro-specific. (The app metafield namespace is hard-coded in the
package for now — not an integration option, same as @storesynk/next.)
Usage
---
import { StoresynkStore, StoresynkProduct } from '@storesynk/astro/components';
---
<StoresynkStore>
<StoresynkProduct handle="classic-snowboard">
<show-title>Product Title</show-title>
<div class="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.
Components
<StoresynkStore country? language? trackEvents?>— renders<storesynk-store>with the integration credentials + an embedded shop payload.<StoresynkProduct handle revalidate? selectedOptions? syncUrl?>— renders<storesynk-product>with the filled slot + an embedded product payload (including any metafields the slot displays).revalidatemakes the client refetch in the background and repaint only if the live data differs. On SSR routes the variant selection ridesAstro.urlautomatically (selectedOptionsoverrides it): option params match option names case-insensitively, the reservedvariantparam carries a numeric variant id (?variant=41820371452004, the Liquid/Hydrogen shape) and wins over option params, garbage falls back to the default variant;sync-urlis stamped so the client adopts the same selection and mirrors option clicks back into the URL.syncUrlstamps it alone for prerendered pages. If the product isn't found, the slot renders untouched (and the build log warns).<StoresynkList collection limit?>— server-renders a collection grid: the slot's 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.<StoresynkSEO handle title? description? url? siteName?>— place inside<head>:<title>, meta description, Open Graph tags, and schema.org Product JSON-LD (price, availability, images) from the same cached fetches.
Dynamic routes
---
// src/pages/products/[handle].astro
import { getProductHandles } from '@storesynk/astro/runtime';
export async function getStaticPaths() {
return (await getProductHandles()).map((handle) => ({ params: { handle } }));
}
---Localization
country/language in the integration config apply Shopify's @inContext to every
server-side fetch (and the client runtime uses the same context), so SSR'd prices match the
configured market.
Previewing through a tunnel (ngrok, cloudflared, LAN)
astro dev/astro preview are Vite servers, and Vite rejects requests whose Host header
isn't localhost (DNS-rebinding protection): Blocked request. This host (…) is not allowed.
Allow your tunnel hostname in your own astro.config.mjs:
export default defineConfig({
vite: { preview: { allowedHosts: ['my-site.ngrok.io'] } }, // server.allowedHosts for dev
});Local-preview only — deployed static files or an SSR adapter never run Vite, so production is unaffected.
Static or SSR? Start static.
Both modes run the same Storesynk code and produce the same SEO-complete HTML — the only thing you're choosing is when pages render (once at build vs. per request) and therefore how fresh prices/stock are and what hosting you need. The cart is live in both modes, and crawlers see full HTML in both modes, so neither SEO nor checkout should drive this choice.
Default: static + revalidate + rebuild-on-webhook. Free CDN hosting, instant pages,
self-healing prices. This covers most stores:
| Your situation | Mode |
| --- | --- |
| Catalog fits a build (≲ a few thousand pages) and prices change occasionally | Static (+ revalidate on PDPs) |
| Prices/stock must be correct in the HTML itself at all times (not just after hydration) | SSR for those routes (prerender = false) |
| Catalog too large or too volatile to rebuild (tens of thousands of products, constant updates) | SSR |
| Per-request context: geo-based market pricing, personalization, cookies | SSR |
| Mixed: marketing pages + a few hot product pages | Hybrid — static site, prerender = false on the hot routes |
Signals you chose wrong: rebuilds taking too long or firing constantly → move the hot routes to SSR; an SSR server sitting idle serving unchanged pages → go back to static.
SSR caveat (current version): the runtime's fetch helpers are module-cached with no TTL — designed for builds. On a long-running SSR server that means per-process freshness, not per-request; pair SSR with
revalidateor restarts-on-deploy for now. Caches are keyed per market, so size is bounded by markets × handles.
Per-request market from a cookie (SSR)
With persistLocale: 'cookie' on the integration, the client's Markets pickers
(<change-country> / <change-language>) persist the buyer's choice as a
storesynk_locale=<COUNTRY>/<LANGUAGE> cookie — which the wrapper components read back
on SSR requests (prerender = false routes, any adapter) and render everything in that
market: @inContext prices, translations, correct currency symbols, and matching
country/language attributes on <storesynk-store>, so the client adopts the payloads
with zero refetch and no default-currency flash.
// astro.config.mjs
adapter: node({ mode: 'standalone' }),
integrations: [storesynk({ domain, token, persistLocale: 'cookie' })],---
// src/pages/live/[handle].astro — rendered per request
export const prerender = false;
---Precedence per field: component prop > cookie > integration config. Prerendered pages
ignore the cookie (a build has no request) and keep the config defaults — a visitor with a
persisted locale gets the client-side revalidate-style repaint there instead. The server
only ever reads the cookie; writing it is the client picker's job, so the first visit
renders the default market.
Freshness
- Static builds freeze product data at build time. Titles and descriptions are fine; prices/stock go stale between builds — rebuild on Shopify webhooks, or use an SSR adapter for per-request freshness. The cart is always live (client-side, per-user).
astro devfetches per dev-server process (module-cached).
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).
