Deco TanStack Search
Complete reference for implementing search in Deco storefronts running on TanStack Start / React / Cloudflare Workers.
When to Use This Skill
-
Implementing or debugging search (/s?q=... ) pages
-
Fixing "search returns no results" or "search page shows 404"
-
Adding filter support to PLP/search pages
-
Debugging pagination or sort not working
-
Porting search from Fresh/Deno to TanStack Start
-
Understanding how URL parameters flow from the browser to VTEX Intelligent Search API
Architecture: The Search Data Flow
The search flow spans four layers. Understanding each layer is critical for debugging.
┌──────────────────────────────────────────────────────────────────┐ │ 1. BROWSER — SearchBar component │ │ User types "telha" → form submits │ │ navigate({ to: "/s", search: { q: "telha" } }) │ │ URL becomes: /s?q=telha │ ├──────────────────────────────────────────────────────────────────┤ │ 2. TANSTACK ROUTER — cmsRouteConfig in $.tsx │ │ loaderDeps extracts search params: { q: "telha" } │ │ loader builds fullPath: "/s?q=telha" │ │ Calls loadCmsPage({ data: "/s?q=telha" }) │ ├──────────────────────────────────────────────────────────────────┤ │ 3. @decocms/start — CMS resolution pipeline │ │ findPageByPath("/s") → matches CMS page with path: "/s" │ │ matcherCtx.url = "http://localhost:5173/s?q=telha" │ │ resolve.ts injects __pagePath="/s" and __pageUrl="...?q=telha"│ │ into commerce loader props │ ├──────────────────────────────────────────────────────────────────┤ │ 4. @decocms/apps — VTEX productListingPage loader │ │ Reads query from: props.query ?? __pageUrl.searchParams("q") │ │ Reads sort from: props.sort ?? __pageUrl.searchParams("sort") │ │ Reads page from: props.page ?? __pageUrl.searchParams("page") │ │ Reads filters from: __pageUrl filter.* params │ │ Calls VTEX Intelligent Search API │ └──────────────────────────────────────────────────────────────────┘
Layer 1: SearchBar Component
Correct Pattern (TanStack Router)
import { useNavigate, Link } from "@tanstack/react-router";
function Searchbar({ 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} placeholder="Buscar..." /> <button type="submit">Buscar</button> </form> ); }
Suggestion Links — Correct vs Wrong
// WRONG — query string embedded in path, TanStack Router doesn't parse it
<Link to={/s?q=${query}}>Buscar por "{query}"</Link>
// CORRECT — search params as separate object <Link to="/s" search={{ q: query }}>Buscar por "{query}"</Link>
Why it matters: TanStack Router treats to as a path. If you embed ?q=telha in the path, the router navigates to a path literally named /s?q=telha instead of /s with search param q=telha . The loaderDeps function receives an empty search object and no query reaches the API.
Autocomplete / Suggestion Links
Category/product suggestion links should also use TanStack Router <Link> :
{autocomplete.map(({ name, slug }) => (
<Link to={/${slug}} preload="intent">{name}</Link>
))}
Layer 2: Route Configuration ($.tsx)
The catch-all route uses cmsRouteConfig from @decocms/start/routes :
// src/routes/$.tsx import { createFileRoute, notFound } from "@tanstack/react-router"; import { cmsRouteConfig, loadDeferredSection } from "@decocms/start/routes"; import { DecoPageRenderer } from "@decocms/start/hooks";
const config = cmsRouteConfig({ siteName: "My Store", defaultTitle: "My Store - Products", ignoreSearchParams: ["skuId"], // Do NOT ignore: q, sort, page, filter.* });
export const Route = createFileRoute("/$")({ loaderDeps: config.loaderDeps, loader: async (ctx) => { const page = await config.loader(ctx); if (!page) throw notFound(); return page; }, component: CmsPage, // ... });
Critical: ignoreSearchParams
ignoreSearchParams controls which URL params are excluded from loaderDeps . When a param is ignored, changing it does NOT trigger a server re-fetch.
Never ignore: q , sort , page , fuzzy , any filter.* param.
Safe to ignore: skuId (variant selection is client-side), utm_* , gclid .
How loaderDeps works
loaderDeps: ({ search }) => { // search = { q: "telha", sort: "price:asc" } // After filtering ignoreSearchParams: return { search: { q: "telha", sort: "price:asc" } }; }
The loader receives deps.search and builds the full path:
const basePath = "/" + (params._splat || ""); // "/s" const searchStr = "?" + new URLSearchParams(deps.search).toString(); // "?q=telha&sort=price:asc" loadCmsPage({ data: "/s?q=telha&sort=price:asc" });
Layer 3: CMS Resolution (@decocms/start)
Page Matching
findPageByPath("/s") searches CMS blocks for a page with path: "/s" .
Prerequisite: The CMS must have a page block with "path": "/s" — typically pages-search-*.json in .deco/blocks/ .
If the page block is missing from blocks.gen.ts , search will 404. Regenerate with:
npm run generate:blocks
or
npx tsx node_modules/@decocms/start/scripts/generate-blocks.ts
__pageUrl Injection
In resolve.ts , when a commerce loader is called:
if (rctx.matcherCtx.path) { resolvedProps.__pagePath = rctx.matcherCtx.path; // "/s" } if (rctx.matcherCtx.url) { resolvedProps.__pageUrl = rctx.matcherCtx.url; // "http://localhost:5173/s?q=telha" }
This is how the request URL reaches the commerce loader — not via Request object (as in Fresh), but via injected props.
Layer 4: Commerce Loader (VTEX)
The productListingPage Loader
The loader must read search parameters from __pageUrl as fallback when props.query is not set by the CMS:
export interface PLPProps { query?: string; count?: number; sort?: string; fuzzy?: string; page?: number; selectedFacets?: SelectedFacet[]; hideUnavailableItems?: boolean; __pagePath?: string; __pageUrl?: string; // ← CRITICAL: must be declared }
export default async function vtexProductListingPage(props: PLPProps) { const pageUrl = props.__pageUrl ? new URL(props.__pageUrl, "https://localhost") : null;
// Read from props first (CMS override), then URL (runtime), then default const query = props.query ?? pageUrl?.searchParams.get("q") ?? ""; const count = Number(pageUrl?.searchParams.get("PS") ?? props.count ?? 12); const sort = props.sort || pageUrl?.searchParams.get("sort") || ""; const fuzzy = props.fuzzy ?? pageUrl?.searchParams.get("fuzzy") ?? undefined; const pageFromUrl = pageUrl?.searchParams.get("page"); const page = props.page ?? (pageFromUrl ? Number(pageFromUrl) - 1 : 0); // ... }
Filter Extraction from URL
Users apply filters via URL params like ?filter.category-1=telhas&filter.brand=saint-gobain . The loader must parse these:
if (pageUrl) { for (const [name, value] of pageUrl.searchParams.entries()) { const dotIndex = name.indexOf("."); if (dotIndex > 0 && name.slice(0, dotIndex) === "filter") { const key = name.slice(dotIndex + 1); if (key && !facets.some((f) => f.key === key && f.value === value)) { facets.push({ key, value }); } } } }
Pagination Links Must Preserve URL Params
When building nextPage /previousPage URLs, persist all current URL params (q, sort, filter.*) and only change the page number:
const paramsToPersist = new URLSearchParams(); if (pageUrl) { for (const [k, v] of pageUrl.searchParams.entries()) { if (k !== "page" && k !== "PS" && !k.startsWith("filter.")) { paramsToPersist.append(k, v); } } } else { if (query) paramsToPersist.set("q", query); if (sort) paramsToPersist.set("sort", sort); }
// Filter toggle URLs also need paramsToPersist const filters = visibleFacets.map(toFilter(facets, paramsToPersist));
Key Difference: Fresh/Deno vs TanStack Start
Aspect Fresh/Deno (original) TanStack Start
Request access Loader receives Request directly Loader receives CMS-resolved props
URL reading url.searchParams.get("q")
props.__pageUrl → parse URL
Navigation <a href="/s?q=..."> (full reload) navigate({ to: "/s", search: { q } }) (SPA)
Route matching Deco runtime matches /s
TanStack catch-all /$
- cmsRouteConfig
Param flow Direct from Request URL → loaderDeps → loadCmsPage → matcherCtx → resolve.ts → __pageUrl
CMS Page Block Structure
The search page block (.deco/blocks/pages-search-*.json ) should look like:
{ "name": "Search", "path": "/s", "sections": [ { "page": { "sort": "", "count": 12, "fuzzy": "automatic", "__resolveType": "vtex/loaders/intelligentSearch/productListingPage.ts", "selectedFacets": [] }, "__resolveType": "site/sections/Product/SearchResult.tsx" } ], "__resolveType": "website/pages/Page.tsx" }
Important: The page.query field is intentionally empty/absent. The query comes from the URL at runtime via __pageUrl .
Debugging Checklist
When search is broken, check each layer:
- Is the URL correct?
Expected: /s?q=telha Check: Browser address bar after search submit
- Are search params reaching loaderDeps?
Add a temporary log in $.tsx :
loader: async (ctx) => { console.log("[CMS Route] deps:", ctx.deps); // Should show: { search: { q: "telha" } } }
- Does the CMS page exist?
Check blocks.gen.ts has the search page
grep '"path": "/s"' src/server/cms/blocks.gen.ts
If missing, regenerate:
npm run generate:blocks
- Is __pageUrl being injected?
Add a temporary log in the commerce loader:
console.log("[PLP] __pageUrl:", props.__pageUrl); // Should show: "http://localhost:5173/s?q=telha"
- Is the query reaching VTEX API?
Check terminal output for the Intelligent Search API call:
[vtex] GET .../api/io/_v/api/intelligent-search/product_search/?query=telha&...
- Is the loader returning null?
The loader returns null when both facets and query are empty:
if (!facets.length && !query) { return null; // ← This triggers "no results" / NotFound }
Common Pitfalls
- Query string in Link to prop
// BUG: TanStack Router doesn't parse ?q= from to
<Link to={/s?q=${query}}>
// FIX: Use search prop
<Link to="/s" search={{ q: query }}>
- Missing __pageUrl in loader interface
If PLPProps doesn't declare __pageUrl , TypeScript won't complain (it's injected dynamically), but the loader won't read it.
- blocks.gen.ts out of date
After adding/editing CMS blocks locally, blocks.gen.ts must be regenerated. Automate in package.json :
"dev": "npm run generate:blocks && vite dev"
- ignoreSearchParams filtering out q
If ignoreSearchParams includes "q" , search will never work. Only ignore client-side-only params.
- Shopify vs VTEX patterns
Shopify loader already reads __pageUrl correctly:
const query = props.query || pageUrl.searchParams.get("q") || "";
VTEX initially didn't — this was the root cause of the espacosmart search bug.
- Duplicate search param keys (filters)
VTEX filter URLs use duplicate keys: ?filter.category-1=telhas&filter.category-1=pisos . TanStack Router's search is a plain Record<string, string> — it cannot represent duplicate keys.
Consequences:
-
navigate({ search: Object.fromEntries(params) }) loses all but the last value per key
-
loaderDeps receives a flat object, so the loader builds a URL with collapsed params
Solution: Use plain <a href={url}> for filter and pagination links. This triggers a server round-trip (like the original Fresh site), but the real request URL preserves all params. The cmsRoute.ts loadCmsPageInternal prefers getRequestUrl() over the loaderDeps -built path, so the commerce loader receives the full URL via __pageUrl .
// WRONG — navigate({search}) collapses duplicate keys <Link to="." search={parsedParams}>Filter</Link>
// WRONG — TanStack Router treats to as a path, not a relative URL
<Link to="?filter.category-1=telhas&q=telha">Filter</Link>
// CORRECT — plain <a href> preserves full query string <a href="?filter.category-1=telhas&q=telha">Filter</a>
Sort (single key) can safely use navigate({ search }) since there are no duplicate keys.
Related Skills
-
deco-tanstack-storefront-patterns — General runtime patterns for TanStack storefronts
-
deco-apps-vtex-porting — Full guide for porting VTEX loaders to apps-start
-
deco-tanstack-navigation — Navigation patterns including Link, prefetch, search params
-
deco-tanstack-hydration-fixes — Fixing hydration and flash-of-white issues
-
deco-cms-route-config — Deep dive into cmsRouteConfig and route helpers
Files Reference
File Layer Purpose
src/components/search/SearchBar.tsx
Browser Search input, form submit, suggestion links
src/routes/$.tsx
Router Catch-all route with cmsRouteConfig
deco-start/src/routes/cmsRoute.ts
Framework loaderDeps , loadCmsPage , URL construction
deco-start/src/cms/resolve.ts
Framework __pageUrl /__pagePath injection into loaders
apps-start/vtex/inline-loaders/productListingPage.ts
Commerce VTEX IS API call, URL param reading
.deco/blocks/pages-search-*.json
CMS Page definition for /s route
src/server/cms/blocks.gen.ts
Build Compiled CMS blocks (must include search page)