Checkout
Drop-in multi-step checkout UI for Next.js apps built on the Sazito SDK.
Overview
@sazito/checkout is a ready-made, fully typed checkout flow with built-in state management. It handles:
- Cart review (edit quantities, remove items, apply discount codes)
- Shipping address form with region / city cascading selects
- Shipping method selection (multiple groups supported)
- Payment method selection and gateway redirect
- Post-payment result screen (success, pending, failed, stock violated)
- Automatic pending-payment polling after gateway return
Steps run in order: cart → shipping → payment → result
The payment step is the last interactive step: its CTA (پایان خرید / "Finish purchase") finalizes the order directly — there is no separate review step.
Installation
pnpm add @sazito/client-sdk @sazito/checkout react@18 react-dom@18The checkout package requires Node.js 18 or newer. It ships ESM and CommonJS builds, bundled TypeScript declarations, and four public entry points:
| Entry point | Use case |
|---|---|
@sazito/checkout/core | Framework-independent engine, state, actions, selectors, events, and formatters. |
@sazito/checkout/react | CheckoutProvider, SazitoProvider, and checkout hooks. |
@sazito/checkout/next | Drop-in checkout page, rendered UI, hooks, and customizable primitives. |
@sazito/checkout/styles.css | Self-contained RTL/LTR styles and theme variables. |
Import the stylesheet once — typically in your root layout:
import '@sazito/checkout/styles.css';Setup
Create the Sazito SDK once and wrap your app (or checkout route) with SazitoProvider. Every SazitoCheckoutPage inside reads that same SDK client from context.
// app/providers.tsx
'use client';
import { createSazitoClient } from '@sazito/client-sdk';
import { SazitoProvider } from '@sazito/checkout/next';
const sazito = createSazitoClient({ domain: process.env.NEXT_PUBLIC_SAZITO_DOMAIN! });
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SazitoProvider client={sazito}>
{children}
</SazitoProvider>
);
}Basic usage
With SazitoProvider in place, the checkout page only needs credentials and config:
'use client';
import { SazitoCheckoutPage } from '@sazito/checkout/next';
export default function CheckoutPage() {
return (
<SazitoCheckoutPage
credentials={{ cart: { identifier: '<cart-identifier>' } }}
config={{
locale: 'fa',
continueShoppingUrl: '/',
theme: { accent: '#4f46e5', radius: 16 },
}}
/>
);
}Props reference
SazitoProvider
Wraps the app once and exposes the existing SDK client to every checkout below
it. Create the client with createSazitoClient; the provider does not create a
client from a domain.
| Prop | Type | Description |
|---|---|---|
client | SazitoClient | Required pre-built SDK client. Checkout reuses this client's credentials and configuration. |
children | ReactNode | Application or checkout subtree. |
SazitoCheckoutPage
| Prop | Type | Description |
|---|---|---|
credentials | CheckoutCredentials | Cart (and optionally invoice) identifiers to attach to. |
config | CheckoutConfig | Locale, theme, URLs, event hook, behaviour flags. |
paymentReturnParams | Record<string, string> | Query params from a returning payment gateway. Triggers result-resolution mode instead of bootstrapping a fresh flow. |
className | string | Extra class on the root element. |
renderNextButton | (props: RenderButtonProps) => ReactNode | Replace the continue / next button across all steps. |
renderBackButton | (props: RenderButtonProps) => ReactNode | Replace the back button across all steps. |
renderEmptyCart | (props: RenderEmptyCartProps) => ReactNode | Replace the empty-cart screen. Receives continueShoppingUrl. |
CheckoutCredentials
interface CheckoutCredentials {
cart?: { id?: number; identifier: string };
invoice?: { id: number; identifier: string };
}Typically only cart.identifier is needed. Supply invoice to re-attach to an already-created invoice (e.g. returning from a gateway).
CheckoutConfig
| Field | Type | Default | Description |
|---|---|---|---|
locale | 'fa' | 'en' | 'fa' | UI language. Controls all labels, RTL/LTR layout, and number formatting. |
direction | 'rtl' | 'ltr' | derived from locale | Override text direction explicitly. |
theme | CheckoutTheme | — | Visual overrides — see Theming. |
continueShoppingUrl | string | — | URL for the back button on the cart step and the "continue shopping" link on the result screen. |
returnUrl | string | current URL | URL the payment gateway redirects to. |
pollIntervalMs | number | 15000 | How often (ms) to poll for a pending payment result after gateway return. |
currencyLabel | string | locale default | Override the currency unit label (e.g. 'تومان'). |
onEvent | (event: CheckoutEvent) => void | — | Analytics hook — called for every checkout lifecycle event. See Analytics. |
Theming
All visual tokens are CSS custom properties. Pass a theme object to override them:
config={{
theme: {
accent: '#7c3aed', // primary colour — buttons, active states, selected rows
accentForeground: '#ffffff', // text rendered on top of accent
accentSoft: '#ede9fe', // subtle accent tint — hover, selection background
background: '#ffffff', // page background
foreground: '#0f172a', // primary text
muted: '#f1f5f9', // muted surface background
mutedForeground: '#64748b', // secondary text
border: '#e2e8f0', // borders and dividers
card: '#ffffff', // form, product card, shipping card background
summaryBackground: '#f8fafc', // order-summary sidebar background
danger: '#ef4444', // error and destructive states
success: '#22c55e', // success and completed states
successForeground: '#ffffff', // content on solid success surfaces
logoBackground: '#ffffff', // plate behind shipping-provider logos
shippingNeutral: '#f1f5f9', // fallback surface for white shipping colors
shippingNeutralForeground: '#475569', // content on that neutral surface
radius: 12, // base border-radius in px (scaled per element)
fontFamily: 'inherit', // font — defaults to host page font
},
}}Reuse your application's design tokens
Theme colors and fontFamily accept any valid CSS value, including references
to variables already defined by the host application. This is the recommended
way to connect checkout to an existing design system:
<SazitoCheckoutPage
credentials={{ cart: { identifier: '<cart-identifier>' } }}
config={{
locale: 'fa',
theme: {
accent: 'var(--color-primary)',
accentForeground: 'var(--color-on-primary)',
accentSoft: 'var(--color-primary-soft)',
background: 'var(--color-background)',
foreground: 'var(--color-foreground)',
muted: 'var(--color-muted)',
mutedForeground: 'var(--color-muted-foreground)',
border: 'var(--color-border)',
card: 'var(--color-card)',
summaryBackground: 'var(--color-surface)',
danger: 'var(--color-danger)',
success: 'var(--color-success)',
successForeground: 'var(--color-on-success)',
logoBackground: 'var(--color-logo-plate)',
shippingNeutral: 'var(--color-muted)',
shippingNeutralForeground: 'var(--color-muted-foreground)',
fontFamily: 'var(--font-sans)',
radius: 12,
},
}}
/>CSS fallbacks work as usual—for example,
accent: 'var(--color-primary, #4f46e5)'. The host variables must be defined
on :root or another ancestor of the checkout. When a light/dark theme changes
their values, checkout updates automatically without a React rerender:
:root {
--color-primary: #6d28d9;
--color-on-primary: #ffffff;
--color-background: #ffffff;
--color-foreground: #18181b;
--color-card: #ffffff;
--color-border: #e4e4e7;
}
[data-theme='dark'] {
--color-background: #09090b;
--color-foreground: #fafafa;
--color-card: #111113;
--color-border: #27272a;
}CSS-only token mapping
For a stylesheet-driven integration, add a class to the checkout and map its
native variables. Import these overrides after @sazito/checkout/styles.css:
<SazitoCheckoutPage
className="store-checkout"
credentials={{ cart: { identifier: '<cart-identifier>' } }}
/>.szc-root.store-checkout {
--szc-accent: var(--color-primary);
--szc-accent-foreground: var(--color-on-primary);
--szc-bg: var(--color-background);
--szc-fg: var(--color-foreground);
--szc-muted: var(--color-muted);
--szc-muted-fg: var(--color-muted-foreground);
--szc-border: var(--color-border);
--szc-card: var(--color-card);
--szc-summary-bg: var(--color-surface);
--szc-danger: var(--color-danger);
--szc-success: var(--color-success);
--szc-font: var(--font-sans);
--szc-radius: var(--radius-lg);
}Do not set the same token through both approaches. Values supplied through
config.theme are written as inline --szc-* properties and take precedence
over ordinary stylesheet declarations. The CSS-only approach is also required
when radius must reference a CSS variable, because config.theme.radius
accepts a number in pixels.
Payment gateway return
When a gateway redirects back, render SazitoCheckoutPage with paymentReturnParams. The checkout auto-resolves the outcome and shows the result screen.
// app/checkout/return/page.tsx
import { SazitoCheckoutPage } from '@sazito/checkout/next';
export default function ReturnPage({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
return (
<SazitoCheckoutPage
credentials={{ cart: { identifier: '<cart-identifier>' } }}
config={{ locale: 'fa', returnUrl: '/checkout/return' }}
paymentReturnParams={searchParams}
/>
);
}If the gateway returns status: 'pending', the checkout polls every pollIntervalMs until it resolves.
Analytics
Pass onEvent to receive structured events at every step. Use it to send to any analytics provider:
config={{
onEvent(event) {
analytics.track(event.name, {
step: event.step,
value: event.value, // order total in Rials where applicable
...event.metadata,
timestamp: event.timestamp,
});
},
}}Event names
| Event | Fired when |
|---|---|
checkout_viewed | Checkout mounts |
step_viewed | User lands on a step |
address_submitted | Shipping address saved |
shipping_rate_selected | User picks a shipping method |
discount_applied | Discount code accepted |
discount_removed | Discount code removed |
payment_method_selected | User picks a payment gateway |
payment_initiated | Order placed, gateway redirect started |
payment_succeeded | Gateway return resolved as success |
payment_failed | Gateway return resolved as failed |
payment_pending | Gateway return resolved as pending (polling starts) |
Validation gates
The next button disables automatically — no configuration needed:
| Step | Disabled when |
|---|---|
| Cart | Cart is empty |
| Shipping | Shipping rates loaded but no rate selected (skipped for digital-only orders) |
| Payment | No payment methods available, or none selected |
Replacing the empty-cart screen
Use renderEmptyCart when the storefront has its own empty-state component:
<SazitoCheckoutPage
credentials={{ cart: { identifier: '<cart-identifier>' } }}
config={{ locale: 'fa', continueShoppingUrl: '/products' }}
renderEmptyCart={({ continueShoppingUrl }) => (
<EmptyCart
actionHref={continueShoppingUrl ?? '/products'}
actionLabel="Browse products"
/>
)}
/>Replacing action buttons
Use renderNextButton / renderBackButton to swap the built-in action buttons for components from your own design system. Both receive pre-computed checkout state. The next button renders under the order summary on desktop and inside the sticky bottom bar (next to the payable total) on mobile; the back button renders at the end of the step content.
import type { RenderButtonProps } from '@sazito/checkout/next';
<SazitoCheckoutPage
credentials={{ cart: { identifier: '<cart-identifier>' } }}
config={{ locale: 'fa' }}
renderNextButton={({ loading, disabled, onClick, children }) => (
<MyButton loading={loading} disabled={disabled} onClick={onClick}>
{children}
</MyButton>
)}
renderBackButton={({ onClick, children }) => (
<MyGhostButton onClick={onClick}>{children}</MyGhostButton>
)}
/>RenderButtonProps
| Prop | Type | Description |
|---|---|---|
loading | boolean | Action is in-flight — show a spinner |
disabled | boolean | Validation not met — button should be inert |
onClick | () => void | Triggers the checkout action for this step |
children | ReactNode | Localised label for the current step |
The children label changes per step: ادامه, ذخیره اطلاعات ارسال, ادامه به پرداخت, نهایی کردن سفارش (Persian) or the equivalent English strings.
Replacing primitives with asChild
All built-in primitives accept an asChild prop. When set, the component merges its behaviour props onto your single child element instead of rendering its own DOM node — the same pattern used by Radix UI and shadcn/ui.
Class names are merged (primitive class + child class). Event handlers are composed (both called).
Button
import { Button } from '@sazito/checkout/next';
<Button asChild loading={isLoading} disabled={isDisabled} onClick={handleClick}>
<MyButton variant="filled">Continue</MyButton>
</Button>Props forwarded: className, disabled, aria-busy, onClick, and all standard button attributes.
Field
Keeps the label, optional marker, and error message. Swaps the <input> element.
import { Field } from '@sazito/checkout/next';
<Field label="Email" id="email" error={errors.email} asChild>
<MyInput placeholder="you@example.com" />
</Field>Spinner
import { Spinner } from '@sazito/checkout/next';
<Spinner asChild>
<MyLoadingIndicator />
</Spinner>ErrorBanner
Merges role="alert" and class onto your component.
import { ErrorBanner } from '@sazito/checkout/next';
<ErrorBanner message={errorMessage} asChild>
<MyAlert />
</ErrorBanner>SectionTitle
Swaps the default <h3> for any heading element or typography component.
import { SectionTitle } from '@sazito/checkout/next';
<SectionTitle asChild>
<h2 className="text-xl font-bold">Payment method</h2>
</SectionTitle>ProductPlaceholder
Replaces the default box icon shown when a product has no image.
import { ProductPlaceholder } from '@sazito/checkout/next';
<ProductPlaceholder asChild>
<img src="/fallback-product.png" alt="" />
</ProductPlaceholder>Programmatic control with useCheckout
Use useCheckout inside any component rendered within CheckoutProvider to read state or trigger actions directly.
'use client';
import { useCheckout } from '@sazito/checkout/next';
export function DiscountWidget() {
const { state, actions } = useCheckout();
return (
<div>
<input
value={state.discountCode}
onChange={(e) => actions.setDiscountCode(e.target.value)}
/>
<button
onClick={() => actions.applyDiscount()}
disabled={state.flags.applyingDiscount}
>
Apply
</button>
{/* Invalid/expired codes set an inline error, not the global banner. */}
{state.discountError ? <p role="alert">{state.discountError}</p> : null}
{state.appliedDiscountCode ? (
<button onClick={() => actions.removeDiscount()}>
Remove {state.appliedDiscountCode}
</button>
) : null}
</div>
);
}CheckoutState fields
| Field | Type | Description |
|---|---|---|
step | CheckoutStep | Active step: cart | shipping | payment | result |
status | CheckoutStatus | Coarse engine status: idle | bootstrapping | working | redirecting | polling | error |
locale | 'fa' | 'en' | Active label and number-formatting locale |
direction | 'rtl' | 'ltr' | Active layout direction |
cart | Cart | null | Current cart |
invoice | Invoice | null | Current invoice |
regions | CheckoutRegion[] | Regions and nested cities used by the address form |
addressForm | AddressFormValues | Live shipping address form values |
postalCodeMandatory | boolean | Whether shop settings require a postal code |
emailMandatory | boolean | Whether shop settings require an email address |
addressDirty | boolean | Whether the form differs from the saved invoice address |
applicable | ApplicableShippingMethods | null | Raw invoice-specific shipping response |
shippingGroups | ShippingGroup[] | Grouped shipping rates for each item bundle |
paymentMethods | PaymentMethod[] | Available payment gateways |
selectedPaymentMethodId | number | null | Selected gateway |
discountCode | string | Input value of the discount field |
appliedDiscountCode | string | null | Successfully applied code |
appliedDiscount | AppliedDiscount | null | Classified percentage, fixed-amount, or free-shipping discount and its savings |
discountError | string | null | Inline error for the discount field (e.g. invalid code); cleared on edit/apply/remove |
result | CheckoutResult | null | Post-payment result (status, order, message) |
error | CheckoutError | null | Last error with message, code, step |
flags | CheckoutFlags | Granular async flags (see below) |
CheckoutFlags
| Flag | Fires during |
|---|---|
bootstrapping | Initial cart + invoice load |
updatingCart | Quantity change or item remove |
savingAddress | Shipping form submit |
loadingShipping | Fetching applicable rates after address save |
selectingRate | Selecting a shipping rate |
applyingDiscount | Discount code submission |
loadingPayments | Fetching payment methods |
placingOrder | Order placement + gateway redirect |
CheckoutActions
| Method | Description |
|---|---|
start() | Bootstrap cart and invoice. Called automatically. |
next() | Advance to the next step (validates current step). |
back() | Return to the previous step. |
goToStep(step) | Jump to any step directly. |
updateItemQuantity(cartProductId, variantId, quantity) | Update a cart line quantity. |
removeItem(cartProductId, variantId) | Remove a cart line. |
setAddressField(key, value) | Update a single address form field. |
submitAddress() | Save address and load applicable shipping rates. |
selectShippingRate(groupKey, rateId) | Select a rate within a shipping group. |
setDiscountCode(code) | Update the discount code input. |
applyDiscount() | Submit the discount code. |
removeDiscount() | Remove the applied discount. |
selectPaymentMethod(id) | Select a payment method. |
placeOrder() | Place the order and redirect to the gateway. |
resolvePaymentReturn(params) | Resolve a return from the payment gateway. |
reset() | Reset checkout state to initial. |
Using SazitoCheckout directly
SazitoCheckoutPage bundles the provider and UI. If you already have a
CheckoutProvider in your tree, render SazitoCheckout directly:
import {
CheckoutProvider,
SazitoCheckout,
} from '@sazito/checkout/next';
// SazitoProvider must still wrap the tree above this component.
export default function CheckoutPage() {
return (
<CheckoutProvider
credentials={{ cart: { identifier: '<cart-identifier>' } }}
config={{ locale: 'fa' }}
>
<SazitoCheckout className="my-checkout" />
</CheckoutProvider>
);
}SazitoCheckout includes the stepper, active checkout step, footer actions,
and order summary. Use SazitoCheckoutPage unless you need to place the
provider and UI separately.
Error codes
CheckoutError.code gives a machine-readable reason:
| Code | Meaning |
|---|---|
no_cart | Cart identifier not found |
no_invoice | Invoice could not be created or found |
min_basket | Cart total below store minimum |
rate_limited | Too many requests |
cart_invalid | Cart is empty or contains invalid items |
invoice_locked | Invoice is locked (already placed or expired) |
stock_violated | One or more items went out of stock during checkout |
shipping_required | Cart requires shipping but no address or rate provided |
address_required | Shipping address missing |
payment_failed | Gateway reported payment failure |
network | Network or server error |
validation | Invalid input sent to the API |
unknown | Unclassified error |