React widgets

@meisei/widgets is the React package behind the storefront widgets. If your storefront is a React app, you can import the widgets directly instead of dropping in the embed script, and you can take any widget apart and lay its pieces out yourself.

This page covers the three depths you can work at, the parts each widget exposes, and the props each part takes. It is precise enough to build from, so if you hand it to a code assistant, hand it the whole page.

On Next.js, read Next.js and Vercel Shop instead. It covers the same widgets, with their data loaded on the server.

Setup

npm install @meisei/widgets

Wrap the part of your tree that renders widgets in a MeiseiProvider. Every connected widget reads the API base URL, the publishable key and the signed-in customer from it.

import { MeiseiProvider } from "@meisei/widgets"
import "@meisei/widgets/theme.css"
;<MeiseiProvider
  baseUrl="https://your-store.meisei.app/api"
  apiPublishableKey="pk_live_..."
  customerAuth={customer ? { email, timestamp, signature } : null}
>
  <App />
</MeiseiProvider>

baseUrl is your store's own address with /api on the end, which Settings → General prints under Address. There is no default, so the widgets say what to set and stop if you leave it out. On Next.js the provider reads MEISEI_API_URL instead (see Next.js).

customerAuth is the same signed trio the embed script takes. See Identifying the customer for how to make it. Leave it null for a signed-out shopper.

Pass agentTools to let an AI agent in the shopper's browser read the reviews, ask about a product, check their rewards and save to their wishlist. See AI agents.

Each widget's stylesheet is imported by the widget itself. The custom properties and class names in Appearance apply exactly as they do to the embed, so a theme set in the dashboard reaches a React storefront too, provided you link the served stylesheet from your page's <head>.

Two components per widget

Every widget ships as a pair:

ConnectedPresentationalWhat it does
ProductReviewsWidgetProductReviewsThe reviews list, with summary, sort, filter, votes and form.
ProductRatingWidgetProductRatingStars and a count.
ReviewsCarouselWidgetReviewsCarouselA sliding row of review cards.
MediaGridWidgetMediaGridA wall of shopper photos and videos with a lightbox.
CustomerWishlistsWidgetCustomerWishlistsThe shopper's lists and the products on them.
AddToWishlistWidgetAddToWishlistThe save button for a product page.
WishlistsModalWidgetWishlistsModalA button that opens the lists in a sheet from the right.
LoyaltyWidgetLoyaltyPanelThe loyalty program, inline.
LoyaltyLauncherWidgetLoyaltyLauncherThe same panel behind a floating corner button.
LoyaltyPointsWidgetLoyaltyPoints"Earn 250 points with this purchase."

The connected widget talks to the API: it fetches, paginates, votes, submits, and handles the shopper's sign-in state. The presentational component takes everything as props and fetches nothing. You will mostly use the connected one; the presentational one is for when you already have the data, or for a preview against sample data.

Level one: drop it in

import { ProductReviewsWidget } from "@meisei/widgets"
;<ProductReviewsWidget productSlug="classic-tee" />

The reviews widget as it renders: the summary, breakdown, write button and the list

Every connected widget renders its default composition when it has no children. Its props are listed under Connected widget props.

Level two: lay the parts out yourself

Give the connected widget children and it renders them instead of the default composition, inside a root it has filled with what it fetched. Each part reads that data and draws its own internals. How the parts sit relative to each other, and any markup between them, is up to you.

import { ProductReviews, ProductReviewsWidget } from "@meisei/widgets"
;<ProductReviewsWidget productSlug="classic-tee">
  <div className="flex justify-between gap-8">
    <ProductReviews.RatingSummary />
    <ProductReviews.RatingBreakdown />
    <ProductReviews.WriteReview />
  </div>
  <ProductReviews.Toolbar />
  <ProductReviews.List />
  <ProductReviews.LoadMore />
</ProductReviewsWidget>

Four rules hold for every widget:

  • Parts are properties of the presentational component, ProductReviews.List, never separate imports. Import the presentational component to reach them.
  • A part takes no data props. It reads the root. Leaving a part out leaves that piece off the page; a part with nothing to show (no summary, no more pages, no form handler) renders nothing.
  • The root element and its class names are unchanged. The connected widget still renders .meisei.meisei-reviews around your children, so custom CSS written against the documented classes keeps applying, and anything with structured data still emits it.
  • With the presentational component, the root is explicit. Pass the same props you would pass to the component to <ProductReviews.Root> and put the parts inside it. The connected widget does this for you, which is why you do not write Root inside it.
<ProductReviews.Root averageRating={4.5} totalReviews={2} ratingBreakdown={...} reviews={reviews} hasMore={false}>
  <ProductReviews.RatingSummary />
  <ProductReviews.List />
</ProductReviews.Root>

Level three: recompose a row

A list part renders its children once per item, inside a context holding that item. With no children it renders the default row. So a review row can be rebuilt from its own parts, or replaced with a component of yours:

<ProductReviewsWidget productSlug="classic-tee">
  <ProductReviews.RatingSummary />
  <ProductReviews.List>
    <ProductReviews.Review.Root>
      <ProductReviews.Review.Rating />
      <ProductReviews.Review.Author />
      <ProductReviews.Review.Body />
    </ProductReviews.Review.Root>
  </ProductReviews.List>
</ProductReviewsWidget>
function Excerpt() {
  const review = ProductReviews.Review.useReview()
  return <blockquote>{review.body}</blockquote>
}

;<ProductReviews.List>
  <Excerpt />
</ProductReviews.List>

Every widget with a list exposes the same shape: a list part that takes children, a default row, and a hook that returns the current item. The hooks are listed per widget below.

Writing a part of your own

Every presentational component exposes useData(), which returns what its Root was given with defaults applied, plus any state the root keeps for its parts (which tile is open, whether the popover is showing). A custom part is an ordinary component that calls it:

function ReviewCount() {
  const { totalReviews } = ProductReviews.useData()
  return <span>{totalReviews} reviews</span>
}

A hook used outside its root throws with the name of the root it needs, for example This part has to be rendered inside <ProductReviews.Root>.

The parts, widget by widget

Each table lists a part, what it renders, and when it renders nothing. A part's props are listed where it takes any; most take none. children on a list part are rendered once per item; empty replaces the default empty state.

ProductReviews

Root element: div.meisei.meisei-reviews. Default order: Summary, AboutReviews, QuestionSummary, WriteReview, AiSummary, AskBox, Toolbar, List and LoadMore.

PartRendersNothing when
RootThe provider and root element. Props: every ProductReviews prop, children.
SummaryRatingSummary and RatingBreakdown side by side in .meisei-reviews-summary.
RatingSummaryThe average as a number, its stars and the review count.
RatingBreakdownOne bar per star rating; filter buttons when onRatingChange is set.
AboutReviewsThe "About our reviews" link. It opens the store's statement in a dialog, or goes to the store's own page when it has set one.No reviewStatement.
QuestionSummaryWhat published reviews answered to your review questions.questionSummary is empty.
WriteReviewThe "Write a review" toggle and its inline form.No onSubmitReview.
AiSummaryThe generated "What reviewers say" block.No summary.
AskBoxThe "Looking for specific info?" box: an instant answer from the model.No onAsk, or instant answers not enabled.
ToolbarThe sort control and the "Showing 5 star reviews only" line.No onSortChange and no active rating.
ListThe reviews. Props: children, empty.Shows the empty state instead when no reviews.
ReviewOne default row. Also a namespace, below.
LoadMoreThe "Load more reviews" button.hasMore is false or no onLoadMore.
useData()Hook: the root's props with defaults applied.

ProductReviews.Review parts, valid inside List:

PartRendersNothing when
Rootdiv.meisei-reviews-item. Props: children.
HeaderAuthor, Verified, the country and Date on one line.
AuthorThe reviewer's first name and last initial.
VerifiedThe "Verified buyer" badge.Not a verified buyer.
DateThe review date.
RatingThe review's stars.
TitleThe title.No title.
BodyThe body.No body.
AnswersThis reviewer's answers to your review questions.No answers.
MediaThe attached photos and videos, opening a lightbox.No files.
ResponseThe store's reply.No reply.
VotesThe helpful and not helpful buttons.No onVote on the root.
ReportThe "Report" link and the dialog it opens.No onReport on the root.
useReview()Hook: the current review.

ProductRating

Root element: div.meisei.meisei-rating. Default: Stars, then Count unless hideCount.

PartRenders
RootThe provider and root element. Props: every ProductRating prop, children.
StarsFive stars filled to the average.
CountThe review count.
useData()Hook: averageRating, totalReviews, starSize, filledColor, emptyColor.

ReviewsCarousel

Root element: section.meisei.meisei-reviews-carousel. Default: Header, Track. With no reviews the root renders the empty state (or its empty prop) whatever children it was given.

PartRendersNothing when
RootThe provider and root element. Props: every ReviewsCarousel prop, empty, children.
HeaderHeading and Nav on one line.
HeadingThe heading.heading is "".
NavThe previous and next arrows, each disabled at its end of the track.
TrackThe sliding row. Props: children.
CardOne default card. Also a namespace, below.
useData()Hook: the props plus atStart, atEnd, slide(direction).

ReviewsCarousel.Card parts, valid inside Track: Root (li.meisei-reviews-carousel-card, takes children), Rating, Title, Body, Image (the first photo), Footer (name and verified badge), Product (nothing unless showProduct and the review names one), and useCard() for the current review.

MediaGrid

Root element: section.meisei.meisei-media-grid. Default: Heading, Tiles, LoadMore, Lightbox. With no items the root renders the empty state (or its empty prop) whatever children it was given.

PartRendersNothing when
RootThe provider and root element. Props: every MediaGrid prop, empty, children.
HeadingThe heading.heading is "".
TilesThe grid. Props: children.
TileOne default tile: the photo or video frame as a button.
LoadMoreThe "Load more photos" button.hasMore is false or no onLoadMore.
LightboxThe lightbox with the review behind the open photo.No tile is open.
useData()Hook: the props plus openIndex and setOpenIndex.
useTile()Hook, inside Tiles: { item, index, open() }.

A custom tile should call open() from useTile() to show the lightbox, and the composition should include <MediaGrid.Lightbox /> somewhere for it to open into.

CustomerWishlists

Root element: div.meisei.meisei-wishlists. Default: ErrorNotice, Overview, Detail, ShareDialog. The Overview parts render only while no list is open and the Detail parts only while one is, so a composition can include both.

PartRendersNothing when
RootThe provider and root element. Props: every CustomerWishlists prop, children.
ErrorNoticeThe error message.No error.
OverviewOverview.Header, Overview.CreateForm, Overview.Rows. Props: children.A list is open.
Overview.HeaderThe "Your wishlists" title.A list is open.
Overview.CreateFormThe new wishlist form.A list is open, or single-list mode.
Overview.RowsOne row per list. Props: children, empty.A list is open.
Overview.RowOne default row: name, count, View, Make default, Share, Delete.
DetailDetail.Header, Detail.Items. Props: children.No list is open.
Detail.HeaderBack, the name or rename form, Share, Rename, Delete.No list is open.
Detail.ItemsOne card per product. Props: children, empty.No list is open.
Detail.ItemOne default card with its edit, move and remove controls.
ShareDialogThe share dialog.Share has not been pressed.
useData()Hook: the props plus standalone, shareTarget, openShare(wishlist), closeShare().
useWishlist()Hook, inside Overview.Rows: the current list.
useItem()Hook, inside Detail.Items: the current product.

AddToWishlist

Root element: div.meisei.meisei-add-to-wishlist. Default: Controls, Popover, Toast, ErrorNotice. The root closes the popover on a click anywhere outside it.

PartRendersNothing when
RootThe provider and root element. Props: every AddToWishlist prop, children.
ControlsButton and PickerButton grouped.
ButtonThe save button, toggling the product on the default list.
PickerButtonThe chevron that opens the list picker.Single-list mode with one list.
PopoverThe list picker with "Manage wishlists" at the bottom.Closed.
ToastThe "Added to ..." message.Nothing saved in the last 5 seconds.
ErrorNoticeThe error message.No error.
useData()Hook: the props plus inDefault, showPicker, isBusy, popoverOpen, toast, toggleDefault(), togglePopover(), toggleList(wishlist), manage().

WishlistsModal

Root element: dialog.meisei.meisei-wishlists-modal. This root is the exception. Its children are the dialog's content, not a default composition, because a modal has no default content of its own. <WishlistsModal> itself renders Header then Body around its children.

The variant prop picks where it opens: modal (the default) sits in the middle of the viewport, and sheet docks to the right edge at full height and slides in. The manage dialogs use the sheet; the share dialog stays a modal.

PartRenders
RootThe dialog and its content box. Props: open, onClose, title, variant, children.
HeaderThe title and the close button.
BodyThe scrolling body. Props: children.
useData()Hook: open, title, variant, onClose.

WishlistsModalWidget has two parts of its own, since it owns the open state: WishlistsModalWidget.Trigger (the button) and WishlistsModalWidget.Dialog (the sheet, holding <CustomerWishlistsWidget /> unless given children). Pass children to the widget to place them apart.

LoyaltyPanel

Root element: div.meisei.meisei-loyalty. Default: Header, Notices, ProgramTabs, ErrorNotice, HeldRewards, Sections. Without a program the root renders the "no program yet" line (or the error) whatever children it was given.

PartRendersNothing when
RootThe provider and root element. Props: every LoyaltyPanel prop, children.
HeaderProgram name, balance, tier, and the join button or sign-in hint.
NoticesThe draft and paused notices.The program is live.
ProgramTabsThe switcher between programs.One program, or no onSelectProgram.
ErrorNoticeThe error message.No error.
HeldRewards"Your rewards", one row per held reward. Props: children.Not a member, or none held.
HeldRewardRowOne default held reward: name, code, expiry, Cancel.
SectionsTiers, Rules, Offers, Rewards in the program's order.
TiersThe tier ladder and progress to the next tier.Section off or no tiers.
Rules"Ways to earn", one row per rule. Props: children.Section off or no rules.
Offers"Offers", one row per rule. Props: children.Section off or no offers.
Rewards"Redeem your points", one row per reward. Props: children.Section off or no rewards.
RuleRowOne default earning rule or offer, with the member's punch card progress.
RewardRowOne default redeemable reward, with the confirm step.
useData()Hook: the props with the program resolved, plus names, member, enabled.
useRule()Hook, inside Rules, Offers or Rewards: the current rule.
useHeldReward()Hook, inside HeldRewards: the current held reward.

A custom row inside any of the list parts should render its own <li>; the list part renders the <ul>.

LoyaltyLauncher

Root element: div.meisei.meisei-loyalty-launcher. Default: Panel, Button. <LoyaltyLauncher> itself puts its children inside Panel.

PartRendersNothing when
RootThe provider and root element. Props: open, onToggle, label, title, position, children.
PanelThe panel with its title bar. Props: children.Closed.
ButtonThe floating pill.
useData()Hook: open, onToggle, label, title, position.

LoyaltyPoints

Root element: a.meisei.meisei-loyalty-points when href is set, otherwise p.meisei.meisei-loyalty-points. Default: Label. The root renders nothing at all when points is zero or less, whatever children it was given.

PartRenders
RootThe link or paragraph. Props: every LoyaltyPoints prop, children.
Label"Earn 250 points with this purchase." or the join variant.
useData()Hook: points, pointsNames, member, href, amount (formatted, such as "250 points"), label.

Loading on the server

Every connected widget that fetches on mount also accepts initialData. Pass it and the widget draws on its first render, on the server and in the browser, and makes no request until the shopper sorts, filters or asks for another page. A search engine can read reviews that are in the HTML, and a shopper on a slow connection sees them straight away.

Each one has a loader that returns exactly that shape. Loaders are plain async functions with no framework in them, so they work in a Next.js server component, a getServerSideProps, a Remix loader or any server of your own:

import { loadProductReviews, ProductReviewsWidget } from "@meisei/widgets"

const initialData = await loadProductReviews({
  baseUrl: "https://your-store.meisei.app/api",
  apiSecretKey: process.env.MEISEI_SECRET_KEY,
  productSlug: "classic-tee",
})
;<ProductReviewsWidget productSlug="classic-tee" initialData={initialData} />
LoaderFillsQuery props
loadProductReviewsProductReviewsWidgetproductSlug, limit, sort, rating
loadProductRatingProductRatingWidgetproductSlug (one slug or a list of them)
loadReviewsCarouselReviewsCarouselWidgetproductSlug, sort, minRating, limit
loadMediaGridMediaGridWidgetproductSlug, minRating, limit
loadLoyaltyLoyaltyWidget, LoyaltyLauncherWidgetsiteSlug, customer
loadLoyaltyEarnableLoyaltyPointsWidgetprice, currencyCode, siteSlug, customer

Every loader takes your store's baseUrl and one key: apiSecretKey for a server, or apiPublishableKey if that is all you have. Use the secret key on a server, because the publishable key is rate limited per address. One server rendering for every shopper would spend a single shopper's allowance. See Rate limits.

fetch and init are passed straight through, so init: { next: { revalidate: 60 } } or a fetch of your own works as usual. loadProductRating takes a list of slugs and answers for all of them at once, so a category page loads every rating on it in one request and hands the same response to each widget.

A page with several widgets can load them all in one request. Make one reader per page with createWidgetReader({ baseUrl, apiSecretKey }) and pass it to every loader as reader. Loaders called in the same tick then go out together as one GET /widgets/boot rather than one request each.

import { createWidgetReader, loadLoyalty, loadProductReviews } from "@meisei/widgets"

const reader = createWidgetReader({
  baseUrl,
  apiSecretKey: process.env.MEISEI_SECRET_KEY,
})
const [reviews, loyalty] = await Promise.all([
  loadProductReviews({
    baseUrl,
    apiSecretKey,
    reader,
    productSlug: "classic-tee",
  }),
  loadLoyalty({ baseUrl, apiSecretKey, reader }),
])

In the browser you do not need a reader. Every widget inside one MeiseiProvider asks for its first view through the provider, and the provider sends the page's reads together.

signCustomer({ baseUrl, apiSecretKey, email }) returns the signed trio for <MeiseiProvider customerAuth={...}>, so a signed-in shopper's wishlists and points can be loaded on the server too, without your code holding the signing secret.

On Next.js, use @meisei/widgets/next instead of calling these by hand. It has a provider and a server component per widget that do all of the above, including the theme stylesheet and signing the shopper in.

Connected widget props

The connected widgets take the props below, plus children on every one of them, plus initialData on the ones listed under Loading on the server. Without initialData a connected widget renders nothing until its first response has arrived, so a part never sees a half-loaded root; with it, the first render is drawn from what you passed.

Every widget that shows one product takes either productSlug or productExternalId, the product's ID on your store (a Shopify product ID, say). An ID keeps working when the product's slug changes. If you have several sites and the same ID is on more than one of them, also pass site, the slug of the site you mean, to the widget or once on MeiseiProvider.

WidgetProps
ProductReviewsWidgetproductSlug, pageSize (10), structuredData (true), reviewForm (true), questionSummary (true), productUrl, context, pageContext, starSize, filledColor, emptyColor.
ProductRatingWidgetproductSlug, structuredData (false), productUrl, className, starSize, filledColor, emptyColor, hideCount.
ReviewsCarouselWidgetproductSlug, sort (newest), minRating (every rating), maxReviews (10), heading, starSize, filledColor, emptyColor.
MediaGridWidgetproductSlug, minRating, maxItems (12), heading, starSize, filledColor, emptyColor.
CustomerWishlistsWidgetNone.
AddToWishlistWidgetproductSlug, productSnapshot (what a guest save remembers: title, imageUrl, price, productUrl).
WishlistsModalWidgettriggerLabel, title.
LoyaltyWidgetcurrencyCode (USD), siteSlug.
LoyaltyLauncherWidgetAs LoyaltyWidget, plus label, title, position (bottom-right or bottom-left).
LoyaltyPointsWidgetprice (whole currency units), currencyCode, siteSlug, className.

context on ProductReviewsWidget is the store's notes for the instant answer, and pageContext is what the page says about the product. If you leave it out, the widget reads the page itself (title, meta descriptions, any schema.org Product). A string is used as is, and false sends nothing beyond the product's stored description. See Instant answers.

What children do not change: ProductReviewsWidget and ProductRatingWidget still emit their structured data, AddToWishlistWidget still renders the manage dialog beside the button, and CustomerWishlistsWidget still switches between the signed-in and guest lists.

Presentational component props

The presentational components' props are the data the connected widget would fetch plus the handlers it would wire up. They are typed in the package. The main shapes are:

  • ProductReviews: averageRating, totalReviews, ratingBreakdown, reviews, hasMore, and optionally summary, reviewAnswers, reviewStatement ({ text, url }, what the About our reviews link shows), sort, activeRating, loading, signedInEmail, the star props, and the handlers onLoadMore, onSortChange, onRatingChange, onVote, onReport (a shopper's report on one review, as (reviewId, { reason, note })), onSubmitReview, onUploadReviewFile and onAsk (the instant answer).
  • ReviewsCarousel: reviews, heading, showProduct, the star props.
  • MediaGrid: items, heading, showProduct, hasMore, onLoadMore, loading, the star props.
  • CustomerWishlists: wishlists, activeWishlist, error, loading, sharingEnabled, multipleWishlists, guest, and one handler per action.
  • AddToWishlist: wishlists, loading, pending, error, disabled, multipleWishlists, onToggleDefault, onAddToWishlist, onRemoveFromWishlist, onManageWishlists.
  • LoyaltyPanel: program, programs, loading, error, canJoin, currencyCode, onJoin, onClaim, onCancelReward, onSelectProgram.
  • LoyaltyPoints: points, pointsNames, member, href, className.

Every type is exported from the package root: Review, ReviewSummary, CarouselReview, MediaGridItem, WishlistSummary, WishlistDetail, WishlistItem, AddToWishlistOption, LoyaltyProgram, LoyaltyMember, LoyaltyRule, LoyaltyReward and the rest.

A worked example

packages/example-custom-store in the Meisei repository is a React storefront that uses the connected widgets on every page that has one. Its product page lays the reviews widget out from parts: the rating summary, the breakdown and the write button in one row, then the rest in the default order. See Custom integration for the store as a whole.