deco-to-tanstack-migration
Deco-to-TanStack-Start Migration Playbook
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).
Architecture Boundaries
| 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 ~ |
Architecture Map
| 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 |
Migration Phases
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 | Worker Entry & Server | template | deco-edge-caching |
| 10 | Async Rendering & Polish | 0% | deco-async-rendering-site-guide |
Phase 0 — Scaffold
Entry: Source site accessible, @decocms/start + @decocms/apps published
Actions:
- Create TanStack Start project
- Copy
src/components/,src/sections/,src/islands/,src/hooks/,src/sdk/,src/loaders/from source - Copy
.deco/blocks/(CMS content) - Copy
static/assets - Create
package.json— seetemplates/package-json.md - Create
vite.config.ts— seetemplates/vite-config.md npm install
Exit: Empty project builds with npm run build
Phase 1 — Imports & JSX
Entry: Source files copied to src/
Actions (bulk sed — see references/codemod-commands.md):
- Preact → React:
from "preact/hooks"→from "react", etc. ComponentChildren→ReactNodeclass=→className=in JSX- SVG attrs:
stroke-width→strokeWidth,fill-rule→fillRule, etc. - HTML attrs:
for=→htmlFor=,fetchpriority→fetchPriority,autocomplete→autoComplete - Remove
/** @jsxRuntime automatic */pragma comments
Verification: grep -r 'from "preact' src/ | wc -l → 0
Exit: Zero preact imports, zero class= in JSX
See: references/imports/README.md
Phase 2 — Signals & State
Entry: Phase 1 complete
Actions:
- Bulk:
from "@preact/signals"→from "@decocms/start/sdk/signal"(module-level signals) - Manual:
useSignal(val)→useState(val)(component hooks) - Manual:
useComputed(() => expr)→useMemo(() => expr, [deps])(component hooks) - For global reactive state: use
signal()from@decocms/start/sdk/signal+useStore()from@tanstack/react-store
Verification: grep -r '@preact/signals' src/ | wc -l → 0
Exit: Zero @preact/signals imports
See: references/signals/README.md
Phase 3 — Deco Framework
Entry: Phase 2 complete
Actions (mostly bulk sed):
- Remove
$fresh/runtime.tsimports (asset()→ identity,IS_BROWSER→typeof window !== "undefined") from "deco-sites/SITENAME/"→from "~/"from "$store/"→from "~/"from "site/"→from "~/"SectionProps→ inline type orimport { 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
Phase 4 — Commerce & Types
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 utilities:
~/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
Phase 5 — Platform Hooks
Entry: Phase 4 complete
Actions (manual implementation):
- Create
src/hooks/useCart.ts— module-level singleton + listener pattern - Create
src/hooks/useUser.ts,src/hooks/useWishlist.ts(stubs or real) - Wire VTEX API calls via
@decocms/appsinvoke functions
Pattern: 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
Phase 6 — Islands Elimination
Entry: Phase 5 complete
Actions:
- Audit
src/islands/— categorize each file:- Wrapper: just re-exports from
components/→ delete, repoint imports - Standalone: has real logic → move to
src/components/
- Wrapper: just re-exports from
- Update all imports pointing to
islands/to point tocomponents/ - Delete
src/islands/directory
Verification: ls src/islands/ 2>/dev/null → directory not found
Exit: No islands/ directory
See: skill deco-islands-migration
Phase 7 — Section Registry
Entry: Phase 6 complete
Actions (critical — build src/setup.ts):
- Register all sections via
registerSections()with dynamic imports - Register critical sections (Header, Footer) via
registerSectionsSync()+setResolvedComponent() - Register section loaders via
registerSectionLoaders()for sections withexport const loader - Register layout sections via
registerLayoutSections() - Register commerce loaders via
registerCommerceLoaders()with SWR caching - Wire
onBeforeResolve()→initVtexFromBlocks()for VTEX config - Configure
setAsyncRenderingConfig()withalwaysEagerfor critical sections - Configure admin:
setMetaData(),setRenderShell(),setInvokeLoaders()
Template: templates/setup-ts.md
Exit: setup.ts compiles, all sections registered
See: skill deco-async-rendering-site-guide
Phase 8 — Routes & CMS
Entry: Phase 7 complete
Actions:
- Create
src/router.tsxwith scroll restoration - Create
src/routes/__root.tsxwith QueryClient, LiveControls, NavigationProgress, analytics - Create
src/routes/index.tsxusingcmsHomeRouteConfig() - Create
src/routes/$.tsxusingcmsRouteConfig()
Templates: templates/root-route.md, templates/router.md
Exit: Routes compile, CMS pages resolve
See: skill deco-tanstack-navigation
Phase 9 — Worker Entry
Entry: Phase 8 complete
Actions:
- Create
src/server.ts— CRITICAL:import "./setup"MUST be the first line - Create
src/worker-entry.ts— same:import "./setup"first - Wire admin handlers (handleMeta, handleDecofileRead, handleRender)
- Wire VTEX proxy if needed
Template: 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
Phase 10 — Async Rendering
Entry: Phase 9 complete (site builds and serves pages)
Actions:
- Identify lazy sections from CMS Lazy wrappers
- Add
export function LoadingFallback()to lazy sections - Configure
registerCacheableSections()for SWR on heavy sections - Test deferred section loading on scroll
Exit: Above-the-fold renders instantly, below-fold loads on scroll
See: skill deco-async-rendering-site-guide
Post-Migration Verification
# 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
Key Principles
- No compat layer anywhere -- not in
@decocms/start, not in@decocms/apps, not in the site repo - Replace, don't wrap -- change the import to the real thing, don't create a pass-through
- Types from the library, UI from the site --
Producttype comes from@decocms/apps/commerce/types, but the<Image>component is site-local - One Vite alias maximum --
"~"->"src/"is the only acceptable alias in a finished migration tsconfig.jsonmirrorsvite.config.ts-- only"~/*": ["./src/*"]in paths- Signals don't auto-subscribe in React -- reading
signal.valuein render creates NO subscription; useuseStore(signal.store)from@tanstack/react-store - Commerce loaders need request context --
resolve.tsmust pass URL/path to PLP/PDP loaders for search, categories, sort, and pagination to work wrangler.jsoncmain must be a custom worker-entry -- TanStack Start ignoresexport defaultinserver.ts; create a separateworker-entry.tsand point wrangler to it- Copy components faithfully, never rewrite --
cpthe 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 changes - Tailwind v4 logical property hazard -- mixed
px-*+pl-*/pr-*on the same element breaks the cascade. Replace mixed patterns with consistent longhand (pl-X pr-Xinstead ofpx-X) on those elements only - oklch CSS variables need triplets, not hex -- sites using
oklch(var(--x))must store variables as oklch triplets (100% 0.00 0deg), not hex values.oklch(#FFF)is invalid CSS - Verify ALL imports resolve at runtime, not just build -- Vite tree-shakes dead imports, so
npm run buildpasses even with missing modules. ButregisterSectionslazy imports execute at runtime, killing entire sections silently import "./setup"first — in bothserver.tsandworker-entry.ts- globalThis for split modules — Vite server function split modules need
globalThis.__decoto share state
Worker Entry Architecture
The 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
Site worker-entry.ts Pattern
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:
./setupMUST be imported first (registers sections, loaders, meta, render shell)- Admin handlers are passed as options, NOT imported inside
createDecoWorkerEntry /live/and/.decofileare inDEFAULT_BYPASS_PATHS-- never cached by the edge
Admin Preview HTML Shell
The 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.
Client-Safe vs Server-Only Imports
@decocms/start has two admin entry points:
@decocms/start/admin-- server-only handlers (handleMeta, handleRender, etc.) -- these may transitively importnode:async_hooks@decocms/start/admin/setup(re-exported from@decocms/start/admin) -- client-safe setup functions (setMetaData, setInvokeLoaders, setRenderShell) -- NO node: imports
The 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.
Admin Self-Hosting Architecture
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
Content URL Resolution Priority
devContentUrlURL param → saved tolocalStorage[deco::devContentUrl::${site}]→ used by Content SDKdevContentUrlfrom localStorage → used by Content SDKsite.metadata.selfHosting.productionUrl(Supabase) → used by Content SDKhttps://${site}.deco.site→ fallback
Environment Platform Gate
The admin only uses createContentSiteSDK when:
devContentUrlis set (localStorage or URL param), OR- The current environment has
platform: "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:
https://admin.deco.cx/sites/YOUR_SITE/spaces/...?devContentUrl=http://localhost:5181
Admin / CMS Schema Architecture
The deco admin (deco-cx/deco) communicates with the storefront via:
GET /live/_meta-- returns full JSON Schema + manifest of block typesGET /.decofile-- returns the site's content blocksPOST /deco/render-- renders a section/page with given props in an iframePOST /deco/invoke-- calls a loader/action and returns JSON
Schema Composition (composeMeta)
The schema generator (scripts/generate-schema.ts) only produces section schemas from site TypeScript files. Framework-managed block types (pages) are defined in src/admin/schema.ts and injected at runtime via composeMeta().
[generate-schema.ts] --> meta.gen.json (sections only, pages: empty)
[setup.ts] --> imports meta.gen.json --> calls setMetaData(metaData)
[setMetaData] --> calls composeMeta() --> injects page schema + merges definitions
[/live/_meta] --> returns composed schema with content-hash ETag
Key rules:
toBase64()MUST produce padded Base64 (matchingbtoa()) -- admin usesbtoa()to construct definition refs- Page schema uses flat properties (no allOf + @Props indirection) to minimize RJSF resolution steps
- ETag is a content-based DJB2 hash, not string length, for reliable cache invalidation
- The etag is also included in the JSON response body for admin's
metaInfo.value?.etagcache check
Admin Local Development
To use the deco admin with a local storefront:
- Start admin:
cd admin && deno task play(port 4200) - Start storefront:
bun run dev(port 5181 or wherever it lands) - Set
devContentUrlin admin's browser console:localStorage.setItem('deco::devContentUrl::YOUR_SITE_NAME', 'http://localhost:PORT') - Navigate to
http://localhost:4200/sites/YOUR_SITE_NAME/spaces/pages - After schema changes: clear admin cache (
localStorage.removeItem('meta::YOUR_SITE_NAME')) and hard-refresh
Conductor / AI Bulk Migration Workflow
For sites with 100+ sections and 200+ components, manual file-by-file migration is impractical. The proven workflow:
Phase 1: Scaffold + Copy (human)
- Scaffold TanStack Start project
cp -rthe entiresrc/from the original site- Set up
vite.config.ts,tsconfig.json,wrangler.jsonc,package.json - Install dependencies
Phase 2: Mechanical Rewrites (AI/conductor)
Let AI tackle the bulk TypeScript errors in a single pass:
-
Import rewrites (safe for bulk
sed):from "preact"→from "react"from "preact/hooks"→from "react"from "preact/compat"→from "react"from "@preact/signals"→from "~/sdk/signal"from "apps/commerce/types"→from "@decocms/apps/commerce/types"from "$store/"→from "~/"
-
JSX attribute rewrites (safe for bulk):
class=→className=(in JSX context)for=→htmlFor=(on<label>elements)stroke-width→strokeWidth,fill-rule→fillRule(SVG)- Remove
data-fresh-disable-lock
-
Type rewrites (per-file, AI-assisted):
JSX.TargetedEvent<HTMLInputElement>→React.ChangeEvent<HTMLInputElement>JSX.TargetedMouseEvent→React.MouseEventComponentChildren→ReactNodeSVGAttributes<SVGSVGElement>→React.SVGProps<SVGSVGElement>- Create consolidated type files (
~/types/vtex.ts,~/types/widgets.ts)
-
Signal-to-state (per-file, needs judgment):
useSignal(x)→useState(x)with setter.valuereads → direct variable reads.value =writes →setState()calls- Toggle:
x.value = !x.value→setX(prev => !prev)
Phase 3: Verify (human + AI)
npx tsc --noEmit— catches remaining type errorsnpm run build— catches import resolution errorsbun run dev+ browser test — catches runtime errors- Visual comparison with production — catches layout regressions
Phase 4: Fix Runtime Issues (human-guided)
This is where gotchas 1-45 apply. The mechanical rewrite gets you to "builds clean" but runtime issues require understanding the architectural differences.
Key Insight: Never Rewrite, Only Port
The conductor approach that worked (836 errors → 0 across 213 files) treated every file as: copy the original, apply mechanical changes only. The failed approach was: "look at the original and rewrite it in React" — this produced components that looked similar in code but rendered completely differently because of subtle grid/flex/responsive differences.
Reference Index
| Topic | Path |
|---|---|
| Preact → React imports | references/imports/ |
| Signals → TanStack Store | references/signals/ |
| Deco framework elimination | references/deco-framework/ |
| Commerce & widget types | references/commerce/ |
| Platform hooks (VTEX) | references/platform-hooks/ |
| Vite configuration | references/vite-config/ |
| Automation commands | references/codemod-commands.md |
| Admin schema composition | src/admin/schema.ts in @decocms/start |
| Common gotchas (45 items) | references/gotchas.md |
| setup.ts template | templates/setup-ts.md |
| vite.config.ts template | templates/vite-config.md |
| worker-entry template | templates/worker-entry.md |
| __root.tsx template | templates/root-route.md |
| router.tsx template | templates/router.md |
| package.json template | templates/package-json.md |
Related Skills
| Skill | Use When |
|---|---|
| deco-apps-vtex-porting | Understanding VTEX loader internals (Phase 4-5) |
| deco-islands-migration | Eliminating islands/ (Phase 6) |
| deco-async-rendering-site-guide | Lazy wrappers, LoadingFallback (Phase 7, 10) |
| deco-tanstack-navigation | Link, prefetch, scroll issues (Phase 8) |
| deco-edge-caching | Worker caching, cache profiles (Phase 9) |
| deco-tanstack-hydration-fixes | Hydration mismatches post-migration |
| deco-tanstack-search | Search page not working |
| deco-typescript-fixes | Bulk TypeScript error resolution |
| deco-start-architecture | Understanding @decocms/start internals |
| deco-tanstack-storefront-patterns | Runtime bugs after migration |
| deco-server-functions-invoke | Server function patterns |
| deco-tanstack-data-flow | Data flow architecture |