@suiteop/storefront
v0.3.0
Published
Typed client, React hooks and headless components for the SuiteOp Storefront API.
Readme
@suiteop/storefront
Typed client, React hooks and headless components for the SuiteOp Storefront API: the API a direct-booking site uses to list properties, check availability, quote a stay and book it.
npm install @suiteop/storefront zodzod 4 is a dependency (the response schemas run on it). react 18 or 19 is an optional peer,
needed only for @suiteop/storefront/react and @suiteop/storefront/components.
Client
import { createStorefrontClient } from '@suiteop/storefront'
const storefront = createStorefrontClient({ publishableKey: 'pk_live_eu_…' })
const { items, total } = await storefront.listings.list({ location: 'lisbon', limit: 20 })
const availability = await storefront.availability.get(items[0].id, {
checkIn: '2027-03-01',
checkOut: '2027-03-04',
guests: 2,
})
const quote = await storefront.quotes.create({
listingId: items[0].id,
checkIn: '2027-03-01',
checkOut: '2027-03-04',
guests: 2,
})
const session = await storefront.checkoutSessions.create({ quoteRef: quote.quoteRef })| Option | Default | |
| ---------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| publishableKey | required | pk_{live\|test}_{us\|eu\|apac}_…. A secret sk_ key is refused. |
| baseUrl | from the key | pk_live_ → https://api-<region>.suiteop.com; pk_test_ → https://api-<region>-staging.suiteop.com. 'same-origin' → the page's own origin (SuiteOp-hosted sites). Any URL → that host. |
| timeoutMs | 10 s reads, 20 s quotes / checkout | Per request. |
| locale | none | Sent as Accept-Language for API-side copy. |
| fetch | globalThis.fetch | |
Every method takes a last { signal } option; aborting rejects with the signal's AbortError.
Responses are validated against the API's own schemas, so a shape this version does not know
fails loudly (invalid_response) instead of rendering undefined. Reads are retried once on a
network failure or a 5xx; quotes, checkout and contact are never retried.
checkoutSessions.create sends an Idempotency-Key and returns it on the session, because
<suiteop-checkout idempotency-key> needs the same one. Pass your own, or let the client keep
one per quote and rate plan: a retry after a timeout or network error reaches the same session
and the same payment hold. The key is released only when a new session is safe: a session that
comes back expired or cancelled, a create refused with idempotency_key_reused (the key
belongs to another quote), or a confirm error that ends the session (session_expired, or
requiresNewSession). Every other create refusal keeps it: the same key may already name a live
session (a replay, or a concurrent create that won), and where none exists a retry is simply a
new create. A failed session or booking keeps the key: the guest may already have been
charged, so a retry replays the dead session and your UI should show the failure.
checkoutSessions.forget(quoteRef, ratePlanId) releases the key explicitly — never call it after
a failed result, or a retry can take a second payment. A failed create carries the key in error.details.idempotencyKey.
A site can add custom fields and upsells to its checkout. <suiteop-checkout> shows them and
sends the guest's choices itself, so a host page creates the session exactly as above. A session
carries extras: the upsell lines the API priced and heldTotalMinor, the total the hold is
for. Show that total, never one computed on the page. With extras the guest pays for a session
the element created, so a page that prints a total of its own takes it from the element's
suiteop-checkout:ready event (detail.totalMinor, detail.currency, present on the payment
step). A booking's totalMinor is what was paid, and lines lists the extras inside it.
Requires Node 20+ outside browsers (global fetch and crypto).
Money is an integer …Minor field (the amount × 100) with a lower-case ISO 4217 currency;
upper-case it yourself for display.
Content
A site declares the content its property manager edits in the SuiteOp dashboard — collections (a blog, press mentions), page sections (a home-page banner) and extra fields on buildings or areas — in its own code, and reads the values per request:
// src/suiteop.content.ts
import { defineContent, field } from '@suiteop/storefront/content'
export default defineContent({
blog: {
kind: 'collection',
label: 'Blog',
titleField: 'title',
routable: true, // each post gets a slug
drafts: true, // posts are published explicitly
order: 'newest',
fields: {
title: field.text({ label: 'Title', required: true, maxLength: 150 }),
cover: field.image({ label: 'Cover photo' }),
body: field.richText({ label: 'Article', required: true }),
},
},
home_hero: {
kind: 'section',
label: 'Home — top banner',
fields: {
title: field.text({ label: 'Headline' }),
photos: field.gallery({ label: 'Photos' }),
},
},
area_details: {
kind: 'fields',
attachesTo: 'area',
label: 'Area details',
fields: { best_for: field.text({ label: 'Best for' }) },
},
})import { createContentReader } from '@suiteop/storefront/content'
import definition from './suiteop.content'
const content = createContentReader(storefront, definition)
const { items } = await content.collection('blog').list({ limit: 10 }) // newest first
const post = await content.collection('blog').get(slug) // null: draft, scheduled or unknown
const hero = await content.section('home_hero').get()
const [area] = await storefront.locations.list()
const bestFor = content.fieldsOf('area_details', area).best_for // string | nullField types: text, richText (Markdown — render it with raw HTML disabled), image,
gallery, number, boolean, date (YYYY-MM-DD), option, link ({ url, label }),
reference / references ({ kind, id, slug }, to: 'listing' | 'building' | 'area' |
'collection:<key>'). Every value is typed from the declaration; any single value may be null
(not filled in yet, or no longer fitting its field after a code change) and lists may be empty.
Text is in the client's locale with the site's default language as fallback.
With suiteop({ content: './src/suiteop.content.ts' }) in astro.config.mjs, the build emits the
model at /_suiteop/content-model.json and the SuiteOp deploy registers it: new types and fields
appear in the dashboard; ones the code stops declaring are hidden, and nothing a manager typed is
ever deleted. Add routes: { '/blog/[slug]': 'collection:blog' } for the sitemap to list each
published post.
Errors
Every failure is a StorefrontError with status (0 when nothing answered), code,
requestId and details. Branch on code (StorefrontErrorCode lists the known ones; an
unknown code from a newer API is still a string) or on the subclasses:
| Class | When | Extra fields |
| ------------------------ | --------------------------- | -------------------------------------------------------------------------------------- |
| PriceChangedError | price_changed (409) | quote — the fresh quote at session creation, null at confirm; requiresNewSession |
| WrongRegionError | wrong_region (421) | region, baseUrl |
| RateLimitedError | rate_limited (429) | retryAfterSec, scope (key / ip / pms) |
| StorefrontTimeoutError | no answer in time | code: 'timeout' |
| StorefrontNetworkError | the request never completed | code: 'network' |
StorefrontConfigError (not a StorefrontError) means the SDK is set up wrong: a bad key, a bad
baseUrl, a hook outside its provider.
React
import {
SuiteOpProvider,
useListing,
useAvailability,
useQuote,
useCheckoutSession,
} from '@suiteop/storefront/react'
;<SuiteOpProvider client={storefront}>
<Listing id={id} />
</SuiteOpProvider>Hooks return AsyncState<T> (idle / loading / success / error). Identical concurrent
reads under one provider share one request, reused for 30 seconds; a read whose last consumer
unmounts is aborted. useCheckoutSession goes through the client's per-quote idempotency key,
so a double click opens one session, and exposes priceChanged when the price moved.
Components
@suiteop/storefront/components ships DateRangePicker, GuestPicker, Gallery and Map:
unstyled, no CSS, keyboard- and screen-reader-complete. Every element takes a class (className,
classNames) and exposes data-state. The only copy is English defaults in defaultLabels;
pass labels with your translations. Map loads no map library — it hands markers and bounds
to its children render prop so you can use MapLibre, Google Maps or anything else.
Checkout
The card form and booking confirmation run in the <suiteop-checkout> element, served by the
SuiteOp storefront host as a content-hashed script with a Subresource Integrity hash; card data
never touches your code or this package.
import { SuiteOpCheckout } from '@suiteop/storefront/react'
const session = await storefront.checkoutSessions.create({ quoteRef: quote.quoteRef })
;<SuiteOpCheckout
checkoutSessionId={session.id}
quoteRef={quote.quoteRef}
idempotencyKey={session.idempotencyKey}
onComplete={(booking) => showConfirmation(booking)}
onPriceChanged={() => requote()}
onError={({ code, requiresNewSession }) => showError(code, requiresNewSession)}
/>The publishable key and API host come from the <SuiteOpProvider> client. On a SuiteOp-hosted
site the bundle loads from the page's own origin; a self-hosted site passes its storefront host
as checkoutOrigin (e.g. https://<site>.suiteop.site). Without React, call
loadCheckout({ origin }) from @suiteop/storefront/checkout and render the element yourself;
outside a browser it resolves without doing anything. A bundle that cannot be loaded rejects
(or reaches onError) with checkout_unavailable.
Astro sites
// suiteop.config.ts — validated at build time; an invalid theme fails the build
import { defineSuiteOpConfig } from '@suiteop/storefront/config'
export default defineSuiteOpConfig({
brand: { name: 'Acme Stays', logoUrl: null, faviconUrl: null } /* … */,
})
// astro.config.mjs
import { suiteop } from '@suiteop/storefront/astro'
export default defineConfig({ integrations: [suiteop()] })suiteop() writes /_suiteop/manifest.json after every build: the prerendered pages (path,
language, title, last change — pages marked noindex are left out) and the on-demand routes that
expand from catalogue data (/listing/[id], /building/[id]). /_suiteop/* is reserved for the
platform; do not add pages under it.
On SuiteOp hosting, getSiteContext(Astro.locals) (@suiteop/storefront/astro) returns the
per-request site props the edge passes (publishable key, API bases; null elsewhere). The checkout
element loads with loadCheckout() (§Checkout).
Events
The platform requires three page events: page_view on every page, view_item on a listing and
begin_checkout on the checkout page. analyticsFor(publishableKey) returns the document's one
analytics instance (createAnalytics builds a separate one); it batches events and posts them,
with the key, to the SuiteOp edge's /_suiteop/events, filling each event's id, time, path and
referrer:
import { analyticsFor } from '@suiteop/storefront'
const analytics = analyticsFor(publishableKey)
analytics.pageView()
analytics.track({ name: 'view_item', listingId: listing.id })
analytics.track({ name: 'begin_checkout', checkoutSessionId: session.id })Under <SuiteOpProvider> the provider sends page_view itself, and useTrack() returns the same
instance's track. Off SuiteOp hosting the edge route is absent and the instance goes quiet.
Versioning
The public surface is the three entry points' exports plus the StorefrontError.code strings.
- Patch — fixes, no type change.
- Minor — additive: new methods, new optional fields, new error subclasses for an existing code. SuiteOp-hosted sites receive minors automatically, after their contract check passes.
- Major — any removal or rename, a new required input, a peer range change, or dropping a Storefront API version. Opt-in only.
Additive API changes do not break an installed SDK: status, reason and payment-provider values
it does not know yet are passed through as plain strings (typed Known | (string & {})), so
handle the unknown case in any switch. Each release also sends the Storefront API version it
was built against (X-SuiteOp-Api-Version); the API reserves that header for pinning older
clients to older behaviour in future, and does not act on it yet. The previous major receives security fixes
for 12 months after the next one ships. 0.x releases are pre-stable: a minor may break.
See CHANGELOG.md for each release.
