Building a Headless Shopify Storefront with TanStack Start on Cloudflare Workers
How I built kaheli.pl — a Polish candles & perfume storefront — on a fully custom React 19 frontend running on Cloudflare Workers, with type safety from the Shopify GraphQL schema all the way to the React component.
I built kaheli.pl — a store for a Polish brand of scented candles and perfumes — and it runs on a stack that’s a bit unusual for a small e-commerce store: headless Shopify, TanStack Start, Cloudflare Workers, Rust/WASM Shopify Functions.
This is a case study about why that stack made sense, what it actually buys you, and where the trade-offs hurt. Not a tutorial — but a faithful map of the decisions behind a production storefront.
Why a custom frontend?
Shopify’s Liquid templating gets you a working store quickly. Hydrogen (Shopify’s own React meta-framework) gets you further. But both carry trade-offs: Liquid is slow to iterate on for complex UIs; Hydrogen locks you into Shopify’s hosting and opinionated data layer.
The goal for kaheli.pl was a storefront that:
- Renders on the edge (Cloudflare Workers, not a Node.js server)
- Uses React 19 with SSR and streaming
- Keeps full TypeScript type safety from the Shopify GraphQL schema all the way to the React component
- Extends Shopify’s checkout with custom Rust/WASM functions and Preact UI extensions
That combination landed on TanStack Start — a file-based SSR router built on TanStack Router and Vite — deployed to Cloudflare Workers.
The shape of the codebase
A pnpm workspace, orchestrated by Turborepo. Four apps live side by side: the public storefront, a Shopify CLI app that holds the checkout extensions, a Storybook for component documentation, and a small CLI for generating social-media content. A handful of internal packages sit underneath — most importantly the Shopify client package, which is the only place in the entire repo allowed to talk to the Storefront and Admin APIs.
Tooling choices that shape day-to-day work:
- pnpm — strict dependency isolation, fast installs
- Turborepo — parallel task execution, remote caching
- Biome v2 — single tool for formatting and linting (no ESLint config hell)
- Vitest — unit tests
- Playwright — E2E, visual regression, and API tests
Nothing about that stack is exotic. The interesting part is what’s stitched on top.
Type safety, end to end
“We use TypeScript” is table stakes. The thing that actually pays off here is a chain of guarantees that runs from the Shopify GraphQL schema all the way down to the React render — with no manual type maintenance anywhere in the chain.
| Layer | Tool | What it guarantees |
|---|---|---|
| GraphQL → TS types | @shopify/api-codegen-preset | Query shape is the type. Add a field to the query, run codegen, the type updates. No hand-written interfaces. |
| Server / client boundary | TanStack createServerFn + Zod inputValidator | One schema validates at runtime and types the handler’s data. |
| Runtime env vars | varlock | A typo in env.SHOPIFY_* is a compile error, not a 2 a.m. undefined. |
| Content (blog, pages) | content-collections + Zod frontmatter | Bad markdown frontmatter breaks the build. |
| Whole codebase | strict TS + noUncheckedIndexedAccess + Biome noExplicitAny | any is banned. array[0] is T | undefined, so the optional case can’t be forgotten. |
The single most representative piece of glue is the server-function boundary. One Zod schema, one source of truth — used for runtime validation and the handler’s input type:
export const addToCartFn = createServerFn({ method: "POST" })
.inputValidator(z.object({
lines: z.array(z.object({
merchandiseId: z.string().min(1),
quantity: z.number().int().positive(),
})),
}))
.handler(async ({ data }) => {
// data.lines is fully typed — no casting
});
Every Storefront query is also tagged with @inContext(country: PL, language: PL), so Shopify returns Polish pricing, tax rules, and translated content without us having to localize anything by hand on the client.
Data flow on a page load
When someone lands on the homepage, the request hits a Cloudflare Worker at the nearest edge. The route loader fires off the queries it needs (featured products, collections), each one going through a server function that wraps the Shopify call in an edge-cache layer. The cache uses Cloudflare’s Cache API with a ~5-minute TTL, so most requests never reach Shopify at all. The query results are dehydrated into the SSR stream, hydrated on the client, and the React components read straight from the cache without re-fetching.
The end result: cold pages render in well under 100 ms globally, and warm ones are essentially free.
Cloudflare Workers deployment
The storefront ships as a single Cloudflare Worker. The Vite build produces an ESM bundle for the Worker and a directory of static assets served directly from Cloudflare’s network — the Worker itself only handles SSR and API routes.
Three environments, each with its own Shopify store, API tokens, and feature-flag state:
- local — Vite dev server on
:3000 - test —
webforma.pl, behind Cloudflare Access (not public) - production —
kaheli.pl
Feature flags follow a fail-closed convention — only the literal string "true" enables the feature, so a misconfigured environment defaults to off rather than on.
Reaching into Shopify’s checkout
Custom checkout behavior is where the headless approach earns its keep. Two extensions live in the repo.
Delivery customization (Rust → WASM)
InPost parcel lockers, InPost courier, and DPD courier all appear in Shopify’s checkout as delivery options. The customer picks one on a pre-checkout page that we own, and we persist the choice as a Cart Attribute (e.g. delivery_method: "inpost_paczkomat").
A Shopify Function — written in Rust, compiled to WASM — reads that attribute at checkout time and hides the non-matching delivery options. Because it’s a Shopify Function, it executes inside Shopify’s own infrastructure, with cold starts measured in microseconds. There is no round-trip to our Worker, and nothing to keep warm.
Thank-you block (Preact)
A small Preact component rendered on Shopify’s hosted thank-you page, inside the checkout sandbox. It uses Shopify’s <s-*> design-system web components so it inherits checkout styling automatically, and it shows a short Polish-language candle care tip — Przytnij knot do 5 mm przed każdym paleniem. — turning the receipt page into a tiny piece of post-purchase brand work.
What TanStack Start brings
TanStack Start is still pre-1.0, but it solves a specific problem: file-based routing with full SSR and React Query integration on non-Node runtimes. That last constraint is what rules out most of its competitors when you’re targeting Cloudflare Workers.
The pieces I lean on:
createFileRoute— route-level loaders, search-param validation, and<head>meta colocated with the routecreateServerFn— typed server/client boundary with automatic serializationsetupRouterSsrQueryIntegration— dehydrates the query client server-side, hydrates client-side, no double-fetchdefaultViewTransition: true— native browser View Transitions API for page changes
The pattern that ties it together is the router context: the queryClient lives there, loaders call ensureQueryData, and components call useSuspenseQuery against the same queryOptions factory. Server and client share the same cache key, so there’s no parallel data layer to keep in sync.
Trade-offs
What you give up vs Liquid:
- No Shopify theme editor. Marketing can’t drag-and-drop sections.
- App compatibility: many Shopify apps inject Liquid or expect a theme. A custom frontend means manual integration for each one.
- More infrastructure to own: the CDN edge, cache invalidation, SSR cold starts.
What you gain:
- Full React component model, hooks, context — no Liquid workarounds for interactive UI
- Zero-runtime CSS (Tailwind v4, build-time extraction)
- Edge-first architecture — cached responses in well under 50 ms globally
- Type safety across the entire stack
- Shopify stays as the product engine (catalog, inventory, checkout, payments) while you own the shopping experience
Was it worth it?
For kaheli.pl, yes — but the answer depends entirely on what you’re optimizing for.
If you need a store yesterday and don’t have engineering bandwidth, stay with Liquid or use Hydrogen. The custom approach pays off when you have:
- Strong opinions about UX that the theme editor can’t deliver
- An in-house dev who’ll own the codebase long-term
- A reason to optimize beyond what the templating layer allows (in my case: Polish localization, custom delivery flow, gift box configurator, scent quiz, and a content-heavy blog)
The headless trade-off is real: you exchange the convenience of Shopify’s app ecosystem for full control over every pixel and millisecond. For a brand where the shopping experience is the brand — candles, perfumes, gifting — that control matters.
The full stack: TypeScript, pnpm workspaces, TanStack Start, Cloudflare Workers, Rust/WASM Shopify Functions. If you want to see it in action, browse kaheli.pl. Happy to go deeper on any layer — reach out if you’re building something similar.
About the Author
Low code enthusiast, automation advocate, open-source supporter, digital transformation lead consultant, skilled Pega LSA holding LSA certification since 2018, Pega expert, AI practitioner and JavaScript full-stack developer as well as people manager.
15+ years of experience in the IT field with a focus on designing and implementing large scale IT systems for world’s biggest companies. Professional knowledge of: software design, enterprise architecture, project management and project delivery methods, BPM, CRM, Low-code platforms, Pega 8/23/24 suite, and enterprise AI implementation.