xStore có hai bộ máy storefront, và bạn chọn một:

Liquid (mặc định) Next.js (starter này)
Bạn viết theme Liquid trên trình quản trị một dự án Next.js của riêng bạn
Dữ liệu xStore đọc thẳng từ CSDL qua HTTP, API công khai /public/v1
Triển khai tự động, ngay khi lưu theme tải bản build lên xStore (chưa mở — xem §10)
Phù hợp khi muốn nhanh, không cần lập trình viên đã có đội frontend, muốn toàn quyền kiểm soát

Chọn Next.js không phải là cam kết di trú. Liquid vẫn được hỗ trợ đầy đủ, không có kế hoạch khai tử, và mọi tính năng mới của nền tảng vẫn về cả hai phía. Mỗi tên miền chọn bộ máy riêng, nên bạn có thể chạy Liquid ở shop.example.vn và thử Next.js ở beta.example.vn cùng lúc.

Trang API Storefront (đọc) liệt kê từng endpoint; trang SDK cho storefront tự dựng mô tả thư viện gọi chúng. Trang này nói về dự án mẫu ghép hai thứ đó lại thành một cửa hàng chạy được.


1. Starter là gì

xstore-next-starter là một cửa hàng hoàn chỉnh, không phải một bộ khung rỗng:

Trong dự án có sẵn hơn 600 test Vitest. Bạn sửa giao diện, chạy pnpm test, biết ngay mình có làm hỏng đường dây dữ liệu không.


2. Tạo khoá publishable

Vào trang quản trị: Quản trị → Cài đặt → Khoá API → Tạo khoá. Khoá có dạng pk_live_… và chỉ hiện đầy đủ đúng một lần — chép ngay khi tạo.

Khoá này công khai theo thiết kế, giống publishable key của Stripe: mọi thứ nó mở ra đều là dữ liệu công khai của cửa hàng (sản phẩm, danh mục, bài viết, giỏ hàng ẩn danh). Nó được nhúng thẳng vào bundle trình duyệt và điều đó là bình thường.

Thứ không bao giờ được nhúng vào bundle: JWT của khách hàng, token triển khai, và bất kỳ thông tin kết nối CSDL nào (starter không hề nhận thông tin CSDL — đó là ranh giới bảo mật của nền tảng).

Mất khoá thì thu hồi (revoke) hoặc xoay (rotate) trong cùng màn hình đó; khoá cũ ngừng hiệu lực ngay lập tức.


3. Cài đặt và chạy

git clone <địa-chỉ-repo-starter> my-shop
cd my-shop

cp .env.example .env.local
pnpm install
pnpm dev            # http://localhost:4308

.env.local:

XSTORE_API_URL=https://api.xstore.vn/public/v1
XSTORE_PUBLISHABLE_KEY=pk_live_…
XSTORE_SITE_URL=https://shop.example.vn

NEXT_PUBLIC_XSTORE_API_URL=https://api.xstore.vn/public/v1
NEXT_PUBLIC_XSTORE_PUBLISHABLE_KEY=pk_live_…

Năm biến trên là toàn bộ những gì tiến trình Node nhận được. XSTORE_SITE_URL là origin tuyệt đối của cửa hàng — canonical, sitemap và JSON-LD đều đọc từ đó, nên đặt sai sẽ khiến Google lập chỉ mục nhầm tên miền.

Yêu cầu môi trường. Node >= 20. pnpm 11 lại đòi Node >= 22.13, nên nếu bạn đang ở Node 20 hãy dùng pnpm@10 hoặc npm để cài; next build và test vẫn chạy bình thường.

Các lệnh khác:

pnpm build          # next build (output: "standalone")
pnpm start          # node .next/standalone/server.js
pnpm typecheck      # tsc --noEmit
pnpm test           # Vitest — chạy trên transport giả, không cần backend
pnpm test:e2e       # Playwright — cần cửa hàng thật, xem §8

4. Cấu trúc dự án

app/                  các route Next — mỗi route là một slot giao diện
  page.tsx                       home/index
  san-pham/                      product/index, product/detail
  danh-muc/[slug]/               product/category
  bo-suu-tap/[slug]/             product/collection-detail
  tim-kiem/                      product/search
  flash-sale/                    product/flash-sale
  bai-viet/                      cms/article-list, cms/article
  gio-hang/  thanh-toan/         cart, checkout
  dang-nhap/ dang-ky/            account/login, account/register
  tai-khoan/                     13 slot tài khoản
  [...path]/                     ĐỊNH TUYẾN ĐỘNG — mọi URL merchant tự đặt
  not-found.tsx                  product/not-found
  robots.ts  sitemap.xml/  sitemap/[section]/

components/           giao diện — bạn sửa nhiều nhất ở đây
  slots/              các slot dùng chung cho HAI lối vào (route tĩnh + [...path])
  layout/             header, footer, menu, giỏ hàng trên thanh, ô tìm kiếm
  product/ cart/ checkout/ account/ auth/ cms/

lib/xstore/            lớp tiếp xúc với SDK
  server.ts           serverClient() — client cho MỖI request (xem §5)
  browser.ts          client duy nhất phía trình duyệt
  config.ts           đọc biến môi trường
lib/format.ts         money(), formatDate()
lib/seo/              metadata, JSON-LD, sitemap XML

Các slot trong components/slots/ tồn tại vì một trang có thể vào bằng hai đường: route tĩnh (/san-pham/abc) và catch-all [...path] khi merchant đặt URL riêng. Cả hai render cùng một component, nên không thể lệch nhau.


5. Quy tắc bắt buộc: một client cho mỗi request

// ✅ đúng — trong mỗi Server Component / Server Action
import { serverClient } from "@/lib/xstore/server"

export default async function Page() {
  const xstore = await serverClient()
  const products = await xstore.products.list({ limit: 24 })
  …
}
// ❌ SAI — không bao giờ làm thế này trên server
const xstore = createXstoreClient({ … })   // ở phạm vi module

Lý do: một tiến trình SSR phục vụ nhiều khách hàng cùng lúc. createXstoreClient() giữ trạng thái phiên bên trong nó — JWT khách hàng, token giỏ hàng, mã giảm giá. Một client ở phạm vi module sẽ rò giỏ hàng và phiên đăng nhập của khách này sang khách khác, và triệu chứng là "thỉnh thoảng khách thấy đơn của người lạ" — hầu như không tái hiện được trên máy dev vì ở đó chỉ có một người dùng.

serverClient() đọc hai cookie và dựng một client mới mỗi lần được gọi:

Cookie httpOnly Chứa gì
xstore_customer có JWT của khách hàng
xstore_cart không token giỏ hàng

JWT khách hàng bắt buộc httpOnly: API khách hàng không có refresh token và không có endpoint thu hồi, nên một token JavaScript đọc được là chiếm quyền tài khoản vĩnh viễn. xstore_cart thì cố ý đọc được và cố ý trùng tên với bộ máy Liquid, để trong giai đoạn chuyển đổi khách vẫn giữ một giỏ hàng duy nhất giữa hai bộ máy.

Phía trình duyệt thì ngược lại: lib/xstore/browser.ts tạo đúng một client ở phạm vi module — một tab là một khách, nên ở đó singleton mới đúng.


6. url — đọc, không tự ghép

Mỗi thực thể (sản phẩm, danh mục, bài viết, trang CMS) mang cột url của riêng nó, và giá trị đó có thể là null một cách hợp lệ.

// ✅
<MaybeLink href={product.url}>{product.title}</MaybeLink>

// ❌ — đẹp, chạy được hôm nay, hỏng im lặng khi merchant đổi sơ đồ URL
<a href={"/san-pham/" + product.slug}>{product.title}</a>

<MaybeLink> (components/MaybeLink.tsx) trả <a> khi có url, trả <span> khi không. Nó là chỗ duy nhất trong starter quyết định điều đó. Đường dẫn dạng chuỗi chỉ được viết trong các tệp route ở app/ — nơi đó là URL của chính storefront, không phải URL của thực thể.

Hai quy tắc cùng loại, mỗi quy tắc đúng một hàm:

Quy tắc Hàm duy nhất
url dùng nguyên văn, hoặc hiện dạng chữ thường components/MaybeLink.tsx
Ảnh danh mục: cover_image_url ?? image_url (một cột, hai tên) lib/xstore/category.ts
Tiền: nhận cả string lẫn number lib/format.ts → money()

7. Tiền tệ đến theo hai kiểu

Pydantic tuần tự hoá Decimal thành chuỗi JSON. Vì vậy:

Trường Kiểu trên dây
ProductCard.price, .effective_price, .compare_at_price number | null
CartQuoteRead.subtotal, .total, mọi dòng thuế và giảm giá string
CustomerOrderRead.total string

Luôn dùng money() trong lib/format.ts — nó nhận cả hai kiểu, định dạng vi-VN, và trả "" cho null để sản phẩm chưa có giá hiện không có gì thay vì 0 ₫. Đừng gọi Number() lên một trường của API ở chỗ khác.

Ngày giờ cũng có bẫy tương tự: /public/v1 trả UTC không có hậu tố Z, mà JavaScript đọc một chuỗi không có offset là giờ địa phương. formatDate() đã xử lý; nếu bạn tự parse, nhớ thêm Z trước.


8. Giới hạn tần suất

Cửa sổ Hạn mức
60 giây 600 request
1 giờ 20 000 request

Hạn mức tính theo khoá publishable, không theo IP — một máy chủ SSR của tenant chỉ là một IP cho toàn bộ lưu lượng cửa hàng, nên tính theo IP sẽ vô nghĩa. Nếu request không kèm khoá, hạn mức tính theo tenant, tức là mọi thứ trỏ vào cửa hàng đó dùng chung một rổ.

Vượt hạn mức → HTTP 429, và SDK ném XstoreError với status = 429 và retryAfter (giây):

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

try {
  const page = await xstore.products.list({ limit: 24 })
} catch (e) {
  if (isXstoreError(e) && e.status === 429) {
    // e.retryAfter — số giây nên chờ
  }
  throw e
}

SDK cố ý KHÔNG tự thử lại. Một lần thử lại âm thầm bên trong lượt render SSR sẽ biến giới hạn tần suất thành timeout của cả trang: khách chờ, không có lỗi nào hiện ra, và trang chỉ đơn giản là không tải. Quyết định chờ hay hạ cấp là của bạn, ở chỗ bạn nhìn thấy được.

⚠️ Khi chạy bộ test Playwright, hãy để workers: 1. Một suite chạy song song có thể vét sạch hạn mức 600/phút, và triệu chứng không phải một trang 429 gọn gàng: nó là một loạt lỗi 500 rải rác trông chẳng liên quan gì, kể cả trên tệp CSS.


9. Những khoảng trống đã biết

Danh sách này có thật và được liệt kê đầy đủ, để bạn không mất một buổi đi tìm thứ không tồn tại. Starter không giả lập thứ nào trong số này.

Vận chuyển — không có endpoint danh sách phương thức. /public/shipping-methods chưa từng được cài đặt. Bộ máy Liquid cũng đang gửi danh sách rỗng trong môi trường thật kể từ ngày nó ra mắt. Starter làm đúng như vậy: checkout.placeOrder() chỉ gửi address và payment, không gửi khối shipping, và trong components/checkout/ có một khối chú thích chỉ rõ chỗ cắm bộ chọn khi endpoint xuất hiện. Đừng tự bịa một mức phí cố định — đó là tính sai cước cho khách hàng thật.

Mã giảm giá — không có endpoint "ví mã của tôi". /customer/coupons trả lịch sử mã đã dùng ({ id, coupon_code, used_at, order_id }), không phải danh sách mã còn dùng được. Không có endpoint nào liệt kê mã khả dụng; việc kiểm tra mã xảy ra bên trong cart.quote({ couponCode }), mỗi lần một mã do khách tự nhập. Vì vậy trang tài khoản trong starter có tiêu đề "Mã giảm giá đã dùng".

Giảm giá theo đơn hàng không đi vào đơn. Coupon và khuyến mãi cấp đơn hàng được tính trong cart.quote() nhưng không được ghi vào orders khi đặt hàng — discount_amount luôn bằng 0 và PlaceOrderInput không có trường mã giảm giá. Giảm giá cấp sản phẩm, điểm thưởng và giá flash sale thì có vào đơn. Starter vẫn báo giá kèm mã (để con số không nhảy giữa /gio-hang và /thanh-toan) và hiện một cảnh báo tiếng Việt cho khách thấy trước khi đặt. Hiện tổng đã giảm rồi thu đủ tiền là kết cục duy nhất không được phép xảy ra.

Chi tiết đơn hàng không có dòng sản phẩm. GET /customer/orders/{id} trả đúng mười trường vô hướng: id, code, current_status, total, subtotal, discount_total, shipping_total, currency, placed_at, note. Không có items, không có shipping_address. Theme Liquid cũng gọi đúng endpoint này nên khối order.items của nó cũng chưa từng hiển thị. Starter nói thẳng điều đó với khách thay vì để trống.

Bài viết: không có bình luận, không có view_count. /public/v1/articles/* không trả hai thứ này, và reviews.list() là API đánh giá theo thực thể, không phải bình luận bài viết. Starter bỏ hẳn hai khối đó kèm chú thích.

filters chỉ để hiển thị. GET /filters trả cấu hình facet (thương hiệu, thuộc tính, khoảng giá) mà merchant cấu hình trong trang quản trị, nhưng products.list() không nhận tham số facet nào. Không có tham số thương hiệu, thuộc tính, khoảng giá, kho hay tồn kho. Vì vậy FilterPanel trong starter render nhãn, không render ô tích — một ô tích không làm gì là thứ tệ hơn không có ô tích. Sắp xếp thì có thật: newest, price_asc, price_desc, và chỉ ba giá trị đó.

Bộ sưu tập: không có get(id). Module collections chỉ có list() và getBySlug(). Định tuyến động trả về id, nên starter phải quét một trang list({ limit: 200 }) để tìm. Cửa hàng có hơn 200 bộ sưu tập có thể sở hữu một URL bộ sưu tập giải được rồi 404.

Sitemap: số đếm và số mục có thể lệch. GET /sitemap đếm theo trạng thái, còn GET /sitemap/{section} đòi thêm url khác rỗng. Cửa hàng có sản phẩm chưa cấu hình url sẽ thấy section báo có N mục nhưng phục vụ 0 mục.

Không có danh mục tỉnh/phường công khai, nên ba ô địa chỉ trong starter là ô nhập tự do. Có danh mục hành chính phía sau trang quản trị, nhưng không endpoint /public/v1 nào mở nó ra, và nhúng cứng 63 tỉnh vào một starter thì lỗi thời ngay lần sáp nhập đơn vị hành chính kế tiếp.

Không có danh mục phương thức thanh toán. payment_method là chuỗi tự do. Starter đưa sẵn cod và bank_transfer; thêm VNPay là sửa một dòng trong components/checkout/PaymentMethodPicker.tsx.

Địa chỉ tiếng Việt có tên trường ngược trực giác: trong AddressInput, city là Tỉnh/Thành phố (bắt buộc) và province là Quận/Huyện (tuỳ chọn). Starter đặt nhãn tiếng Việt đúng và ánh xạ ở một chỗ duy nhất, có chú thích. Đừng "sửa" cho thuận mắt — hai bộ máy cùng ghi vào một bảng orders.


10. Triển khai — chưa mở

Đường ống tải bản build lên xStore để chạy chưa tồn tại. Hôm nay bạn dựng, chạy và kiểm thử starter trên hạ tầng của mình; khi đường ống hosting ra mắt, bạn sẽ tải chính bản next build này lên.

Trong dự án có tệp xstore.json mô tả bản build cho đường ống đó:

{
  "engine": "next",
  "engineVersion": "15",
  "nodeMajor": 20,
  "entrypoint": ".next/standalone/server.js",
  "requiredEnv": ["XSTORE_API_URL", "XSTORE_PUBLISHABLE_KEY", "XSTORE_SITE_URL"],
  "staticDir": ".next/static",
  "publicDir": "public"
}

⚠️ xstore.json là schema TẠM THỜI. Nó được viết trước khi đường ống hosting tồn tại, nên các khoá trong đó có thể đổi tên hoặc đổi cấu trúc khi đường ống ra mắt. Đừng xây công cụ nội bộ dựa trên tệp này như một hợp đồng đã chốt. Khi schema chốt, tài liệu này sẽ được cập nhật.


11. Kiểm thử

Starter có hai tầng test, cố ý tách bạch:

Vitest (pnpm test) — tầng bắt buộc. Chạy trên một Transport giả: mỗi test khai báo bản đồ đường-dẫn → dữ liệu, nên trang render dữ liệu cố định, không cần backend, không cần mạng, không cần cửa hàng mẫu. Một đường dẫn chưa khai báo fixture sẽ ném lỗi thay vì trả rỗng — để test gọi nhầm endpoint thì đỏ, chứ không âm thầm xanh với một trang trắng. Đây là tầng bạn chạy trước mỗi lần commit.

Playwright (pnpm test:e2e) — tầng tuỳ chọn. Chạy trình duyệt thật trên cửa hàng thật, nên nó đỏ khi merchant sửa dữ liệu, không chỉ khi mã hỏng. Chạy có chủ đích, đừng cắm vào CI chặn merge.

pnpm exec playwright install chromium
pnpm test:e2e

⚠️ Hai spec trong đó ghi vào cơ sở dữ liệu thật: purchase.spec.ts tạo đơn hàng thật và account.spec.ts tạo tài khoản khách thật. Cả hai bị khoá sau biến môi trường (E2E_ALLOW_ORDERS=1, E2E_ALLOW_ACCOUNTS=1) và mặc định bị bỏ qua. Chỉ bật khi bạn biết chắc XSTORE_API_URL đang trỏ vào cơ sở dữ liệu nào.

Kết quả Playwright ghi vào e2e/.results/ và thư mục đó đã nằm trong .gitignore — ảnh chụp màn hình của một lần chạy hỏng không nên nằm trong repo mãi mãi.