deco-tanstack-search

Complete reference for implementing search in Deco storefronts running on TanStack Start / React / Cloudflare Workers.

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-search" with this command: npx skills add decocms/deco-start/decocms-deco-start-deco-tanstack-search

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:

  1. Is the URL correct?

Expected: /s?q=telha Check: Browser address bar after search submit

  1. 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" } } }

  1. 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

  1. 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"

  1. 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&...

  1. 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

  1. 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 }}>

  1. 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.

  1. 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"

  1. ignoreSearchParams filtering out q

If ignoreSearchParams includes "q" , search will never work. Only ignore client-side-only params.

  1. 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.

  1. 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)

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-storefront-test-checklist

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-e2e-testing

No summary provided by upstream source.

Repository SourceNeeds Review
General

deco-tanstack-navigation

No summary provided by upstream source.

Repository SourceNeeds Review