# Build my own lab supplies store on Crystallize, like Lab Universe

I want a storefront for my business that works like **Lab Universe** (https://lab-universe.superfast.shop), a demo store built on Crystallize: A laboratory supplier built around the buyer’s procurement: roles, approval per department, budgets per cost centre, contract prices per institution, standing orders and regulated chemicals with GHS data.

The content model and the wiring below are proven in that store: keep them for the features I want, and rename things to fit my trade. If a feature doesn't fit my business, leave its parts out rather than forcing it. Everything I see is mine to decide: brand, colours, typography, layout, copy and pages. Don't copy the demo's look; build my brand.

What the demo does: approval flows, budgets and cost centres, buyer roles, standing orders, subscriptions, customer price lists, volume tiers, ghs hazard data, large variant sets, typed specs and filters, multiple markets, multi-currency. It is B2B, in English, Norwegian, Swedish, Dutch; my markets and languages may differ.

## How to work with me

- You need to run commands, edit files and use MCP tools. If you can't (for example in a plain chat), tell me, and suggest I paste this prompt into Claude Code, Cursor or another coding agent instead.
- Ask your questions a few at a time (three to five), in the order of the steps below, and suggest a sensible default for each so I can just say yes. Ask each thing once, even where two lists overlap, and ask the ones that change the build first. Don't start a step before the one before it is done.
- The technical detail below is for you. Talk to me in plain words, and explain a choice only when I need to make it.
- Keep the folder empty until step 5, apart from `.env.local`.
- Never ask me to paste an access token secret into the chat, and tell me not to paste a filled-in command or its output either. Tell me which command to run or which file to put it in, and I'll do it.
- Before you create or change anything in Crystallize, show me the plan (shapes, pieces, topics, price variants, markets) and wait for my yes. Before an import, show me a sample of five items.
- Use the Crystallize skills (the `skills` MCP tool, or the use-crystallize plugin) for how-to: `content-model`, `information-architecture`, `taxonomy`, `data-creation`, `mass-operations`, `pricing`, `query`, `mutation`, `js-api-client`, `responsive-images`, `payments`, and when this store needs them `subscriptions`, `bookable-resources`, `vector-ranking` and `permissions`. Where this prompt and a skill disagree, the skill wins. Check the live schemas with the MCP instead of guessing field names.
- When something I asked for isn't possible in Crystallize the way I pictured it, say so and offer the closest thing that is.
- **The storefront never reads from the Core API on customer-facing pages.** Products, public prices, stock and content come from the Discovery API and the Catalogue API; confidential prices (negotiated B2B price lists) from the Catalogue API on the server.
- **Use the Shop API for everything about customers:** carts, checkout, customers, orders (including orders created directly, such as renewals), order meta, pipeline stages, payments and subscription contracts. Take the customer identifier from the server session, never from the browser. The Core API is for setup scripts, back-office tools, scheduled jobs and what the Shop API can't do, always on the server and never once per page view.

## Step 1: Crystallize tenant, tokens and MCP

Ask me:
1. Do I have a Crystallize account and a tenant for this store? If not, I sign up for free at https://app.crystallize.com/signup and create an empty tenant. Ask for its **tenant identifier**.
2. Do I have access tokens? If not, tell me: in the Crystallize App, go to **Settings → Access Tokens → Generate a new token**, name it, and keep the ID and the secret (the secret is shown once). A token acts with its user's role, so while we build, that user needs to be Tenant Admin (or have a role that can write shapes, pieces, topics, items and prices and run mass operations). Once the store is built, switch the MCP to a token from a read-only user.
3. Is the Crystallize MCP connected, with write access? Check that you have its write tools (`run-mass-operation`, `mutate-core`), not only the read tools (`tenant-overview`, `fetch-content-model`): with only the read tools, it was added without `?exposeWrite=true`. If it's missing, have me run this in my terminal with my own token, then restart you:
   ```bash
   npx add-mcp "https://mcp.crystallize.com/mcp?exposeWrite=true" \
     --header "X-Crystallize-Access-Token-Id: <token id>" \
     --header "X-Crystallize-Access-Token-Secret: <token secret>"
   ```
   If the MCP can't be connected in my tool, say so and fall back to scripts that call the Crystallize APIs with the tokens from `.env.local`.
   In Claude Code, also suggest the Crystallize skills: `/plugin marketplace add crystallizeapi/ai`, then `/plugin install use-crystallize@crystallize-ai`. The plugin brings its own read-only Crystallize MCP entry with placeholder credentials: keep using the one added above (and add `&exposeSkills=false` to its URL once the plugin's skills are installed).
4. Confirm the connection with `tenant-overview`, and tell me what's already in the tenant. If it isn't empty, ask whether to build next to what's there or in a fresh tenant.

The storefront needs the tenant identifier and its own credentials: an access token pair for the Shop API (server only), and a static auth token if Discovery and the Catalogue API are secured. Don't reuse the admin build token in production. Put them in `.env.local` (have me fill in the secrets) and make sure `.env*` is in `.gitignore`.

## Step 2: my business and my brand

Ask me:
- What I sell, who buys it, and what makes us different. My brand name and a one-line pitch.
- My markets, currencies and languages, and my VAT/tax setup.
- Where my product data lives today: a spreadsheet or CSV, an export from Shopify, WooCommerce or another platform, a PIM or ERP, my current website, or nothing yet. Where my product images are.
- My brand: logo (SVG if I have it), colours, typefaces, tone of voice, and two or three sites whose look I like. If I have no brand guide, propose a direction from my answers and show it before building pages.
- Where it should run: my own domain, and hosting (suggest Vercel for Next.js).

Then ask what is particular to this kind of store:

- Which product families do I sell, and which typed specs matter for each (volume range and accuracy for pipettes, capacity and readability for balances…)?
- Is there compatibility between products (a tip fits a pipette, a rotor fits a centrifuge)? Where does that data come from?
- How my customers are organised: institutions, departments or cost centres, people. Which roles order, approve and purchase?
- Approval rules: the amount limit per department, which categories or hazards need approval, who approves.
- Contract terms: a percentage per brand or SKU, fixed prices, the contract period, a restricted range per customer?
- List prices, volume tiers on consumables, currencies, VAT per market, languages.
- Standing orders for consumables, and at which intervals?
- Regulated data (GHS hazard labelling, safety data sheets, certifications), and who may buy hazardous items.
- Where orders go after approval (an ERP, EDI)?

## Step 3: the content model

Create this in my tenant with the MCP, renamed to fit my trade, after I have approved the plan. Keep the structure: it is what the storefront's features depend on.

Build in dependency order, since mass operations run in sequence with no rollback: pieces and shapes (empty first, then their components), topic maps (at most 30 topics per `topic/create`), price variants, markets and VAT types, the folder tree (two to four levels, five to twelve per level), and only then items. Use lowercase-hyphen identifiers. Decide now which fields are translated and which are discoverable. Restrict every item relation with `acceptedShapeIdentifiers`. Never add price or stock components to a shape: variants have them built in.

One product shape; a single component choice `specs` picks one typed family per product. Typed values only (numeric with units, boolean, selection), no properties tables. Relations for things, topics for properties. No content chunks on variants. Text is translated.

- **lab-product** (product):
  - tagline, summary, story (paragraphs), highlights.
  - `manufacturer` chunk: `brand` relation (required), product line.
  - `specs`: a component choice of one spec piece per family: pipette (channels, volume min and max in µL, adjustment, operation, increment, accuracy %, precision CV %), tip (max volume, filter, low retention, graduated, format, length, material), serological pipette, balance (capacity, readability, repeatability, calibration, pan, draft shield, legal for trade), centrifuge (max speed and RCF, places, tube formats, refrigerated), thermomixer, tube, cell culture, glove (material, thickness, length, powder-free, standards, AQL), glassware, chemical (CAS, formula, molar mass, grade, purity, form, density), accessory. Name the standards in component descriptions (ISO 8655, EN ISO 374, CLP/GHS, UNSPSC).
  - `compatibility` chunk: `tip-system` relation (0–1) and `fits` relation (a tip → its pipettes, a rotor → its centrifuges).
  - Pieces: `handling`, `safety`, `procurement`, `seo`.
  - `documents` (repeatable chunk: title, type such as SDS, manual or certificate, and a URL to the manufacturer's copy, never re-hosted).
  - `merchandising` chunk: sold in 30 days, margin, rating, campaign boost, pairs with (max 8).
  - Images live on the variants. Variant components: GTIN, catalogue number; SKU = the manufacturer's catalogue number. Variant attributes by type: a pipette's volume range, a tip's volume and format, a glove's size, a tube's volume and pack, a chemical's pack size.
- **handling** piece: storage (room temperature, 2–8 °C, −20, −80, dry and dark), shelf life (months), sterility, certified free of (DNase, RNase, human DNA, pyrogens, PCR inhibitors), autoclavable, single use.
- **safety** piece: GHS pictograms (GHS01–09), signal word, hazard and precautionary statements (key = code, value = official text), UN number, and `verified-customers-only`, which drives who may see it.
- **procurement** piece: UNSPSC, consumable (may go on a standing order), unit, pack contents, minimum order.
- **brand** (document), **tip-system** (document: brand, locked, its pipettes and tips), **category** (folder).
- Catalogue: `/<category>/<subcategory>/<product>` (pipetting, weighing, centrifugation, cell culture, tubes and containers, gloves and protection, laboratory glass, chemicals), plus brands and tip systems.
- Topic maps: category, application (PCR, cell culture, protein work, microbiology…), tip system, volume class, hazard; image topics for image type and manufacturer.
- Pricing: list prices excluding VAT, one price variant per currency, with volume tiers on consumables (the first tier at threshold 1 is the base price). VAT types per country.
- **Contract prices:** one price list per institution, targeted at that customer, with a percentage modifier per SKU, valid over the contract period. It applies on top of the list tiers, and departments and people inherit it. An institution's list can leave out a range (e.g. chemicals).
- Standing orders: a subscription plan `standing-order` with monthly and quarterly periods, on consumables' variants.
- Order pipelines: Requisitions (awaiting approval, approved, rejected, sent to supplier) and Standing orders (active, paused).
- Meta presets for customers, orders and subscription contracts, so the Crystallize App shows the meta as fields.

## Step 4: my data

Import my products and content with mass operations, mapping my data onto the model: validate each file with `build-mass-operation`, run it with `run-mass-operation` and follow it with `get-mass-operation-status`. Read the operation logs, not just the task status: a task can finish as complete with failed operations, and there is no rollback. Give every upsert a `resourceIdentifier` so a re-run updates instead of duplicating, and chunk large imports. Start with a small batch, check it in the Crystallize App with me, then run the rest.

Publish every imported item in each language: the storefront only sees published versions (in a mass operation, `item/publish` needs real `itemId`s). Import images from URLs with `copyRemoteAsset`, or upload them (presigned upload, then `registerImage`). Renditions are generated in a queue: after a large import, republish once they exist, and have the storefront fall back to the original image URL while `variants` is empty. Before building pages, query Discovery once; if the tenant isn't ignited yet, run `igniteDiscoApi` (with `stacks: opensearch` if we use vector ranking), wait for the task to complete, and allow a few minutes. If I have no data yet, create a small, clearly labelled placeholder set (ten or so products) so we can build the pages, and remind me to replace it.

- The demo's facts (catalogue numbers, specs, compatibility, GHS) come from the manufacturers' own sites and PDFs and from PubChem; the descriptions were written for it. 377 products, 2,211 variants and 1,571 images copied from the manufacturers' CDNs, with alt text in four languages.
- From me: product master data with typed specs per family, compatibility data, links to safety data sheets, images, list prices and tiers, my customers' organisation tree, contract terms, approval rules and budgets.

## Step 5: the storefront

- Next.js (App Router) on Vercel, plain `fetch` GraphQL clients. The storefront never calls the Core API (it's rate limited): only Discovery, Catalogue and Shop API. Core and PIM are for setup and tools.
- **Discovery API:** listings, facets, search and typeahead, product pages, list prices and tiers; sort by sales. "Tips that fit" filters on tips related to the pipette through `fits`, or through its tip system. Hide `verified-customers-only` products from unverified customers and guests, and enforce it on the server too (Discovery is public): refuse restricted SKUs before `hydrate` and `place`.
- **Catalogue API** (server side): the institution's contract terms, `priceList(identifier) { productVariants(language, first) { edges { node { sku priceVariant(identifier) { priceList(identifier) { modifier modifierType } } } } } }`, one cached read per institution, paged until an empty page (`hasNextPage` stays true past the last SKU). The storefront applies the percentage to the list price and tier. Per SKU, `productVariants(skus) { priceVariant(identifier) { priceFor(count, customerIdentifiers: [...]) } }` does the same (at most 150 SKUs).
- **Shop API** (JWT on the server):
  - One cart per person: `hydrate` the whole basket with the person as customer, so the price list applies.
  - Checkout: set the customer and put meta on the cart with `hydrate(input: { meta })`, `place`, then `/order` `createFromCart` into Requisitions: "awaiting approval" if it's over the department's limit or holds a hazardous item, otherwise "sent to supplier".
  - Approvals move the order with `addToStage` and merge meta (approver, decision, reason, time). A department's requisitions are its people's orders (`orders(customerIdentifier, limit: 100)`, identifiers from the session) filtered on the department meta, grouped by `coreId` keeping the newest. Budget used = approved + sent this year, per cost centre.
- **Standing orders** through the Shop API's subscription contracts (experimental; the JWT needs `subscription-contract` and `subscription-contract:admin`): one contract per standing order, the department as customer, all lines in `recurring.productVariants`; update the phase, pause, resume, skip by changing dates. Nothing renews by itself: a scheduled server job writes each period's order with Shop `/order` `create` (type `recurring`, subscription lines), then calls `renew`. Pause doesn't stop the dates: on resume, push `renewAt` and `activeUntil` forward. A list read right after `create` can be empty.
- **Customers (created in setup through the Core API):** an institution is an organization; a department is an organization under it (meta: cost centre, budget, approval limit, approver, equipment); a person is an individual under both (meta: role orders, approves or purchasing). The demo signs in with a persona switcher; real customers need real authentication.
- Order meta: your own order number, department, cost centre, orderer, approver, approval, reason, hazardous, decided at, comments.
- Env: `CRYSTALLIZE_TENANT_IDENTIFIER`, `CRYSTALLIZE_ACCESS_TOKEN_ID`, `CRYSTALLIZE_ACCESS_TOKEN_SECRET`.

Build my design, not a demo's: my typefaces, colours and tone, with pages that suit my products. Show me the home page, a category and a product page early, and adjust the design with me before building the rest.

Every page is built for search engines, answer engines and AI agents from the start, not added at the end:
- **SEO:** server-rendered HTML, one `<h1>`, a unique title and description, canonical URLs, `hreflang` per language, a sitemap and `robots.txt`, and clean, translated paths.
- **Structured data:** JSON-LD on every page that has a subject: `Organization` and `WebSite` (with a `SearchAction`) on the home page, `Product` with `Offer` (price, currency, availability) on product pages, `BreadcrumbList` on categories and products, `Article` or `Recipe` on content. Generate it from the same data as the page.
- **GEO, for answer engines and agents:** `/llms.txt` (an H1, a summary, links to every section), `/llms-full.txt`, and a Markdown twin of every page (its URL plus `.md`, or `Accept: text/markdown`), with prices, stock and facts written out in plain sentences.
- **Agents can act, not only read:** register the store's actions (search, add to cart, check availability) with WebMCP (`navigator.modelContext.registerTool`, or annotated forms), and keep the accessibility tree clean: semantic HTML, labelled controls, real buttons and links.
- **Fast and stable:** responsive images from Crystallize, no layout shift (reserve space for images and late content), little client JavaScript. Aim for 100 in every Lighthouse category (Performance, Accessibility, Best Practices, SEO; heavy 3D or video pages may score lower on performance) and pass every Agentic Browsing check.

## Step 6: run it, then ship it

Run it locally and walk me through each feature above with my own data. Fix what I find. Before you call it done, run Lighthouse on the home page, a category and a product page (`npx lighthouse <url> --only-categories=performance,accessibility,best-practices,seo,agentic-browsing`), validate the JSON-LD (https://validator.schema.org), open `/llms.txt` and a page's `.md` twin, and fix what falls short. Deploy only when I ask: set the same environment variables on the host, point my domain, and give me the live URL.

## Watch out for

- Price variants are price types, not customer prices: contracts are percentage price lists (absolute lists lose the tiers). Target customers directly, not customer groups; children inherit.
- Discovery resolves price lists only per market: get customer terms from the Catalogue API and cache them (`productVariants(skus)` takes at most 150).
- Subscription contracts made in the Core API can't be used from the Shop API: create the ones the storefront edits through the Shop API. Updating the recurring phase replaces it whole.
- Every Core `updateOrder` adds another copy to the Shop API's order list: group by the Core id and keep the latest. Shop-to-Core order sync takes about 10–20 seconds, and `coreId` can stay empty after a Shop `create`, so don't use it as a sync flag. An order's reference can't be set: keep your number in meta.
- Meta preset choices are stored as JSON arrays (`["approved"]`); filter on that string. Write order meta with Shop `/order` `setMeta(id, meta, merge: true)`; without `merge` it replaces all meta.
- Discovery: a selection is one field per option key (`<field>_<key>: { exists: true }`); number facets need at least two boundaries; no explicit nulls; `term` matches whole words; answers are cached per query body.
- Subscription plans are created and edited only in the PIM API (send the existing period ids back, or they are re-minted); variant plan prices are written through Core; the storefront reads plans from Discovery.
- `copyRemoteAsset` can register a key whose file never arrives (bot-protected CDNs): wait for the tasks, check each key resolves, and upload the failures yourself. Respect the source site's rules.
- Don't invent facts about my business (prices, stock, certifications, delivery promises). Ask, or leave a clearly marked placeholder.

And for every Crystallize store, from the skills:
- **Orders:** create and change them through the Shop API `/order` (`createFromCart`, `create`, `setMeta` with `merge: true`, `addToStage`, `setPayments`). Editing an order in Core adds a duplicate to the Shop API's list. Read a customer's orders with the identifier from the session, `limit: 100`, grouped by `coreId` (keep the newest). Seed order history through `/order` `create`, not Core `registerOrder`. The order id is the cart id; don't call `fulfill` after `createFromCart`.
- **Payments:** create the order from the payment provider's verified webhook, never from the browser, once per cart.
- **Cart meta** goes in `hydrate(input: { meta })`; `hydrate` is the whole cart, so send every line each time.
- **Images:** serve them straight from Crystallize (not through an image optimizer such as `next/image` in its default setup): a WebP `srcset` from the returned `variants` with an accurate `sizes`, falling back to the original URL while renditions are missing. Replace an image by uploading a new one, not with `registerImageRevision`.
- **Ranking (if the store ranks per shopper):** check that `__type(name: "RankByInput")` exists; pass `context` as a variable, with `userTaste` entries of `{ vocabulary, weights, magnitude }` (magnitude = the square root of the sum of squared weights); `tieBreaker` is required; don't combine `sorting` with ranking, and page with `skip` and `limit` inside the rerank window; introspect `TenantRankByField` for field names; leave `context` out when there's no taste; use `explain: true` only for tuning.
- **Bookings (if the store books):** durations are in seconds; the role needs the `bookingPolicies` permission; put the customer on the cart before `bookSkuItem`; every `hydrate` must resend each booked line with its window and unit, or the booking is cancelled; remove a booking by re-hydrating without it.

## Done when

- My tenant has the content model above with my products, prices and images in it.
- The storefront runs on my domain in my brand, and every feature I chose works with my data: we have clicked through each one together.
- Lighthouse scores close to 100 in every category and every Agentic Browsing check passes on the home, category and product pages.
- I know how to add a product, change a price and publish, in the Crystallize App.
