deco-tanstack-navigation

Deco TanStack Navigation Migration

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "deco-tanstack-navigation" with this command: npx skills add decocms/deco-start/decocms-deco-start-deco-tanstack-navigation

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

deco-tanstack-search

No summary provided by upstream source.

Repository SourceNeeds Review
General

deco-e2e-testing

No summary provided by upstream source.

Repository SourceNeeds Review
General

deco-site-memory-debugging

No summary provided by upstream source.

Repository SourceNeeds Review
General

deco-storefront-test-checklist

No summary provided by upstream source.

Repository SourceNeeds Review