skills/decocms/deco-start/deco-tanstack-search

deco-tanstack-search

SKILL.md

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

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

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

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

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

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

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

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

4. ignoreSearchParams filtering out q

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

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

6. 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)
Weekly Installs
4
First Seen
5 days ago
Installed on
gemini-cli4
claude-code4
github-copilot4
codex4
kimi-cli4
cursor4