Migrate Deco.cx storefronts from Fresh/Preact to TanStack Start/React on Cloudflare Workers. Phase-based playbook with automation scripts, battle-tested templates, and cross-references to specialized skills. Use when migrating a deco-site, porting Preact components to React, or setting up TanStack Start for a Deco storefront.
Phase-based playbook for converting deco-sites/* storefronts from Fresh/Preact/Deno to TanStack Start/React/Cloudflare Workers. Battle-tested on espacosmart-storefront (100+ sections, VTEX, async rendering).
| Layer | npm Package | Purpose | Must NOT Contain |
|---|---|---|---|
| @decocms/start | @decocms/start | CMS resolution, DecoPageRenderer, worker entry, sdk (useScript, signal, clx) | Preact shims, widget types, site-specific maps |
| @decocms/apps | @decocms/apps | VTEX/Shopify loaders, commerce types, commerce sdk (useOffer, formatPrice, analytics) | Passthrough HTML components, Preact/Fresh refs |
| Site repo | (not published) | All UI: components, hooks, types, routes, styles |
No compat/ layer, no aliases beyond ~ |
@decocms/start (framework)
├── src/cms/ # Block loading, page resolution, section registry
│ └── loader.ts # loadBlocks, setBlocks, AsyncLocalStorage for per-request overrides
├── src/admin/ # Admin protocol: meta, decofile, invoke, render, schema composition
│ ├── meta.ts # setMetaData() calls composeMeta() at startup; /deco/meta handler
│ ├── schema.ts # MetaResponse type, composeMeta(), framework block schemas (pages)
│ ├── render.ts # /live/previews/* for section + page preview (HTML shell)
│ ├── setup.ts # Client-safe setup (setMetaData, setInvokeLoaders, setRenderShell)
│ ├── decofile.ts # /.decofile read/reload
│ ├── invoke.ts # /deco/invoke for loader/action calls
│ ├── cors.ts # CORS + admin origin validation
│ └── liveControls.ts # Admin iframe bridge postMessage script
├── src/sdk/
│ ├── workerEntry.ts # createDecoWorkerEntry: outermost Cloudflare Worker wrapper
│ ├── useScript.ts, signal.ts, clx.ts, cachedLoader.ts, instrumentedFetch.ts
│ └── ...
├── src/hooks/ # DecoPageRenderer (uses registry, NOT hardcoded map), LiveControls
├── src/types/ # FnContext, App, AppContext, Section, SectionProps, Resolved
└── scripts/ # generate-blocks.ts, generate-schema.ts
@decocms/apps (commerce)
├── commerce/types/ # Product, AnalyticsItem, BreadcrumbList, Filter, etc.
├── commerce/utils/ # mapProductToAnalyticsItem, parseRange, formatRange
├── commerce/sdk/ # useOffer, useVariantPossibilities, formatPrice, relative, analytics
├── vtex/ # Client, loaders (actual VTEX API calls)
└── shopify/ # Client, loaders (actual Shopify API calls)
site repo (UI + business logic)
├── src/components/ # All UI components (Image, Picture, Seo, Theme, etc.)
├── src/hooks/ # useCart (real VTEX implementation), useUser, useWishlist
├── src/types/ # widgets.ts (string aliases), vtex.ts (OrderFormItem, etc.)
├── src/sdk/ # Site-specific contexts, usePlatform, useUI, useSuggestions
├── src/sections/ # All CMS-renderable sections
├── src/routes/ # TanStack Router routes
└── src/server/ # Server functions (invoke.ts — createServerFn wrappers for @decocms/apps)
| Old Stack | New Stack |
|---|---|
| Deno + Fresh | Node + TanStack Start |
| Preact + Islands | React 19 + React Compiler |
| @preact/signals | @tanstack/store + @tanstack/react-store |
| Deco CMS runtime | Static JSON blocks via @decocms/start |
| $fresh/runtime.ts | Inlined (asset() removed, IS_BROWSER inlined) |
| @deco/deco/* | @decocms/start/sdk/* or inline stubs |
| apps/commerce/types | @decocms/apps/commerce/types |
| apps/website/components/* | ~/components/ui/* (local React) |
| apps/{platform}/hooks/* | ~/hooks/useCart (real implementation) |
| ~/sdk/useOffer | @decocms/apps/commerce/sdk/useOffer |
| ~/sdk/useScript | @decocms/start/sdk/useScript |
| ~/sdk/signal | @decocms/start/sdk/signal |
Each phase has entry/exit criteria. Follow in order. Automation % indicates how much can be done with bulk sed/grep.
| Phase | Name | Automation | Related Skill |
|---|---|---|---|
| 0 | Scaffold & Copy | 100% | — |
| 1 | Import Rewrites | ~90% | — |
| 2 | Signals & State | ~50% | — |
| 3 | Deco Framework Elimination | ~80% | — |
| 4 | Commerce Types & UI | ~70% | deco-apps-vtex-porting |
| 5 | Platform Hooks | 0% | deco-apps-vtex-porting |
| 6 | Islands Elimination | ~60% | deco-islands-migration |
| 7 | Section Registry & Setup | 0% | deco-async-rendering-site-guide |
| 8 | Routes & CMS | template | deco-tanstack-navigation |
| 9 |
Entry: Source site accessible, @decocms/start + @decocms/apps published
Actions:
src/components/, src/sections/, src/islands/, src/hooks/, src/sdk/, src/loaders/ from source.deco/blocks/ (CMS content)static/ assetspackage.json — see templates/package-json.mdvite.config.ts — see templates/vite-config.mdnpm installExit: Empty project builds with npm run build
Entry: Source files copied to src/
Actions (bulk sed — see references/codemod-commands.md):
from "preact/hooks" → from "react", etc.ComponentChildren → ReactNodeclass= → className= in JSXstroke-width → strokeWidth, fill-rule → fillRule, etc.for= → htmlFor=, fetchpriority → fetchPriority, autocomplete → autoComplete/** @jsxRuntime automatic */ pragma commentsVerification: grep -r 'from "preact' src/ | wc -l → 0
Exit: Zero preact imports, zero class= in JSX
See: references/imports/README.md
Entry: Phase 1 complete
Actions:
from "@preact/signals" → from "@decocms/start/sdk/signal" (module-level signals)useSignal(val) → useState(val) (component hooks)useComputed(() => expr) → useMemo(() => expr, [deps]) (component hooks)signal() from @decocms/start/sdk/signal + useStore() from @tanstack/react-storeVerification: grep -r '@preact/signals' src/ | wc -l → 0
Exit: Zero @preact/signals imports
See: references/signals/README.md
Entry: Phase 2 complete
Actions (mostly bulk sed):
$fresh/runtime.ts imports (asset() → identity, IS_BROWSER → typeof window !== "undefined")from "deco-sites/SITENAME/" → from "~/"from "$store/" → from "~/"from "site/" → from "~/"SectionProps → inline type or import { SectionProps } from "~/types/section"useScript → from "@decocms/start/sdk/useScript"clx → from "@decocms/start/sdk/clx"Verification: grep -rE 'from "(@deco/deco|\$fresh|deco-sites/)' src/ | wc -l → 0
Exit: Zero @deco/deco, $fresh, deco-sites/ imports
See: references/deco-framework/README.md
Entry: Phase 3 complete
Actions:
from "apps/commerce/types.ts" → from "@decocms/apps/commerce/types"from "apps/admin/widgets.ts" → from "~/types/widgets" (create local file with string aliases)from "apps/website/components/Image.tsx" → from "~/components/ui/Image" (create local components)~/sdk/useOffer → @decocms/apps/commerce/sdk/useOffer, ~/sdk/format → @decocms/apps/commerce/sdk/formatPrice, etc.Verification: grep -r 'from "apps/' src/ | wc -l → 0
Exit: Zero apps/ imports
See: references/commerce/README.md
Entry: Phase 4 complete
Actions (manual implementation):
src/hooks/useCart.ts — module-level singleton + listener patternsrc/hooks/useUser.ts, src/hooks/useWishlist.ts (stubs or real)@decocms/apps invoke functionsPattern: Closure state + _listeners Set + useState for re-renders. See espacosmart's useCart.ts as template.
Exit: Cart add/remove works, no apps/{platform}/hooks imports
See: references/platform-hooks/README.md, skill deco-apps-vtex-porting
Entry: Phase 5 complete
Actions:
src/islands/ — categorize each file:
components/ → delete, repoint importssrc/components/islands/ to point to components/src/islands/ directoryVerification: ls src/islands/ 2>/dev/null → directory not found
Exit: No islands/ directory
See: skill deco-islands-migration
Entry: Phase 6 complete
Actions (critical — build src/setup.ts):
registerSections() with dynamic importsregisterSectionsSync() + setResolvedComponent()registerSectionLoaders() for sections with export const loaderregisterLayoutSections()registerCommerceLoaders() with SWR cachingonBeforeResolve() → initVtexFromBlocks() for VTEX configsetAsyncRenderingConfig() with alwaysEager for critical sectionssetMetaData(), setRenderShell(), setInvokeLoaders()registerSeoSections() — identify sections that produce title/description/canonical (typically SEOPDP, SEOPLP). Register their loaders in registerSectionLoaders too.Template: templates/setup-ts.md
Exit: setup.ts compiles, all sections registered
See: skill deco-async-rendering-site-guide
Entry: Phase 7 complete
Actions:
src/router.tsx with scroll restorationsrc/routes/__root.tsx with QueryClient, LiveControls, NavigationProgress, analytics. Include fallback description, og:site_name, og:locale in root head(). Do NOT include a hardcoded Device.Provider.src/routes/index.tsx using cmsHomeRouteConfig({ defaultTitle, defaultDescription, siteName })src/routes/$.tsx using cmsRouteConfig({ siteName, defaultTitle, defaultDescription }) — spread the full config, do NOT cherry-pick fields. The framework handles SEO head, cache headers, and staleTime/gcTime.Seo.tsx component — must render JSON-LD structured data (NOT return null). Meta tags are handled by the framework's head().useSyncExternalStore + matchMedia for client-side. Use registerSectionLoaders for server-side UA detection. Do NOT use a hardcoded Device.Provider.Templates: templates/root-route.md, templates/router.md
Exit: Routes compile, CMS pages resolve, PDPs have JSON-LD + meta description in <head>
See: skills deco-tanstack-navigation, deco-cms-route-config
Entry: Phase 8 complete
Actions:
src/server.ts — CRITICAL: import "./setup" MUST be the first linesrc/worker-entry.ts — same: import "./setup" firstTemplate: templates/worker-entry.md
CRITICAL: Without import "./setup" as the first import, server functions in Vite split modules will have empty state (blocks, registry, commerce loaders). This causes 404 on client-side navigation.
Exit: npm run dev serves pages, admin endpoints work
See: skill deco-edge-caching
Entry: Phase 9 complete (site builds and serves pages)
Actions:
export function LoadingFallback() to lazy sectionsregisterCacheableSections() for SWR on heavy sectionsExit: Above-the-fold renders instantly, below-fold loads on scroll
See: skill deco-async-rendering-site-guide
# 1. Build
npm run build
# 2. Zero old imports
grep -rE 'from "(preact|@preact|@deco/deco|\$fresh|deco-sites/|apps/)' src/ | wc -l
# Expected: 0
# 3. Dev server
npm run dev
# 4. SSR test — load homepage via F5
# 5. Client nav — click links, verify no 404
# 6. Console — no hydration warnings, no missing keys
# 7. Deferred — scroll down, sections load on scroll
# 8. Admin — /deco/meta returns JSON, /live/previews works
npm create @tanstack/app@latest -- --template cloudflare-workerssrc/ from deco-sitereferences/vite-config/npm install @decocms/start @decocms/apps @tanstack/store @tanstack/react-storecd apps-start && npm link && cd ../deco-start && npm link && cd ../my-store && npm link @decocms/apps @decocms/startreferences/imports/references/signals/references/deco-framework/references/commerce/~/sdk/useOffer -> @decocms/apps/commerce/sdk/useOffer, ~/sdk/useScript -> @decocms/start/sdk/useScript, etc.~/components/ui/references/platform-hooks/npm run buildfrom "apps/", from "$store/", from "preact", from "@preact", from "@deco/deco", from "~/sdk/useOffer", from "~/sdk/format" etc. -- all sdk utilities should come from packages@decocms/start, not in @decocms/apps, not in the site repoProduct type comes from @decocms/apps/commerce/types, but the <Image> component is site-local"~" -> "src/" is the only acceptable alias in a finished migrationtsconfig.json mirrors vite.config.ts -- only "~/*": ["./src/*"] in pathssignal.value in render creates NO subscription; use useStore(signal.store) from @tanstack/react-storeresolve.ts must pass URL/path to PLP/PDP loaders for search, categories, sort, and pagination to workwrangler.jsonc main must be a custom worker-entry -- TanStack Start ignores export default in server.ts; create a separate worker-entry.ts and point wrangler to itcp the original file, then only change: class → className, for → htmlFor, import paths (apps/ → ~/, $store/ → ~/), preact → react. NEVER regenerate, "clean up", or "improve" the component. AI-rewritten components are the #1 source of visual regressions -- the layout, grid classes, responsive variants, and conditional logic must be byte-identical to the original except for the mechanical migration changespx-* + pl-*/pr-* on the same element breaks the cascade. Replace mixed patterns with consistent longhand (pl-X pr-X instead of px-X) on those elements onlyoklch(var(--x)) must store variables as oklch triplets (100% 0.00 0deg), not hex values. oklch(#FFF) is invalid CSSThe Cloudflare Worker entry point has a strict layering. Admin routes MUST be handled in createDecoWorkerEntry (the outermost wrapper), NOT inside TanStack's createServerEntry. TanStack Start's Vite build strips custom logic from createServerEntry callbacks in production.
Request
└─> createDecoWorkerEntry(serverEntry, { admin: { ... } })
├─> tryAdminRoute() ← FIRST: /live/_meta, /.decofile, /live/previews/*
├─> cache purge check ← __deco_purge_cache
├─> static asset bypass ← /assets/*, favicon, sprites
├─> Cloudflare cache (caches.open)
└─> serverEntry.fetch() ← TanStack Start handles everything else
import "./setup";
import handler, { createServerEntry } from "@tanstack/react-start/server-entry";
import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
import {
handleMeta, handleDecofileRead, handleDecofileReload,
handleRender, corsHeaders,
} from "@decocms/start/admin";
const serverEntry = createServerEntry({
async fetch(request) {
return await handler.fetch(request);
},
});
export default createDecoWorkerEntry(serverEntry, {
admin: { handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders },
});
Key rules:
./setup MUST be imported first (registers sections, loaders, meta, render shell)createDecoWorkerEntry/live/ and /.decofile are in DEFAULT_BYPASS_PATHS -- never cached by the edgeThe preview at /live/previews/* renders sections into an HTML shell. This shell MUST match the production <html> attributes for CSS frameworks to work:
// In setup.ts
setRenderShell({
css: appCss, // Vite ?url import of app.css
fonts: ["https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"],
theme: "light", // -> <html data-theme="light"> (required for DaisyUI v4)
bodyClass: "bg-base-100 text-base-content",
lang: "pt-BR",
});
Without data-theme="light", DaisyUI v4 theme variables (--color-primary, etc.) won't activate in the preview iframe, causing color mismatches vs production.
@decocms/start has two admin entry points:
@decocms/start/admin -- server-only handlers (handleMeta, handleRender, etc.) -- these may transitively import node:async_hooks@decocms/start/admin/setup (re-exported from @decocms/start/admin) -- client-safe setup functions (setMetaData, setInvokeLoaders, setRenderShell) -- NO node: importsThe site's setup.ts can safely import from @decocms/start/admin because it only uses the setup functions. But the barrel export must be structured so Vite tree-shaking doesn't pull server modules into client bundles.
When a site is self-hosted (deployed to its own Cloudflare Worker), the admin communicates with the storefront via the productionUrl:
admin.deco.cx
└─> createContentSiteSDK (when env.platform === "content" OR devContentUrl is set)
├─> fetch(productionUrl + "/live/_meta") ← schema + manifest
├─> fetch(productionUrl + "/.decofile") ← content blocks
└─> iframe src = productionUrl + "/live/previews/*" ← section preview
devContentUrl URL param → saved to localStorage[deco::devContentUrl::${site}] → used by Content SDKdevContentUrl from localStorage → used by Content SDKsite.metadata.selfHosting.productionUrl (Supabase) → used by Content SDKhttps://${site}.deco.site → fallbackThe admin only uses createContentSiteSDK when:
devContentUrl is set (localStorage or URL param), ORplatform: "content"Setting productionUrl in Supabase alone is NOT sufficient. The environment must be "content" platform. This happens when connectSelfHosting is called with a productionUrl -- it deletes/recreates the staging environment as platform: "content".
For local dev, use the URL param shortcut:
| Worker Entry & Server |
| template |
| deco-edge-caching |
| 10 | Async Rendering & Polish | 0% | deco-async-rendering-site-guide |
npm run build passes even with missing modules. But registerSections lazy imports execute at runtime, killing entire sections silentlyimport "./setup" first — in both server.ts and worker-entry.tsglobalThis.__deco to share state