If you sell anything through Tebex (FiveM scripts, Minecraft ranks, Rust kits, game assets), you have two choices. You can use the hosted store page, or you can build your own on the Headless API.
The hosted page works, but it looks like Tebex. The Headless API lets the store look like yours. The catch is that once you choose it, you have to build everything yourself: the catalogue, the cart, account linking, the checkout handoff, discount codes, SEO, dark mode. That's a whole e-commerce frontend.
So I built that part and released it.
Headless Kit is a complete storefront for the Tebex Headless API. You point it at your store, edit one config file, and deploy to Cloudflare Workers.
🛒 Live demo: headless-kit.404development.com
💻 Source: github.com/404-Mods/Headless-Kit
Why I built it
At 404 Development we build mods, scripts, tools and gameplay systems for games and the communities around them. Our motto is "We build what's missing."
When we needed a store, Tebex was the obvious choice. A large share of game creators already sell through it: FiveM and Minecraft servers, Rust communities, people selling assets. The hosted store page worked fine, but it looked like Tebex's store, not ours. So I opened the Headless API docs and thought: how hard can it be? It's a product grid and a checkout button.
The product grid took an afternoon.
Then I started testing it like a real customer, and the problems kept coming:
- The "Added to cart!" message was lying. Tebex makes you link a game account before you can add anything. A shopper would click Add, see a success toast, get sent off to link their account, and come back to an empty cart.
- The sale badges were wrong. A $5.25 discount showed as "5.25% OFF", because Tebex's discount field is an amount, not a percentage.
- Deploys broke without warning. One page fetched the catalogue at build time, the build environment had no token, and the whole build failed.
- Cloudflare threw errors I'd never seen before, like Could not find compiled Open Next config.
None of these problems was hard to fix. What bothered me was that none of them had anything to do with our store. Every creator who chooses the Headless API will hit these same walls, one at a time, on their own. And most of them want to sell their work, not debug basket state at 2am.
So instead of just finishing our store, I stepped back and turned it into something anyone could use. I set two rules:
- It has to run before you configure anything. Clone it, run it, click through the whole shop, and only then go find your API token.
- Rebranding means changing values, not rewriting components.
What's in the box
- Catalogue: categories and subcategories, search, six sort orders, a price cap, and an on-sale filter
- Cart: a basket that persists, quantity steppers, optimistic updates, and a nudge for abandoned carts
- Discount codes: coupons, gift cards and creator codes, with the discount shown separately in the totals
- Account linking: Tebex's full game-account flow, including finishing the add after the redirect back
- Wishlist + recently viewed: stays in sync across browser tabs
- Reviews: star ratings on Cloudflare D1, optional and off by default
- Theming: light, dark and system modes. Nine tokens re-theme the whole store, logo included
- SEO: per-product metadata, social cards, sitemap.xml, robots.txt
- Accessibility: contrast checked against WCAG AA on the main flows, in both themes
Stack: Next.js 16 (App Router) · React 19 · Tailwind v4 · OpenNext on Cloudflare Workers · D1 · Vitest
How it works: the interesting parts
A storefront is mostly a list of edge cases. These are the ones worth writing about.
1. The token never reaches the browser
Every Tebex call runs on the server. The browser only talks to a small set of proxy routes under /api/basket/*, and those routes call Tebex.
Catalogue data is the same for every visitor, so it's cached for five minutes and tagged. Basket data belongs to one user, so it's never cached:
const catalogCache = {${BASE}/accounts/${token()}/packages
next: { revalidate: 300, tags: ["tebex-catalog"] },
};
// categories, packages → cached
const res = await fetch(, catalogCache);${BASE}/accounts/${token()}/baskets/${ident}
// baskets → always fresh
const res = await fetch(, { cache: "no-store" });
Without that cache, every page view would mean a round trip from the Worker to Tebex. That's slow, and it's an easy way to run into rate limits.
2. Account linking has to survive a full page redirect
Tebex requires the shopper to link a game account before you can add packages to their basket. Linking sends them off your site to Tebex and back, which means a full page load. Anything held in React state is gone when they return.
So when someone clicks "Add to cart" without a linked account, the kit saves what they were trying to buy in sessionStorage before redirecting:
if (!currentBasket?.username_id) {${location.origin}${location.pathname}?auth_return=1
writePending({ id: packageId, name, image, price, currency, quantity });
const returnUrl =;
// ...fetch auth links, show the modal
return { status: "needs-auth" };
}
When the page loads with ?auth_return=1, the kit removes the query param, reloads the basket, and completes the add. From the shopper's side, they click Add, link their account, and the item is in the cart. They don't have to click Add a second time.
addToCart also returns a result type (added | already-in-cart | needs-auth | error) instead of void. Otherwise every button would show "Added to cart!" even when nothing was added.
3. discount is not a percentage
This one caused a real bug. On a Tebex package, discount is a money amount, not a percentage. The UI was rendering {pkg.discount}% OFF, so a $5.25 discount showed up as "5.25% OFF".
4. Don't change a basket in parallel
Changing a quantity is a remove followed by an add with the new quantity. Clearing the cart runs its removes one after another, not with Promise.all. Concurrent changes to the same Tebex basket race each other and can leave items behind.
The kit also discards a basket once Tebex marks it complete. If it didn't, a returning customer would find the items they already bought still sitting in their cart.
5. A wishlist that stays in sync across tabs, with no provider
The heart icons on product cards, the wishlist page, and the recently-viewed rail all read the same data. Instead of a context provider, it's a small external store over localStorage, read with useSyncExternalStore.
The one trap: getSnapshot has to return the same reference until the data actually changes. If it doesn't, React re-renders forever. So the store caches the parsed array against the raw string:
function read(): number[] {
const raw = localStorage.getItem(key);
if (raw === cachedRaw) return cached; // same reference, no re-render
cachedRaw = raw;
cached = raw ? JSON.parse(raw) : EMPTY;
return cached;
}
6. The build has to pass without a token
This regression used to break deploys. A page tried to fetch the catalogue at build time, found no token, and the whole build failed.
Now, without a token, catalogue pages show an empty state instead of crashing. In development you get a setup screen that tells you exactly what's missing. CI builds without a token on purpose, so this can't come back without someone noticing.
Bonus: reviews without accounts
Reviews run on Cloudflare D1 and stay off until you bind a database, so the kit works without a Cloudflare account. When they're on, reviews are anonymous. Spam protection is a honeypot field plus a limit of 3 reviews per IP per hour. The IP is SHA-256 hashed before it's stored, and every review has a status column so you can hide one without deleting it.
Over to you
If you sell on Tebex, or you've ever built a headless storefront on any platform, I'd like to hear from you:
- What's missing? What would stop you from using this for your store tomorrow?
- Which gotcha did I miss? There's always one more.
⭐ If it's useful, star the repo. It helps other people find it. Issues and PRs are welcome.

Top comments (0)