Migration playbook
Phase-by-phase walkthrough of moving a Fresh storefront to TanStack Start.
This is the manual walkthrough. If you'd rather automate, run deco-migrate first — it does the mechanical 70%. Read this when the script flags something it won't auto-fix, or when you want to understand what the script is doing.
Phase 1 — Analyze
Before changing anything, take inventory:
cd /path/to/v1-site
# section count
find src/sections -name "*.tsx" | wc -l
# island count (must be eliminated)
find src/islands -name "*.tsx" 2>/dev/null | wc -l
# custom routes
find routes -type f | wc -l
# loaders / actions
find src/loaders src/actions -name "*.ts" 2>/dev/null | wc -l
# blocks
find .deco/blocks -name "*.json" | wc -lWrite these numbers down. They become the post-migration verification baseline.
Look for:
- Imports of
@deco/deco/*and$fresh/*— the script handles these. - Imports of
@preact/signalsandpreact/*— the script rewrites these to React. - Custom
useScript(fn)calls — these need manual review. - Custom
Deno.envreads — these need conversion. - Anything in
src/islands/— every island needs a destination.
Phase 2 — Scaffold
Generate the v2 baseline files:
vite.config.ts(withdecoVitePlugin, Cloudflare plugin, TanStack Start plugin, React, Tailwind v4)wrangler.jsonc(withnodejs_compat+no_handle_cross_request_promise_resolution)src/setup.ts(callscreateSiteSetup)src/server.ts(TanStack Start server entry)src/worker-entry.ts(callscreateDecoWorkerEntry)src/routes/__root.tsx,src/routes/index.tsx,src/routes/$.tsx
The script generates these. If you're doing this by hand, copy from the templates in decocms/blocks/.agents/skills/deco-to-tanstack-migration/templates/.
Phase 3 — Transform
This is the bulk-rewrite phase:
- Imports —
preact/*→react,@preact/signals→@tanstack/store,$fresh/*→@tanstack/react-start,@deco/deco/*→@decocms/blocks/*and@decocms/apps-*. - JSX attributes —
class→className,for→htmlFor. - Fresh APIs —
defineRoute/defineAppto TanStack Router patterns. - Deno-isms —
Deno.env.get(X)to env bindings,import.meta.urlto Vite-friendly equivalents. - Tailwind — v3 → v4 (token-based color references, opacity syntax,
@applyrewrites).
The 70+ rewrite rules live in the migrate script. See Migrate script reference for the breakdown.
Phase 4 — Cleanup
Remove what's no longer needed:
src/islands/— every island became something else.routes/(the old Fresh routes folder).deno.json,deno.lock.static/— moved topublic/.- Any
compat/folders your team added during incremental migration attempts.
Phase 5 — Bootstrap
Install dependencies and regenerate everything:
npm install
npm run generate:blocks
npm run generate:schema
npm run generate:sections
npm run generate:loaders
npx tsr generateWire all of these as composite scripts in package.json so a single npm run generate (or equivalent) keeps everything in sync.
Phase 6 — Verify
Run the typecheck and build:
npm run typecheck # tsc --noEmit
npx vite build # full prod build
npx wrangler deploy --dry-runA clean typecheck + clean build means the mechanical migration is done. Runtime correctness is a separate pass.
Phase 7 — Manual fixes
The migrate script logs everything that needs human attention to MIGRATION_REPORT.md. The most common items:
Islands → "use client" or hoisted state
Pattern: an island that owned local state (cart drawer toggle, search modal). In React, mark the parent section "use client" if the whole section is interactive, or hoist state into a section-level provider.
// before (Fresh)
// src/islands/CartDrawer.tsx ← whole island
// src/sections/Header.tsx imports the island
// after (React)
// src/sections/Header/Header.tsx
"use client";
import { useState } from "react";
import CartDrawer from "~/components/CartDrawer";
export default function Header({ ... }: Props) {
const [open, setOpen] = useState(false);
return (
<>
<header>...</header>
<CartDrawer open={open} onClose={() => setOpen(false)} />
</>
);
}Walk islands one at a time, deciding per island whether it can hoist into the section (server) or needs to remain a client component.
useScript(fn) removal
useScript(fn) was a Fresh pattern that extracted a function to an inline <script> tag. In v2 it doesn't hydrate cleanly. Replace with:
inlineScriptfrom@decocms/blocks/sdk/useScriptfor static script content.- A real client component for anything that touches state or DOM.
Platform hooks (useCart, useUser, useWishlist)
If your v1 site had custom overrides, port them. The default implementations in @decocms/apps-vtex/hooks cover the common cases. See VTEX hooks.
Third-party <head> scripts
Scripts that mutate <head> (Google Tag Manager, Adobe Launch, Hotjar) often cause hydration mismatches in React. The fix is to inject them in the worker response, after React has rendered, instead of in JSX.
Phase 8 — Performance pass
After functional correctness:
- Tune
foldThreshold— usually 2-3 sections eager, rest deferred. - Audit cache profiles — the defaults are conservative; loosen for static content, tighten for cart-adjacent pages.
- Prune
loaders.gen.ts— use--decofile-dir .deco/blocksso only loaders the CMS actually references appear in the registry. Recommended for new sites; existing sites can adopt incrementally. - Verify deferred sections in dev — if you hit "I/O across requests" errors, set
no_handle_cross_request_promise_resolutioninwrangler.jsonc.
Phase 9 — QA and deploy
Use the migration checklist to walk through verification.