Trang API Storefront (đọc) liệt kê từng endpoint. Trang này nói về thư viện chính thức gọi chúng: @xstore/storefront-sdk.

Bạn không bắt buộc phải dùng nó — fetch thẳng vẫn chạy. Nhưng thư viện đã lo sẵn những thứ dễ làm sai: header khoá công khai, phong bì phân trang, lớp lỗi có mã trạng thái, phiên đăng nhập của khách và việc mỗi yêu cầu phải có một client riêng khi dựng trang phía máy chủ.

Đây là cùng một thư viện chạy trên theme Liquid dưới tên window.xStore. Tên module và tên phương thức giống hệt nhau, nên bảng tra cứu ở tab xStore JS áp dụng cho cả hai: xStore.products.list() và xstore.products.list() là cùng một hàm.

Cài đặt

npm install @xstore/storefront-sdk
# hoặc: pnpm add @xstore/storefront-sdk

Gói không có phụ thuộc lúc chạy. Cài một gói là được một gói — không kéo theo thứ gì vào bundle React Native của bạn. Gói viết bằng TypeScript và đã kèm sẵn khai báo kiểu; không cần cài thêm @types/….

createXstoreClient() — và vì sao nó là hàm khởi tạo, không phải singleton

import { createXstoreClient } from "@xstore/storefront-sdk"

const xstore = createXstoreClient({
  baseUrl: "https://api.xstore.vn/public/v1",
  publishableKey: process.env.XSTORE_PUBLISHABLE_KEY,
})

Đây là lỗi mà một storefront Next.js dễ mắc nhất:

// ❌ SAI — đừng làm thế này trong Next.js
// lib/xstore.ts
export const xstore = createXstoreClient({ publishableKey: KEY })

Một tiến trình Next trên máy chủ phục vụ nhiều khách cùng lúc. Client giữ phiên đăng nhập của khách: token khách hàng, mã giỏ hàng, mã giảm giá đang áp. Đặt client ở phạm vi module nghĩa là hai khách đang mua hàng cùng lúc dùng chung một phiên — khách A đăng nhập, khách B tải lại trang và nhìn thấy đơn hàng của khách A. Lỗi này không bao giờ xuất hiện khi chạy next dev một mình.

// ✅ ĐÚNG — mỗi yêu cầu một client
// lib/xstore.ts
import { cookies } from "next/headers"
import { createXstoreClient } from "@xstore/storefront-sdk"

export function xstoreForRequest() {
  return createXstoreClient({
    baseUrl: process.env.XSTORE_API_URL,
    publishableKey: process.env.XSTORE_PUBLISHABLE_KEY,
    // Token đọc từ cookie của CHÍNH yêu cầu này.
    authToken: cookies().get("xstore_customer_token")?.value ?? null,
  })
}

Với dữ liệu công khai thuần tuý (danh sách sản phẩm, menu) thì tạo client mỗi lần gọi cũng rẻ: nó chỉ là một đối tượng, không mở kết nối nào.

Trên trình duyệt thì ngược lại — một tab là một khách — nên tệp xstore-sdk.js của theme Liquid tạo sẵn một thể hiện và gắn vào window.xStore. Đó là chỗ duy nhất singleton là đúng.

Khoá công khai

createXstoreClient({ publishableKey: "pk_live_..." })   // gửi thành header X-Xstore-Key

Khoá tạo trong trang quản trị Cài đặt → Khoá API. Khoá công khai theo thiết kế: mọi thứ nó mở ra đều đã là dữ liệu công khai, và bản dựng của bạn sẽ gửi nó tới từng khách truy cập. Đừng cố giấu nó, đừng dựng proxy chỉ để che nó. Khoá bị lạm dụng thì thu hồi và tạo khoá mới.

Khoá cũng là thứ xác định cửa hàng, nên ứng dụng di động gọi thẳng api.xstore.vn được mà không cần header Host.

Giới hạn nhịp gọi tính theo khoá, không theo địa chỉ IP — vì cả một máy chủ SSR của bạn chỉ là một IP đối với xStore.

Next.js

Thư viện không tự ý quyết định chiến lược cache. Nó chuyển thẳng defaultInit (và tham số init của từng lời gọi) xuống fetch, nên chỉ thị cache của Next dùng như bình thường:

// app/san-pham/[slug]/page.tsx
import { notFound } from "next/navigation"
import { createXstoreClient, isXstoreError } from "@xstore/storefront-sdk"

export default async function ProductPage({ params }: { params: { slug: string } }) {
  // Vẫn là một client MỚI cho mỗi lần dựng trang.
  const xstore = createXstoreClient({
    baseUrl: process.env.XSTORE_API_URL,
    publishableKey: process.env.XSTORE_PUBLISHABLE_KEY,
    // Áp cho MỌI lời gọi của client này.
    defaultInit: { next: { revalidate: 60 } } as RequestInit,
  })

  try {
    const product = await xstore.products.getBySlug(params.slug)
    return <h1>{product.title}</h1>
  } catch (err) {
    if (isXstoreError(err) && err.status === 404) notFound()
    throw err
  }
}

Một lời gọi riêng lẻ vẫn ghi đè được:

// Giá và tồn kho thì đừng cache.
await xstore.cart.quote({}, { cache: "no-store" } as RequestInit)

Đường dẫn do chủ cửa hàng tự đặt

Chủ cửa hàng có thể gán đường dẫn riêng cho bất kỳ nội dung nào (/khuyen-mai-tet trỏ tới một danh mục). Những đường dẫn đó không khớp route tĩnh nào của bạn, nên hãy hỏi máy chủ trước khi trả 404:

// app/not-found.tsx  (hoặc một catch-all route)
const match = await xstore.routes.resolve(pathname)   // { path, target_type, target_id }

Máy chủ có cache âm: một đường dẫn vừa tạo vẫn có thể trượt trong ít phút. Đó là hành vi đúng, không phải lý do để gọi lại trong vòng lặp.

React Native

Không có localStorage trên React Native, nên hãy truyền bộ nhớ của riêng bạn. Mọi phương thức của TokenStorage đều được phép trả về Promise — thư viện luôn await chúng:

import * as SecureStore from "expo-secure-store"
import { createXstoreClient, type TokenStorage } from "@xstore/storefront-sdk"

const secureStore: TokenStorage = {
  get:    (key) => SecureStore.getItemAsync(key),
  set:    (key, value) => SecureStore.setItemAsync(key, value),
  remove: (key) => SecureStore.deleteItemAsync(key),
}

export const xstore = createXstoreClient({
  baseUrl: "https://api.xstore.vn/public/v1",
  publishableKey: process.env.EXPO_PUBLIC_XSTORE_KEY,
  storage: secureStore,
})

Ở đây một thể hiện dùng chung cho cả ứng dụng là đúng: một thiết bị là một khách.

Vì bộ nhớ có thể bất đồng bộ, vài hàm vốn đồng bộ trên theme Liquid lại trả Promise trong gói này:

Trên window.xStore (theme) Trong @xstore/storefront-sdk
xStore.auth.isLoggedIn() → boolean await xstore.auth.isLoggedIn() → Promise<boolean>
xStore.cart.getToken() → string | null await xstore.cart.getToken() → Promise<string | null>

Thư viện không chạm vào window, document hay localStorage ở bất kỳ đâu ngoài bản dựng dành riêng cho trình duyệt — nên nó nạp được trong Metro, trong Node và trong Web Worker mà không cần polyfill.

Phân trang

Mọi endpoint danh sách trả về cùng một phong bì:

{ "items": [ ... ], "next_cursor": "2026-08-01 12:00:00|p001", "has_more": true }

Con trỏ là chuỗi mờ. Gửi lại nguyên văn next_cursor; đừng tách nó ra, đừng tự dựng một con trỏ, đừng đổi nó thành số trang. Có endpoint dùng keyset, có endpoint (tìm kiếm) dùng offset — hình dạng bên trong là chuyện của máy chủ và có thể đổi.

Cần duyệt hết thì dùng paginate() / collect():

import { collect, paginate } from "@xstore/storefront-sdk"

// sitemap.xml: cần toàn bộ, nên collect().
const urls = await collect((cursor) =>
  xstore.sitemap.section("products", { cursor, limit: 500 }))

// Xử lý dần từng phần tử, không giữ hết trong bộ nhớ.
for await (const product of paginate((cursor) => xstore.products.list({ cursor, limit: 100 }))) {
  console.log(product.title)
}

Cả hai đều dừng ở maxPages (mặc định 1000) và ném lỗi nếu máy chủ trả lại một con trỏ đã trả rồi — nếu không, một truy vấn keyset xếp sai thứ tự sẽ thành vòng lặp vô tận ngay giữa lúc dựng trang.

collect() dành cho sitemap và feed, nơi thật sự cần cả tập. Đừng dùng nó cho lưới sản phẩm.

Lỗi

Mọi phản hồi không phải 2xx đều ném ra XstoreError:

import { isXstoreError } from "@xstore/storefront-sdk"

try {
  await xstore.products.getBySlug(slug)
} catch (err) {
  if (!isXstoreError(err)) throw err

  err.status        // 404, 401, 429, …
  err.detail        // thân phản hồi của FastAPI, đã được làm phẳng
  err.retryAfter    // số giây, CHỈ có ở 429
  err.isRateLimited // err.status === 429
}

Thư viện không tự thử lại. Đó là lựa chọn có chủ ý: một lần thử lại âm thầm bên trong quá trình dựng trang phía máy chủ biến một lần chạm giới hạn nhịp gọi thành một trang treo — khách chờ hai lần thời gian chờ mạng rồi nhận lỗi 500, thay vì nhận trang lỗi ngay lập tức. Bạn quyết định: chờ retryAfter giây trong một tác vụ nền thì hợp lý; chờ trong lúc dựng trang thì không.

Phiên khách hàng

Đăng nhập khách hàng dùng một JWT duy nhất, KHÔNG có refresh token:

await xstore.auth.login({ email, password })   // token được lưu vào storage
await xstore.auth.isLoggedIn()                 // chỉ kiểm tra có token, không xác thực token
await xstore.auth.logout()                     // xoá phiên cục bộ

Khi một lời gọi có xác thực trả về 401, thư viện xoá token đang lưu rồi phát sự kiện auth:logout. Nó không tự đăng nhập lại và không tự chuyển hướng — ứng dụng của bạn quyết định điều đó:

xstore.on("auth:logout", () => {
  router.push("/dang-nhap")
})

Không có endpoint thu hồi token phía máy chủ: logout() chỉ xoá token ở phía bạn, và JWT vẫn có hiệu lực cho tới khi hết hạn. Đó là lý do token có thời hạn ngắn và không mang theo gì nhạy cảm.

Giỏ hàng của khách vãng lai tự gộp vào tài khoản sau khi đăng nhập thành công — bạn không phải gọi cart.merge() bằng tay.

Vận chuyển và thanh toán

Module shipping báo giá theo tier (instant | same_day | standard | pickup), và trả về danh sách tỉnh/phường/điểm nhận hàng — dùng cho biểu mẫu địa chỉ và bước chọn gói vận chuyển:

const rates = await xstore.shipping.rates(
  { province_code, ward_code, subtotal },
  { cartToken },   // BẮT BUỘC — thiếu nó báo giá bỏ qua bộ máy khuyến mãi
)

checkout.setShipping() nhận shipping_tier, không còn shipping_amount: máy chủ tính phí, client không tự đặt giá.

Luồng đầy đủ từ đặt hàng tới xác nhận thanh toán là placeOrder → pay → redirect (hoặc không) → paymentReturn:

// 1. Tạo đơn từ giỏ hàng.
const order = await xstore.checkout.placeOrder(cartToken, { address, shipping, payment })

// 2. Khởi tạo thanh toán ở cổng CHO ĐƠN VỪA TẠO — nhận cartToken, không phải
//    order_id, vì route này không yêu cầu đăng nhập.
const pay = await xstore.checkout.pay(cartToken)

// redirect_url là null với cổng không cần chuyển hướng (COD) — luôn kiểm tra
// trước khi điều hướng.
if (pay.redirect_url) {
  window.location.href = pay.redirect_url
}

Trên trang khách quay về sau khi rời cổng thanh toán:

const result = await xstore.checkout.paymentReturn({
  query_string: window.location.search.slice(1),   // NGUYÊN VĂN, không tự parse
})

paymentReturn không ghi gì. Nó chỉ đọc lại trạng thái cho trang kết quả hiển thị — việc chốt đơn thuộc về IPN máy-tới-máy của cổng, chạy độc lập và có thể tới trước hoặc sau khi khách bấm quay lại. Vì vậy settled: false không phải là thất bại: nó chỉ nghĩa là IPN chưa tới. Diễn giải nó thành "thanh toán thất bại" là nói với một khách đã trả tiền rằng họ chưa trả — đọc status (succeeded | pending | failed | cancelled | unknown) để biết cổng thực sự trả lời gì, và hiển thị "đang xác nhận" thay vì đoán khi chưa settled.

checkout.paymentMethods() trả về các cổng tenant này đã bật, để dựng giao diện chọn — đừng viết cứng danh sách cổng trong ứng dụng của bạn.

Tra cứu đầy đủ

Chữ ký của toàn bộ phương thức nằm ở tab xStore JS, một trang cho mỗi module (bao gồm shipping và ba phương thức mới của checkout: paymentMethods, pay, paymentReturn). Danh sách endpoint thô nằm ở API Storefront (đọc).