How the install works
Three blocks are required, and all three were proven necessary by installing on a real Hydrogen shop: the CSP allow-list (Hydrogen otherwise blocks the widget silently), the widget component (containers, bundle load, variant sync), and the /api/cart/add route (so the customised item lands in the same cart your storefront renders). The remaining blocks are polish.
- In your Kastomise dashboard, open Settings → your Shopify integration, and copy your Store ID. Make sure the Storefront access token is configured there (it is provisioned automatically when you install the Kastomise app; the widget needs it for the cart fallback).
- Add the Content Security Policy entries to app/entry.server.tsx. Without them Hydrogen silently blocks the widget — no error, the containers just stay empty.
- Copy the Widget component into app/components/ProductWidgetLoader.tsx, replace the store-ID placeholder, and render it on your product page route (usage shown at the bottom of the block).
- Create app/routes/api.cart.add.tsx from the Cart route block. Without it the customised item goes into a cart of the widget’s own and never appears in your header count, cart drawer or /cart.
- Check your product query requests selectedOptions { name value } on the variant — Hydrogen’s default product route already does.
- Recommended: add the cart-refresh hook to your layout so the header count updates the moment the widget adds an item, without a page navigation.
- Optional: add the 3D teaser container, styling, and cleanup blocks below to polish how the widget sits in your storefront.
Content Security PolicyRequired
Hydrogen ships a strict Content-Security-Policy that silently blocks the widget — no console error, the containers just stay empty (or the customisation popup renders with no frame/mask images and no fonts). In
app/entry.server.tsx, pass these directives to createContentSecurityPolicy (keep the shop config you already have). Each list starts with Hydrogen’s own defaults — only the entries marked ← Kastomise are new. scriptSrc loads the widget bundle; connectSrc fetches the stylesheet + your product’s config; imgSrc allows the widget’s inline data: SVG masks/backgrounds and blob: upload previews; fontSrc/styleSrc allow the widget’s fonts. Omitting them makes the CSP fall back to default-src, which blocks them.app/entry.server.tsx
// app/entry.server.tsx
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
shop: {
checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN,
storeDomain: context.env.PUBLIC_STORE_DOMAIN,
},
// Hydrogen defaults first, then the widget bundle host:
scriptSrc: [
"'self'",
'https://cdn.shopify.com',
'http://localhost:*', // local dev
'https://pub-0cc8fb982a1140e893064fffa1854538.r2.dev', // ← Kastomise widget bundle
],
// Hydrogen defaults first, then the widget's stylesheet + config API:
connectSrc: [
"'self'",
'https://cdn.shopify.com/',
'https://monorail-edge.shopifysvc.com',
'https://your-store.myshopify.com', // ← your store domain
'http://localhost:*', // local dev
'ws://localhost:*',
'ws://127.0.0.1:*',
'ws://*.tryhydrogen.dev:*',
'https://pub-0cc8fb982a1140e893064fffa1854538.r2.dev', // ← Kastomise widget stylesheet
'https://qdtqjlzzplfevelsecmm.supabase.co', // ← Kastomise config API
],
// Widget images: inline data: SVG masks/backgrounds + blob: upload previews.
imgSrc: [
"'self'",
'data:', // ← Kastomise zone masks/backgrounds (inline SVG)
'blob:', // ← Kastomise uploaded-image previews
'https://cdn.shopify.com',
'http://localhost:*', // local dev
'https://pub-0cc8fb982a1140e893064fffa1854538.r2.dev', // ← Kastomise image assets
'https://qdtqjlzzplfevelsecmm.supabase.co', // ← Kastomise stored/generated images
],
// Widget fonts: custom fonts inline as data:, default fonts from Google.
fontSrc: [
"'self'",
'data:', // ← Kastomise custom fonts (base64)
'https://cdn.shopify.com',
'https://fonts.gstatic.com', // ← Kastomise default font files
],
// Widget default fonts load via Google Fonts <link> stylesheets.
styleSrc: [
"'self'",
"'unsafe-inline'",
'https://cdn.shopify.com',
'http://localhost:*', // local dev
'https://fonts.googleapis.com', // ← Kastomise default font stylesheets
],
});Widget componentRequired
One complete, self-contained file — paste it into
app/components/ProductWidgetLoader.tsx and render it on your product page (usage at the bottom). It renders the Customise-button container, loads the bundle, calls initFromShop on mount, and keeps the widget in sync when the shopper switches variant (Hydrogen switches variants client-side, so without the sync effect the widget would keep the page-load variant forever). Replace YOUR_KASTOMISE_STORE_ID with the Store ID shown in your dashboard (Settings → your Shopify integration). The only thing to check on your side: the product query must request selectedOptions { name value } on the variant — Hydrogen’s default product route already does. Option mapping: “Colour”/“Color” sets the background and 3D theme, “Customisation”/“Customization” picks the layout, every other option becomes a metaData key (“Ribbon Width” → metaData.ribbonWidth).app/components/ProductWidgetLoader.tsx
import {useEffect} from 'react';
const WIDGET_SCRIPT_URL = 'https://pub-0cc8fb982a1140e893064fffa1854538.r2.dev/customisation-widget.js';
// Variant options → widget productConfig. "Colour"/"Color" sets the
// background + 3D theme, "Customisation"/"Customization" picks the layout,
// everything else becomes a camelCased metaData key.
const RESERVED = ['colour', 'color', 'customisation', 'customization'];
function toCamelCase(name: string): string {
return name
.toLowerCase()
.split(' ')
.map((w, i) => (i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)))
.join('');
}
function buildProductConfig(selectedVariant: {
selectedOptions?: Array<{name: string; value: string}>;
}) {
const config = {backgroundColor: 'Black', customisationType: '', metaData: {} as Record<string, string>};
for (const option of selectedVariant?.selectedOptions || []) {
const key = toCamelCase(option.name);
if (key === 'colour' || key === 'color') config.backgroundColor = option.value;
else if (key === 'customisation' || key === 'customization') config.customisationType = option.value;
else if (!RESERVED.includes(key)) config.metaData[key] = option.value;
}
return config;
}
export function ProductWidgetLoader({
product,
selectedVariant,
}: {
product: {id: string};
selectedVariant: {id: string; selectedOptions?: Array<{name: string; value: string}>};
}) {
// Mount once: load the bundle (first visit only) and initialise the widget.
useEffect(() => {
const w = window as any;
const init = () =>
w.CustomisationWidget?.initFromShop({
storeId: 'YOUR_KASTOMISE_STORE_ID',
ecommerceProductId: product.id, // ← Storefront API product GID
ecommerceVariantId: selectedVariant.id, // ← selected variant GID
platformType: 'headless_shopify',
productConfig: buildProductConfig(selectedVariant),
containerId: 'customisation-widget-container',
teaserContainerId: 'threed-teaser-container',
});
if (w.CustomisationWidget) init();
else {
const script = document.createElement('script');
script.src = WIDGET_SCRIPT_URL;
script.async = true;
script.onload = init;
document.body.appendChild(script);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Variant sync — required on Hydrogen. Variant switching happens
// client-side (no page reload), so on every change we hand the widget the
// new variant id and a freshly mapped config through the container's
// data-attributes. The widget watches those and updates itself.
useEffect(() => {
const container = document.getElementById('customisation-widget-container');
if (!container) return;
container.dataset.ecommerceVariantId = selectedVariant.id;
container.dataset.productConfig = JSON.stringify(buildProductConfig(selectedVariant));
}, [selectedVariant]);
return <div id="customisation-widget-container" />;
}
// Use it on your product page route (e.g. app/routes/($locale).products.$handle.tsx):
// const {product} = useLoaderData<typeof loader>();
// // selectedVariant: Hydrogen's useOptimisticVariant(...) if you have it,
// // or product.selectedOrFirstAvailableVariant
// <ProductWidgetLoader product={product} selectedVariant={selectedVariant} />
//
// Placement matters: render it in your buy row, where the Customise button
// stands in for the quantity + add-to-cart (see "swap your buy row" below) —
// not at the end of the page. The button only appears when the selected
// variant's Customisation option is not "None".Cart routeRequired
Create
app/routes/api.cart.add.tsx with this file. The widget adds the finished design to the cart itself and posts it here first, so that Hydrogen owns the cart through its session cookie — the customised line then appears in your header count, cart drawer and /cart like any other item. Skip this route and nothing looks broken: the widget quietly falls back to its own browser-side Storefront API cart, so the shopper gets a working checkout link but the item never shows in YOUR cart. The one rule if you edit this file: every path must return JSON. An HTML error page is exactly what the widget reads as “route not deployed”, which puts you back on the silent fallback. No ($locale) prefix — the widget posts to the absolute path /api/cart/add. Running multiple markets? Pass your shopper’s market explicitly — cart.addLines(lines, {country, language}), read off the Referer — or a cart created here is priced in your default currency.app/routes/api.cart.add.tsx
// app/routes/api.cart.add.tsx
import type {Route} from './+types/api.cart.add';
interface WidgetCartLine {
merchandiseId: string;
quantity: number;
attributes?: Array<{key: string; value: string}>;
}
// Always answer JSON — that Content-Type is how the widget decides this
// route exists. An explicit Response (not React Router's data()) guarantees it.
function jsonResponse(
payload: Record<string, unknown>,
init: {status?: number; headers?: Headers} = {},
) {
const headers = init.headers ?? new Headers();
headers.set('Content-Type', 'application/json');
return new Response(JSON.stringify(payload), {
status: init.status ?? 200,
headers,
});
}
export async function action({request, context}: Route.ActionArgs) {
if (request.method !== 'POST') {
return jsonResponse({success: false, error: 'Method not allowed'}, {status: 405});
}
const body = (await request.json().catch(() => null)) as {
lines?: WidgetCartLine[];
} | null;
const lines = body?.lines;
if (!Array.isArray(lines) || lines.length === 0) {
return jsonResponse(
{success: false, error: 'Expected { lines: [{ merchandiseId, quantity }] }'},
{status: 400},
);
}
const {cart} = context;
// No cart cookie yet means addLines CREATES the cart — read this before the
// mutation, because afterwards there is always an id.
const isNewCart = !cart.getCartId();
try {
const result = await cart.addLines(lines);
// Storefront user errors (sold out, bad variant id, …) arrive in the
// payload rather than as a thrown error.
const userError = result.errors?.[0]?.message;
if (userError || !result.cart) {
return jsonResponse(
{success: false, error: userError ?? 'Cart update failed'},
{status: 422},
);
}
// Persist the cart id on the session cookie: this is the line that makes
// the widget's item land in the SAME cart your storefront renders.
const headers = cart.setCartId(result.cart.id);
return jsonResponse(
{
success: true,
cartId: result.cart.id,
checkoutUrl: result.cart.checkoutUrl,
totalQuantity: result.cart.totalQuantity,
isNewCart,
},
{headers},
);
} catch (error) {
// Never let this escape as an HTML error page — see the note above.
console.error('[api.cart.add] Cart mutation failed:', error);
return jsonResponse(
{success: false, error: 'Cart is temporarily unavailable'},
{status: 500},
);
}
}
// A GET here would otherwise render an HTML error page. Keep it JSON.
export function loader() {
return jsonResponse({success: false, error: 'Method not allowed'}, {status: 405});
}Refresh your cart UI after an addOptional
Recommended alongside the cart route. The widget posts straight to
/api/cart/add, outside React Router, so none of your loaders re-run — your header count and cart drawer keep showing the pre-customisation cart until the next navigation. The widget dispatches a cart:updated event on window after a successful add; revalidating on it re-reads the cart from your root loader. Drop this hook into whatever component wraps every page (e.g. PageLayout) and call it once.Layout component
import {useRevalidator} from 'react-router';
import {useEffect} from 'react';
function useWidgetCartSync() {
const {revalidate} = useRevalidator();
useEffect(() => {
const onCartUpdated = () => {
void revalidate();
};
window.addEventListener('cart:updated', onCartUpdated);
return () => window.removeEventListener('cart:updated', onCartUpdated);
}, [revalidate]);
}
// Call it once in your layout component:
// export function PageLayout({...}) {
// useWidgetCartSync();
// return (...);
// }3D teaser containerOptional
Mount point for the spinning 3D product preview — separate from the Customise button so each can live in its own section (e.g. the button next to add-to-cart, the teaser as a full-width band further down the page). Render it anywhere on the same product route; the Widget component above already targets it by id on mount, so no extra wiring — but it must be in the initial render of the page (not behind
Suspense/deferred data), or the widget won’t find it. Renders nothing when your plan doesn’t include the 3D teaser, so it’s safe to keep in place.Product page route
// Add to app/components/ProductWidgetLoader.tsx (or its own file):
export function ProductTeaserContainer({className}: {className?: string}) {
return <div id="threed-teaser-container" className={className} />;
}
// Render it on the same product page route as ProductWidgetLoader, wherever
// the teaser should sit — e.g. a full-width section between your product
// details and the related-products rail:
// <ProductTeaserContainer className="w-full" />StylingOptional
Nice to have — sizing and stacking defaults. The widget works without this; it only tunes how the button and 3D teaser sit in your layout. Add to your global stylesheet and adjust to taste — e.g.
width: 600px; max-width: 100% if the teaser sits inline in a column instead of a full-width section.app/styles/app.css
/* Keep the customisation popup above sticky headers and site overlays. */
#customisation-widget-portal-host { position: relative; z-index: 2147483647; }
/* Teaser fills its section; size it down here if it sits inline instead. */
#threed-teaser-container { width: 100%; }Cleanup on unmountOptional
Nice to have — tears the widget down when the shopper navigates away from the product page. Return this cleanup from the first
useEffect in ProductWidgetLoader.ProductWidgetLoader
// Return from the init effect, after the if/else:
return () => (window as any).CustomisationWidget?.destroy();React to widget state (swap your buy row for Customise)Optional
Optional — show the widget’s Customise button in place of your buy row (quantity + add-to-cart) when the selected variant is customisable. The widget mirrors its state onto the container (
data-kastomise-state, data-kastomise-customisable) and dispatches a bubbling kastomise:ready event on every change, so your component can react without touching widget internals.Buy row component
const [customisable, setCustomisable] = useState(false);
useEffect(() => {
const apply = () => {
const el = document.getElementById('customisation-widget-container');
setCustomisable(
el?.dataset.kastomiseState === 'ready' &&
el.dataset.kastomiseCustomisable === 'true',
);
};
apply(); // widget may have settled before this component mounted
document.addEventListener('kastomise:ready', apply);
return () => document.removeEventListener('kastomise:ready', apply);
}, []);
// In your buy row — hide BOTH the quantity picker and add-to-cart when
// customisable, so the Customise button stands in for the whole row:
// {!customisable && <YourQuantityPicker />}
// {!customisable && <YourAddToCartButton />}
// (the widget renders its own Customise button in its container)/api/cart/add route is missing or returned HTML. Still stuck? Email hello@kastomise.com.