Deco TanStack Navigation Migration
Complete playbook for replacing Fresh/Deno navigation with TanStack Router in Deco storefronts. Goes beyond simple <a> to <Link> — covers the full power of the router to build sites that feel like native apps while keeping SSR-first SEO.
When to Use This Skill
-
Migrating a Fresh/Deno storefront to TanStack Start
-
Links cause full page reloads instead of SPA transitions
-
Filters, sort, or search reload the entire page
-
Forms submit via GET and append query params
-
Navigation feels slow (no prefetching)
-
Menus don't highlight the active page
-
Need type-safe route params
-
Want URL as the single source of truth for filters/pagination
Architecture: SSR-First, Hydrate Smart
Request → Server ├─ TanStack Router matches route ├─ Route loader runs on server (createServerFn) │ ├─ resolveDecoPage(path) │ ├─ runSectionLoaders(sections, request) │ └─ Return full page data ├─ React renders to HTML (SSR) └─ Response: full HTML + serialized data
Client receives HTML ├─ Instantly visible (SEO, LCP, FCP) ├─ React hydrates (attaches event handlers) ├─ TanStack Router takes over navigation └─ Subsequent navigations: ├─ Prefetch on hover/intent (data + component) ├─ Client-side render (no full page reload) ├─ Only the changed route re-renders └─ Shared layout (header/footer) stays mounted
This gives you:
-
SEO: Full HTML on first request, crawlers see everything
-
Speed: Prefetch makes subsequent pages feel instant
-
State: Cart, menus, form inputs survive navigation
-
Bandwidth: Only route data transfers, not the full HTML shell
Pattern 1: <a href> to <Link> with Prefetch
The Basic Migration
// FRESH — full page reload on every click <a href={url}>Click me</a>
// TANSTACK — SPA navigation, preserves state import { Link } from "@tanstack/react-router"; <Link to={url}>Click me</Link>
Prefetch: Make Navigation Instant
The killer feature. The router can preload the next page before the user clicks.
// Preload when user hovers or focuses the link <Link to="/produtos" preload="intent"> Produtos </Link>
// Preload immediately when the link renders (good for hero CTAs) <Link to="/ofertas" preload="render"> Ver Ofertas </Link>
// Disable prefetch (for low-priority links) <Link to="/termos" preload={false}> Termos de Uso </Link>
What gets preloaded:
-
Route component code (the JS chunk)
-
Route loader data (the createServerFn call)
-
Any nested route data
When the user clicks, everything is already cached — navigation is instant.
Prefetch Strategy by Component
Component Strategy Why
Product card preload="intent"
User will likely click after hover
NavItem (menu) preload="intent"
High-intent interaction
Category link preload="intent"
Top-of-funnel navigation
Hero CTA preload="render"
Guaranteed next action
Breadcrumb preload="intent"
Medium priority
Footer links preload={false}
Rarely clicked
Filter options N/A (use useNavigate ) Same page, different params
When NOT to Replace <a>
Keep native <a href> for:
-
External links (https://... to other domains)
-
Checkout redirects (VTEX checkout is on a different domain)
-
Download links (href pointing to files)
-
Anchor links (#section-id )
-
mailto: / tel: links
Discovery Command
rg '<a\s+href=' src/components/ src/sections/ --glob '*.tsx' -l
Gotcha: VTEX URLs Are Absolute
VTEX APIs return absolute URLs. Always convert:
import { relative } from "@decocms/apps/commerce/sdk/url";
<Link to={relative(product.url) ?? product.url} preload="intent"> {product.name} </Link>
Pattern 2: Type-Safe Params
TanStack Router generates types from your route tree. Use them.
Route Definition
// src/routes/produto/$slug.tsx export const Route = createFileRoute("/produto/$slug")({ loader: async ({ params }) => { // params.slug is typed as string — guaranteed by the router const product = await loadProduct({ data: params.slug }); if (!product) throw notFound(); return product; }, component: ProductPage, });
Linking with Type Safety
// TypeScript catches wrong params at compile time <Link to="/produto/$slug" params={{ slug: product.slug }}> {product.name} </Link>
// ERROR: 'id' does not exist in type { slug: string } <Link to="/produto/$slug" params={{ id: "123" }}>
For Deco CMS Routes (Catch-All)
Deco sites use a catch-all route /$ that resolves CMS pages. Links to CMS pages use plain paths:
<Link to={/${categorySlug}} preload="intent">
{category.name}
</Link>
Pattern 3: activeProps for Menus
Automatically style the current page link.
Basic Usage
<Link to="/dashboard" activeProps={{ className: "font-bold text-primary border-b-2 border-primary" }} inactiveProps={{ className: "text-base-content/60" }}
Dashboard </Link>
Navigation Menu (Real Example)
function NavItem({ href, label }: { href: string; label: string }) { return ( <Link to={href} preload="intent" activeProps={{ className: "text-primary font-bold" }} activeOptions={{ exact: false }} className="text-sm hover:text-primary transition-colors" > {label} </Link> ); }
function NavBar({ items }: { items: Array<{ href: string; label: string }> }) { return ( <nav className="flex gap-4"> {items.map((item) => ( <NavItem key={item.href} {...item} /> ))} </nav> ); }
activeOptions
activeOptions={{ exact: true, // Only active on exact path match (not children) includeSearch: true, // Include search params in matching }}
Pattern 4: Search State as URL Source of Truth
Instead of managing filter/sort/pagination state in React state or signals, use the URL as the single source of truth.
The Problem with React State for Filters
// BAD: State is lost on page refresh, not shareable, no back-button support const [sort, setSort] = useState("price:asc"); const [filters, setFilters] = useState({}); const [page, setPage] = useState(1);
The TanStack Way: URL = State
// Link that preserves existing search params and adds/changes one <Link to="." search={(prev) => ({ ...prev, page: 2, })}
Próxima página </Link>
// Link that adds a filter <Link to="." search={(prev) => ({ ...prev, "filter.brand": "espacosmart", })} preload="intent"
Espaço Smart </Link>
// Link that changes sort while keeping filters <Link to="." search={(prev) => ({ ...prev, sort: "price:asc", })}
Menor Preço </Link>
Benefits
-
Shareable: Copy URL → paste → same exact view
-
Back button: Browser history just works
-
SEO: Crawlers see the filter/sort URLs
-
SSR: Server renders the correct results on first load
-
No state management needed: No Zustand, no signals, no context
Reading Search Params in Components
function SearchResult() { const { sort, q, page } = Route.useSearch(); // sort, q, page are typed based on route validation }
Validating Search Params (Advanced)
import { z } from "zod";
const searchSchema = z.object({ q: z.string().optional(), sort: z.enum(["price:asc", "price:desc", "name:asc", "relevance:desc"]).optional(), page: z.number().int().positive().optional().default(1), "filter.brand": z.string().optional(), "filter.price": z.string().optional(), });
export const Route = createFileRoute("/s")({ validateSearch: searchSchema, loaderDeps: ({ search }) => search, loader: async ({ deps }) => { return loadSearchResults({ data: deps }); }, });
Now search params are type-safe and validated.
Pattern 5: window.location Mutations to useNavigate
Problem
// FRESH — forces full page reload window.location.search = params.toString(); window.location.href = newUrl; globalThis.window.location.search = params.toString();
Solution
import { useNavigate } from "@tanstack/react-router";
function Sort() { const navigate = useNavigate();
const applySort = (e: React.ChangeEvent<HTMLSelectElement>) => { const params = new URLSearchParams(window.location.search); params.set("sort", e.currentTarget.value); navigate({ search: Object.fromEntries(params) }); }; }
With Debounce (Price Range Sliders)
const navigate = useNavigate(); const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const applyPrice = (min: number, max: number) => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const params = new URLSearchParams(window.location.search);
params.set("filter.price", ${min}:${max});
navigate({ search: Object.fromEntries(params) });
}, 500);
};
Discovery
rg 'window.location.(search|href)\s*=' src/ --glob '.{tsx,ts}' rg 'globalThis.window.location' src/ --glob '.{tsx,ts}'
Pattern 6: Form Submissions
Search Forms (Navigate with Query Params)
import { useNavigate } from "@tanstack/react-router";
function SearchForm({ action = "/s", name = "q" }) { const navigate = useNavigate();
return ( <form action={action} onSubmit={(e) => { e.preventDefault(); const q = new FormData(e.currentTarget).get(name)?.toString(); if (q) navigate({ to: action, search: { q } }); }} > <input name={name} /> <button type="submit">Search</button> </form> ); }
Keep action as fallback for no-JS/crawlers.
Action Forms (Server Mutations)
Forms that POST data (newsletter, contact, shipping calc) use createServerFn :
import { createDocument } from "~/lib/vtex-actions-server";
function Newsletter() { const [loading, setLoading] = useState(false); const [message, setMessage] = useState("");
return ( <form onSubmit={async (e) => { e.preventDefault(); const email = new FormData(e.currentTarget).get("email")?.toString(); if (!email) return; try { setLoading(true); await createDocument({ data: { entity: "NW", dataForm: { email } } }); setMessage("Cadastrado com sucesso!"); } catch (err: any) { setMessage("Erro: " + err.message); } finally { setLoading(false); setTimeout(() => setMessage(""), 3000); } }}> <input name="email" type="email" required /> <button type="submit" disabled={loading}> {loading ? "Enviando..." : "Inscrever"} </button> {message && <p>{message}</p>} </form> ); }
Pattern 7: Route loaderDeps for Reactive Search Params
Problem
After converting to useNavigate , the URL changes but the page content doesn't update.
Root Cause
TanStack Router only re-runs a loader when its dependencies change. By default: path params only, NOT search params.
Solution
export const Route = createFileRoute("/$")({ loaderDeps: ({ search }) => ({ search }),
loader: async ({ params, deps }) => { const basePath = "/" + (params._splat || ""); const searchStr = deps.search ? "?" + new URLSearchParams(deps.search as Record<string, string>).toString() : "";
const page = await loadCmsPage({ data: basePath + searchStr });
if (!page) throw notFound();
return page;
}, });
Pass Search Params to Section Loaders
The request passed to section loaders must include search params:
const loadCmsPage = createServerFn({ method: "GET" }).handler(async (ctx) => { const fullPath = ctx.data as string; const [basePath] = fullPath.split("?"); const serverUrl = getRequestUrl(); const urlWithSearch = fullPath.includes("?") ? new URL(fullPath, serverUrl.origin).toString() : serverUrl.toString();
const request = new Request(urlWithSearch, { headers: getRequest().headers }); const page = await resolveDecoPage(basePath, matcherCtx); const enrichedSections = await runSectionLoaders(page.resolvedSections, request); return { ...page, resolvedSections: enrichedSections }; });
Pattern 8: Programmatic Preloading
For advanced flows (barcode scanner, autocomplete selection, keyboard navigation):
import { useRouter } from "@tanstack/react-router";
function BarcodeScanner() { const router = useRouter();
const onScan = async (code: string) => { const slug = await resolveBarcode(code);
// Preload the product page while showing feedback
await router.preloadRoute({
to: "/produto/$slug",
params: { slug },
});
// Navigate — page is already loaded, opens instantly
router.navigate({
to: "/produto/$slug",
params: { slug },
});
}; }
Preload on Autocomplete Hover
function SearchSuggestion({ product }) { const router = useRouter(); const url = relative(product.url);
return ( <Link to={url} onMouseEnter={() => { router.preloadRoute({ to: url }); }} > {product.name} </Link> ); }
Pattern 9: <select> with selected to defaultValue
Problem
// FRESH/Preact — works but React warns <option value={value} selected={value === sort}>{label}</option>
Solution
<select defaultValue={sort} onChange={applySort}> {options.map(({ value, label }) => ( <option key={value} value={value}>{label}</option> ))} </select>
SSR + SEO Best Practices
Every Page is SSR by Default
TanStack Start renders on the server first. No extra config needed. But optimize:
- Head metadata from loader data:
export const Route = createFileRoute("/$")({ head: ({ loaderData }) => ({ meta: [ { title: loaderData?.seo?.title ?? "Espaço Smart" }, { name: "description", content: loaderData?.seo?.description ?? "" }, ], links: loaderData?.seo?.canonical ? [{ rel: "canonical", href: loaderData.seo.canonical }] : [], }), });
- Structured data in sections (JSON-LD runs server-side, no hydration needed):
function ProductSection({ product }) { return ( <> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ "@context": "https://schema.org", "@type": "Product", name: product.name, // ... }), }} /> <div>{/* product UI */}</div> </> ); }
- Internal links as <Link> — crawlers follow them AND users get SPA navigation:
<Link to={relative(product.url)} preload="intent"> <img src={product.image} alt={product.name} /> <span>{product.name}</span> </Link>
Complete Migration Checklist
Navigation Links
-
Product card <a href> → <Link to preload="intent">
-
Category/NavItem <a href> → <Link to preload="intent">
-
Breadcrumb <a href> → <Link to>
-
Filter options <a href> → <Link to> (same-page search param change)
-
Search suggestions <a href> → <Link to preload="intent">
-
Footer internal links → <Link to>
Mutations
-
Sort window.location.search = → useNavigate
-
PriceRange window.location.search = → useNavigate with debounce
-
SearchBar <form action> → onSubmit
- useNavigate
- Newsletter form → onSubmit
- createServerFn
Route Configuration
-
$.tsx has loaderDeps: ({ search }) => ({ search })
-
$.tsx passes search params to section loaders via Request URL
-
<select> uses defaultValue instead of <option selected>
Verification
Internal links that are still <a> (should be <Link>):
rg '<a\s+href="/' src/components/ src/sections/ --glob '*.tsx' -l
window.location mutations (should be useNavigate):
rg 'window.location.(search|href)\s*=' src/ --glob '*.{tsx,ts}'
Forms without onSubmit (should have handler):
rg '<form[^>]action=' src/ --glob '.tsx' | rg -v 'onSubmit'
Quick Reference Card
Fresh Pattern TanStack Pattern Benefit
<a href={url}>
<Link to={url} preload="intent">
Instant navigation
window.location.search = x
navigate({ search })
No reload, keeps state
<form action="/s">
onSubmit + useNavigate
SPA navigation
<form action="/" method="POST">
onSubmit + createServerFn
Server mutation
<option selected>
<select defaultValue>
React-compatible
CSS active class manually activeProps={{ className }}
Automatic
No prefetch preload="intent"
Data ready before click
req.url in loader loaderDeps + deps.search
Reactive to URL changes
router.push(url)
router.preloadRoute + navigate
Preload then navigate
Related Skills
Skill Purpose
deco-to-tanstack-migration
Full migration playbook (imports, signals, framework)
deco-islands-migration
Eliminating the islands/ directory
deco-tanstack-storefront-patterns
Runtime patterns and fixes post-migration
deco-storefront-test-checklist
Context-aware QA checklist generation