@whim-sdk/react

React / Next.js

Providers and hooks for React and Next.js storefronts. Works with the App Router so rental prices are in the HTML on first paint.

The script SDK (window.whim) works for Shopify, static HTML, and any page where you can drop in a <script> tag. This package is for merchants who server-render with React.

About

Overview

@whim-sdk/react gives you providers and hooks for Whim pricing in React. Merchant config is fetched once in a layout; each product page's WhimProvider fetches its own product on the server and passes it down through context, so prices are in the HTML before the page hydrates.

When to use

Use @whim-sdk/react if your storefront is a Next.js App Router project or similar React SSR framework. If you're on Shopify, WordPress, or plain HTML, use the script SDK instead.

Architecture

The SDK is organized around two scopes:

Both fetches run on the server during render: rental pricing is present in the initial HTML, and the client receives only the data the page uses. Hooks read from the nearest provider.

Setup

Install

npm install @whim-sdk/react

Requires react >=18. Ships ESM and CommonJS builds with full TypeScript declarations.


Merchant config

Fetch your merchant config in your product layout. This carries the merchant-level data (currency, checkout URL, program name, benefits, FAQs) and runs once for every page under the layout. Product data is not fetched here; each page's WhimProvider fetches its own.

// app/product/layout.tsx
import { getWhimConfig } from '@whim-sdk/react/server'
import { WhimConfig } from '@whim-sdk/react'

export default async function ProductLayout({ children }) {
  const config = await getWhimConfig({ merchantId: 'your-merchant-id' })

  return (
    <WhimConfig config={config}>
      {children}
    </WhimConfig>
  )
}

getWhimConfig() returns a WhimConfigData object. WhimConfig makes it available to all pages via context; useWhimBenefits(), useWhimFaqs(), and checkout URL building read from it.

Caching: both SDK fetches use the platform's standard fetch caching, and the Whim API responses are CDN-cached for several minutes. In Next.js, control how often server-rendered pricing refreshes with route segment options, e.g. export const revalidate = 600 for periodic revalidation or export const dynamic = 'force-dynamic' for per-request rendering.
OptionTypeDescription
merchantIdstringYour Whim merchant ID
apiBaseUrlstringOptional. Whim API origin, defaults to production
checkoutBaseUrlstringOptional. Overrides the checkout base URL from the merchant config
programNamestringOptional. Overrides the program verb (e.g. 'rent')
benefitsstring[]Optional. Overrides the benefit bullets
faqs{ question, answer }[]Optional. Overrides the FAQ entries

WhimProvider

Wrap the page where Whim hooks are used. An async server component: it fetches the named product on the server (so pricing is in the first-paint HTML), then holds the current mode, selected variant, and addon selections for its children. One provider per product.

<WhimProvider merchantId="your-merchant-id" product="your-product-key">
  {/* useWhimProduct(), useWhimPrice(), etc. available here */}
</WhimProvider>

The product key resolves to either a configurable product (variants resolved by attributes) or a simple single-SKU product; hooks behave accordingly. The key is typically the page's URL slug. If your slugs differ from your Whim product keys, map them before rendering the provider.

PropTypeDescription
merchantIdstringYour Whim merchant ID
apiBaseUrlstringOptional. Whim API origin, defaults to production
productstringProduct key: a configurable product name or a simple product name
initialAttributesRecord<string, string>Optional. Starting variant attributes, defaults to the product's default variant
syncModeToUrlbooleanOptional, default true. Mirror buy/whim mode into the ?whim= URL param
onNotFound() => voidOptional. Called when the product key doesn't resolve; pass Next.js's notFound to 404 the page
URL-based mode: WhimProvider checks for ?whim=1 on mount. When present, it initializes in whim mode. setWhimMode() keeps the URL in sync via history.replaceState, no page reload. Disable with syncModeToUrl={false}.

Putting it together

Here's how the pieces fit across three files: product layout, page, and client component.

1

Product layout

Fetch the merchant config on the server with getWhimConfig() and wrap your pages in WhimConfig. Runs once and persists across navigations.

2

Product page

The URL slug is the product key. WhimProvider fetches the product on the server; pass notFound via onNotFound so unknown keys 404 the page.

3

Client component

Your existing product UI stays as-is, hooks are available to any component inside the provider.

Code
// app/product/layout.tsx (server)
import { getWhimConfig } from '@whim-sdk/react/server'
import { WhimConfig } from '@whim-sdk/react'

export default async function ProductLayout({ children }) {
  const config = await getWhimConfig({ merchantId: 'your-merchant-id' })

  return (
    <WhimConfig config={config}>
      {children}
    </WhimConfig>
  )
}

// app/product/[slug]/page.tsx (server)
import { WhimProvider } from '@whim-sdk/react'
import { notFound } from 'next/navigation'
import { ProductInfo } from './product-info'

export default async function Page({ params }) {
  const { slug } = await params

  return (
    <WhimProvider
      merchantId="your-merchant-id"
      product={slug}
      onNotFound={notFound}
    >
      <ProductInfo />
    </WhimProvider>
  )
}

// app/product/[slug]/product-info.tsx
'use client'
import { useWhimMode, useWhimPrice } from '@whim-sdk/react'

export function ProductInfo() {
  const { mode, setWhimMode } = useWhimMode()
  const price = useWhimPrice()
  // your existing PDP UI, wired to the hooks below
}

PDP integration

Inside the provider, use hooks to read Whim state and wire it into your existing product UI.

Preview
Product Name
Large / Black
$599
1

Mode toggle

useWhimMode() gives you the current mode and a setter. Build your own toggle UI around it.

2

Price display

useWhimPrice() returns the rental price. Show your buy price in buy mode, the Whim price in whim mode.

3

Checkout

useWhimCheckoutUrl() builds the checkout URL. Point your CTA at it in whim mode, your own cart in buy mode.

Code
import { useWhimMode, useWhimPrice, useWhimCheckoutUrl } from '@whim-sdk/react'

function ProductInfo({ product }) {
  const { mode, setWhimMode, programName } = useWhimMode()
  const isWhim = mode === 'whim'

  const whimPrice = useWhimPrice()
  const buyPrice = product.currentVariant?.price

  const whimCheckoutUrl = useWhimCheckoutUrl()
  const buyCheckoutUrl = product.currentVariant?.cartUrl

  return (
    <div>
      <div className="mode-toggle">
        <button onClick={() => setWhimMode('buy')}>
          Buy {buyPrice}
        </button>
        <button onClick={() => setWhimMode('whim')}>
          {programName} {fmt(whimPrice?.recurring?.[0]?.amount)}
        </button>
      </div>

      <p className="price">
        {isWhim ? fmt(whimPrice?.recurring?.[0]?.amount) : buyPrice}
      </p>

      <a href={isWhim ? whimCheckoutUrl : buyCheckoutUrl}>
        {isWhim ? `${programName} Now` : 'Add to Cart'}
      </a>
    </div>
  )
}

Client-side rendering

Apps without React Server Components (SPAs, or any fully client-rendered tree) import the provider from @whim-sdk/react/client instead. Same props, same hooks, same children. The only difference is that the product data is fetched in the browser after mount, so hooks return null and useWhimProduct().isLoading is true until it arrives.

import { WhimProvider } from '@whim-sdk/react/client'

<WhimProvider merchantId="your-merchant-id" product="your-product-key">
  <ProductInfo />
</WhimProvider>

function ProductInfo() {
  const { product, isLoading } = useWhimProduct()
  if (isLoading) return <PriceSkeleton />
  // ...
}

The default provider from @whim-sdk/react is an async server component, which React does not support inside a client-rendered tree; the /client entry point exists for that case.


Multiple products

Mount one WhimProvider per product. Each provider holds its own mode, variant, and addon state, and hooks always refer to the product of the nearest provider. Server providers rendered as siblings fetch in parallel.

{['camera-a', 'camera-b', 'mic-c'].map((key) => (
  <WhimProvider
    key={key}
    merchantId="your-merchant-id"
    product={key}
    syncModeToUrl={false}
  >
    <RentableCard />  {/* uses hooks, needs no product props */}
  </WhimProvider>
))}

Set syncModeToUrl={false} on all but the primary product so only one provider writes to the ?whim= URL param. A kit page with selectable extras is usually better modeled as one configurable product with addons than as multiple providers; contact Whim to review your catalog setup. Multi-product checkout (a cart) is on the roadmap.


Unknown products

When the product key doesn't resolve to a catalog product, the provider reports it and lets your page decide the policy. There are two ways to handle it.

Hard 404 for dead URLs: pass Next.js's notFound via onNotFound. This is not a redirect. Next renders the nearest not-found.tsx boundary in place of the page content, inside the surrounding layouts, with the URL unchanged and an HTTP 404 status; see the Next.js documentation for exact status code behavior. Place the boundary file at the segment whose layout should wrap the message.

// app/product/[slug]/page.tsx
import { notFound } from 'next/navigation'

<WhimProvider merchantId="your-merchant-id" product={slug} onNotFound={notFound}>
  <ProductInfo />
</WhimProvider>

// app/product/not-found.tsx renders inside app/product/layout.tsx,
// so your header and page chrome stay on screen around the 404.

Soft, in-place handling for a miss inside an otherwise-valid page: omit onNotFound. The provider renders its children anyway with null product data, and useWhimProduct() reports reason: WhimUnavailableReason.NotFound, so any component can branch and render its own unavailable state.

import { useWhimProduct, WhimUnavailableReason } from '@whim-sdk/react'

function ProductInfo() {
  const { product, reason } = useWhimProduct()

  if (reason === WhimUnavailableReason.NotFound) {
    return <p>This product isn't available to rent right now.</p>
  }
  // ...
}
Status code caveat: the soft path returns HTTP 200 because the page still renders normally on the server. Use it for unavailable-product states inside otherwise valid pages; use onNotFound={notFound} for dead URLs so crawlers and monitoring see a 404. The callback is generic, so redirect policies also work: onNotFound={() => redirect('/products')}.

Reference

All hooks read from the nearest WhimProvider. They throw if called outside one. useWhimBenefits() and useWhimFaqs() read from WhimConfig.

useWhimMode()

Current mode and a setter to switch between buy and whim.

const { mode, setWhimMode, programName } = useWhimMode()

// mode: 'buy' | 'whim'
// programName: merchant-configured label, e.g. 'Rent', 'Subscribe', 'Try'

<button onClick={() => setWhimMode('buy')}>Buy</button>
<button onClick={() => setWhimMode('whim')}>{programName}</button>

Returns { mode, setWhimMode, programName }

setWhimMode('whim') adds ?whim=1 to the URL. setWhimMode('buy') removes it. No page reload.


useWhimProduct()

The currently resolved product and attribute controls. For configurable products, call setWhimAttribute() when the user picks a variant and the provider resolves the matching product automatically.

const {
  product,             // resolved WhimProduct, or null
  configurableProduct, // full WhimConfigurableProduct (variants, attributes), or null
  isAvailable,         // false when no variant matches the Whim catalog
  reason,              // WhimUnavailableReason enum member, or null
  isLoading,           // true while the client-side provider is still fetching
  setWhimAttribute,    // ('size', 'large') - resolves new variant
  attributeDefs,       // [{ name: 'size', values: ['small', 'large'] }, ...]
} = useWhimProduct()

// wire your existing pickers, one extra line each
function handleSizeChange(newSize) {
  setSize(newSize)                       // your own state
  setWhimAttribute('size', newSize)      // sync with Whim
}

// disable rent when variant isn't rentable
<button disabled={!isAvailable}>
  {isAvailable ? 'Rent Monthly' : 'Rent unavailable'}
</button>

Returns { product, configurableProduct, isAvailable, reason, isLoading, setWhimAttribute, attributeDefs }

When setWhimAttribute() resolves a different variant, addon selections reset to that variant's defaults: required addons are re-applied, optional ones are cleared. Different variants can have different available addons, so resetting is the safe default.

Resolution reasons (the WhimUnavailableReason enum) when isAvailable is false:


useWhimPrice()

Pricing for the current product plus selected addons. Recurring and one-time costs are split so you can render them however you want.

const price = useWhimPrice()
// amounts are in cents, format however you want

{price && (
  <>
    {price.recurring.map((r) => (
      <p key={r.interval}>
        {fmt(r.amount)}/{r.interval.toLowerCase()}
      </p>
    ))}
    {price.oneTime && (
      <p>{fmt(price.oneTime.amount)} today</p>
    )}
    {price.introDiscount && (
      <p>
        {fmt(price.introDiscount.amount)} for first
        {' '}{price.introDiscount.months} months
      </p>
    )}
  </>
)}

Returns WhimResolvedPrice | null

Returns null before a product is resolved. See WhimResolvedPrice for the full shape.


useWhimCheckoutUrl()

Checkout URL for the current product, variant attributes, and selected addons. Built client-side from the merchant config's checkoutBaseUrl, so it must be used inside both WhimConfig and WhimProvider.

const whimCheckoutUrl = useWhimCheckoutUrl()

// e.g. https://rent.example.com/merchant/checkout?products=your-product-name(size=large,color=black,addons(case=c1))

<a href={isWhim ? whimCheckoutUrl : product.cartUrl}>
  {isWhim ? `${programName} Now` : 'Add to Cart'}
</a>

Returns string | null

Returns null when no product is resolved or isAvailable is false.


useWhimAddons()

Addon definitions for the current variant and selection state. The provider filters to addons available for the current variant automatically.

const { addonDefs, addons, setWhimAddon, removeWhimAddon } = useWhimAddons()

// addonDefs - addon definitions for the current variant
// addons    - current selections: { case: 'c1', warranty: 'w1' }

// single-option addon (warranty) - checkbox
{addonDefs.filter(a => a.isOptional).map((addon) =>
  addon.options.length === 1 ? (
    <Checkbox
      checked={!!addons[addon.addonKey]}
      onToggle={(on) => on
        ? setWhimAddon(addon.addonKey, addon.options[0].optionKey)
        : removeWhimAddon(addon.addonKey)
      }
    />
  ) : (
    // multi-option addon (case with choices) - dropdown
    <Select
      value={addons[addon.addonKey]}
      onChange={(val) => val
        ? setWhimAddon(addon.addonKey, val)
        : removeWhimAddon(addon.addonKey)
      }
    />
  )
)}

Returns { addonDefs, addons, setWhimAddon, removeWhimAddon }

Key addon behaviors:


useWhimBenefits()

Marketing copy from the merchant config. Useful for showing rental benefits alongside the price.

const benefits = useWhimBenefits()

// ['Free shipping', 'Cancel anytime', '30-day trial']

{benefits.length > 0 && (
  <ul>{benefits.map((b) => <li key={b}>{b}</li>)}</ul>
)}

Returns string[]


useWhimFaqs()

FAQ entries from the merchant config. Useful for rendering a rental FAQ section on the PDP.

const faqs = useWhimFaqs()

// [{ question: 'How does renting work?', answer: 'Pick a plan...' }, ...]

{faqs.length > 0 && (
  <dl>
    {faqs.map((faq) => (
      <div key={faq.question}>
        <dt>{faq.question}</dt>
        <dd>{faq.answer}</dd>
      </div>
    ))}
  </dl>
)}

Returns { question: string, answer: string }[]

Types

WhimConfigData

Returned by getWhimConfig(), passed to WhimConfig.

interface WhimConfigData {
  merchantId: string
  apiBaseUrl: string
  currency: string
  checkoutBaseUrl: string
  programName: string               // 'rent', 'subscribe', 'try', etc.
  benefits: string[]                // marketing copy
  faqs: { question: string, answer: string }[]
}

WhimProductData

Returned by getWhimProduct(), or null when the key doesn't exist (HTTP 404). Network and server errors throw instead, so outages surface as errors rather than 404s. This is what WhimProvider fetches internally.

type WhimProductData =
  | { type: 'configurable', configurable: WhimConfigurableProduct }
  | { type: 'simple', product: WhimProduct }

WhimConfigurableProduct

interface WhimConfigurableProduct {
  name: string                      // e.g. 'your-product-name'
  displayName: string
  attributes: WhimConfigurableAttribute[]
  products: WhimProduct[]           // rentable variants under this configurable
  defaultProduct?: WhimProduct
  addons: WhimAddonDef[]
}

interface WhimConfigurableAttribute {
  name: string                      // 'size', 'color'
  selectorType?: string             // UI hint, e.g. 'color'
  options: {
    name: string                    // display label: 'Midnight Black'
    value: string                   // attribute value: 'black'
    urlValue: string                // checkout URL form: 'mb'
  }[]
}

WhimAttributeDef

interface WhimAttributeDef {
  name: string                      // 'size', 'color'
  values: string[]                  // ['small', 'large'], ['black', 'white']
}

WhimProduct

interface WhimProduct {
  name: string                      // 'your-product-large-black'
  displayName: string
  images: string[]
  isOutOfStock: boolean
  attributeValues?: WhimAttributeValue[]
  addonKeys?: string[]              // which addon defs apply to this variant
  price: WhimProductPrice
  discountPercent: number | null
  discountMonths: number | null
}

interface WhimAttributeValue {
  name: string                      // attribute name: 'size'
  value: string                     // selected value: 'large'
}

interface WhimProductPrice {
  amount: number                    // cents, e.g. 4900
  compareAtAmount: number | null    // buy price reference, e.g. 59900
  isRecurring: boolean
  recurringInterval: string | null  // 'MONTH', 'YEAR', etc.
  recurringCount: number | null
}

WhimResolvedPrice

Returned by useWhimPrice(). Base product price plus selected addons, grouped by billing cadence. Amounts are in cents, formatting is up to you.

interface WhimResolvedPrice {
  recurring: {
    amount: number                  // cents, e.g. 4900
    interval: string                // 'MONTH', 'YEAR', etc.
    count: number                   // e.g. 1
  }[]                               // grouped by cadence, usually one entry
  oneTime: {
    amount: number                  // cents
  } | null
  introDiscount: {
    amount: number                  // discounted cents
    months: number
  } | null
}

WhimAddonDef

interface WhimAddonDef {
  name: string
  displayName: string
  addonKey: string                  // checkout URL key: 'case', 'warranty'
  isOptional: boolean
  defaultOptionKey: string | null
  type: 'FEE' | 'PRODUCT'
  options: {
    optionKey: string               // checkout URL value: 'c1', 'w1'
    product: { name: string, displayName?: string, price: WhimProductPrice }
  }[]
}

WhimUnavailableReason

Enum returned as reason by useWhimProduct() when isAvailable is false. Exported from @whim-sdk/react as a value, so compare against its members.

enum WhimUnavailableReason {
  NotFound = 'NOT_FOUND',
  IncompleteAttributes = 'INCOMPLETE_ATTRIBUTES',
  NoVariantMatch = 'NO_VARIANT_MATCH',
  OutOfStock = 'OUT_OF_STOCK',
}