Category-Deep-Linking-And-Header
11 - Category Deep-Linking & Category Header
Written for AI agents. See Log Methodology Note below for details.
Status: Draft
Background
Design Log 10 introduced category-prefixed product routes. The product-search component supports scoping to a top-level category, showing child categories as filters. However it lacks sub-category deep-linking, category metadata display (image, description, breadcrumbs, SEO), and configurable URL patterns.
The Three URL Parameters
Every URL in a category-aware store can be built from three parameters:
| Param | Source | Example |
|---|---|---|
{prefix} |
Top-level category slug (from Wix API, category ID in config) | polgat, kitan |
{category} |
Sub-category slug (from Wix API, resolved from mainCategoryId) |
shirts, bedroom |
{slug} |
Product slug (from Wix API) | blue-shirt, cotton-sheets |
These three params combine into URL patterns. The site implementor chooses their pattern via:
- Filesystem routes — which directories and
[param]folders to create - URL config — how the component builds canonical links
URL Pattern Catalog
| Pattern | Product URL | Category URL | Top-Level URL | Use Case |
|---|---|---|---|---|
| Flat | /products/{slug} |
— | — | Simple store, no categories |
| Categories only | /products/{category}/{slug} |
/products/{category} |
— | Categories without top-level prefixes |
| Prefix + flat | /products/{prefix}/{slug} |
— | /products/{prefix} |
DL10 current behavior |
| Prefix + categories | /products/{prefix}/{category}/{slug} |
/products/{prefix}/{category} |
/products/{prefix} |
Full setup |
| Custom paths | /{prefix}/{category}/{slug} |
/{prefix}/{category} |
/{prefix} |
No /products prefix |
These are common patterns — any combination of {prefix}, {category}, and {slug} is possible. The headless components receive params and don't impose URL structure. The site implementor is free to create any pattern that fits their needs.
Static overrides via Jay routing
Jay's filesystem routing supports static segments that override dynamic params. This enables per-prefix, per-category, and per-product template overrides:
Per-prefix override — different design for a specific top-level category:
src/pages/products/
├── [prefix]/
│ └── page.jay-html → default design for any prefix
├── polgat/
│ └── page.jay-html → custom design only for polgat
Per-category override — special page for a specific sub-category:
src/pages/products/polgat/
├── [category]/
│ └── page.jay-html → default sub-category design
├── sale/
│ └── page.jay-html → custom design for the "sale" sub-category
Per-product override — dedicated page for a featured product:
src/pages/products/polgat/shirts/
├── [slug]/page.jay-html → default product page
├── premium-oxford/page.jay-html → custom page for one specific product
In each case, the static directory takes routing precedence over the dynamic [param] directory. The override template can use jay-params to set the param value explicitly, or bind a completely different set of components.
Combining dynamic and static at multiple levels:
src/pages/products/
├── page.jay-html → /products (all products search)
├── [prefix]/
│ ├── page.jay-html → /products/:prefix (dynamic prefix)
│ └── [category]/
│ ├── page.jay-html → /products/:prefix/:category
│ └── [slug]/page.jay-html → /products/:prefix/:category/:slug
├── polgat/
│ ├── page.jay-html → /products/polgat (static override)
│ ├── shirts/
│ │ └── page.jay-html → /products/polgat/shirts (static override)
│ └── [category]/
│ ├── page.jay-html → /products/polgat/:category (other categories)
│ └── [slug]/page.jay-html → /products/polgat/:category/:slug
└── kitan/
├── page.jay-html → /products/kitan (static override)
└── [category]/
├── page.jay-html → /products/kitan/:category
└── [slug]/page.jay-html → /products/kitan/:category/:slug
This enables a fully generic [prefix] route for categories that don't need custom designs, alongside static overrides (polgat/, kitan/) for categories that do — all coexisting in the same route tree.
Do We Need categoryPrefixes Config?
No. Each concern it addressed is already handled by other mechanisms:
Template routing → Filesystem routes + jay-params
Creating src/pages/products/polgat/page.jay-html with <script type="application/jay-params">category: polgat</script> IS the configuration. The route defines the page, the jay-params tells the component which category to scope to. No config file needed.
Root scoping → Component receives category param from route
The component receives props.category (from jay-params or URL param). It looks up the category by slug via the Wix API. If found, scopes to it. No pre-configured IDs needed.
Product URL generation → Walk the category tree from API
When {prefix} is in the URL template, the component resolves it at build time by walking up from the product's mainCategoryId to find the root category. Uses the root's slug as {prefix}. Discovered from the API, not config.
Which categories are "roots"? → Routes decide
The site implementor creates routes for the categories they want. Products with root categories that don't have matching routes simply don't get pages (no route = no page generated by the framework). No config needed to "select" root categories.
Conclusion: The only remaining config is urls — how to build canonical links. Everything else comes from filesystem routes + Wix API.
Config Structure
# config/.wix-stores.yaml
# URL templates for link generation (how the component builds <a href="...">)
urls:
product: '/products/{slug}' # default, no categories in URL
category: null # no category deep-linking by default
That's it. No categoryPrefixes. The filesystem routes and jay-params handle everything else.
Example configs for each pattern:
Simple store (no categories in URLs):
urls:
product: '/products/{slug}'
Categories in URLs (no top-level prefixes):
urls:
product: '/products/{category}/{slug}'
category: '/products/{category}'
Top-level prefix + flat products:
urls:
product: '/products/{prefix}/{slug}'
Top-level prefix + sub-categories (full setup):
urls:
product: '/products/{prefix}/{category}/{slug}'
category: '/products/{prefix}/{category}'
Config Type
interface WixStoresConfig {
urls?: {
/** URL template for product pages. Default: "/products/{slug}" */
product?: string;
/** URL template for category pages. Not set = no category pages */
category?: string;
};
/** Slug of the fallback category for pages without category context */
defaultCategory?: string;
}
How the component discovers category data
The component resolves category information using a fallback chain with inheritance:
Resolution order:
subcategoryparam — if set, load the sub-category by slugcategory/prefixparam — if set, load the root category by slugdefaultCategoryfrom config — fallback for pages without category context (e.g.,/productswith no prefix)
# config/.wix-stores.yaml
urls:
product: '/products/{prefix}/{category}/{slug}'
category: '/products/{prefix}/{category}'
defaultCategory: 'all-products' # slug of the fallback category
Metadata inheritance: If the resolved category doesn't have image, description, or SEO data, the component walks up the parent chain until it finds the data:
- Sub-category missing image → check parent category → check grandparent → ... → check default category
- Each field (image, description, SEO) inherits independently
This means a sub-category page can inherit the parent category's hero image while showing its own name and description, and a deeply nested category inherits from its nearest ancestor that has the data.
For product URL generation, when {prefix} or {category} are in the template:
- Fetches product with
ALL_CATEGORIES_INFO - Uses
mainCategoryIdto resolve{category}slug - Walks up the parent chain to find the root category for
{prefix}slug - All data comes from the API — no config mapping needed
Component Design
Three Params in Components
interface ProductSearchParams extends UrlParams {
category?: string; // prefix slug (e.g., 'polgat') — resolved from URL or jay-params
subcategory?: string; // sub-category slug (e.g., 'shirts') — from [category] route param
}
interface ProductPageParams extends UrlParams {
slug: string; // product slug
category?: string; // prefix slug
subcategory?: string; // sub-category slug — for canonical URL validation
}
URL Generation
Two functions, driven by config templates:
function buildProductUrl(
template: string,
slug: string,
categorySlug: string | null,
prefix: string | null,
): string {
let url = template;
url = url.replace('{slug}', slug);
if (categorySlug) url = url.replace('{category}', categorySlug);
if (prefix) url = url.replace('{prefix}', prefix);
// If any placeholder remains unresolved, return null (product can't be linked)
return url.includes('{') ? null : url;
}
function buildCategoryUrl(
template: string | null,
categorySlug: string,
prefix: string | null,
): string | null {
if (!template) return null;
let url = template;
url = url.replace('{category}', categorySlug);
if (prefix) url = url.replace('{prefix}', prefix);
return url.includes('{') ? null : url;
}
Canonical URLs & Redirects
The urls config defines what the canonical URL looks like. The component enforces canonical URLs via 301 redirects when a page is accessed at a non-canonical path.
Product canonical URL:
- Determined by
urls.producttemplate + product'smainCategoryId(for{category}) + root category (for{prefix}) - The product is identified by its slug — that's the lookup key
- If the URL has the wrong
{category}or{prefix}for this product, redirect 301 to the canonical URL
Example: product "blue-shirt" has mainCategoryId pointing to "shirts" under "polgat"
- Canonical:
/products/polgat/shirts/blue-shirt /products/polgat/pants/blue-shirt→ 301 redirect (wrong category)/products/kitan/shirts/blue-shirt→ 301 redirect (wrong prefix)
Category canonical URL:
- Determined by
urls.categorytemplate + category's slug + parent chain (for{prefix}) - The category is identified by its slug — that's the lookup key
- If the URL has the wrong
{prefix}for this category, redirect 301 to the canonical URL
Example: category "shirts" is a child of "polgat"
- Canonical:
/products/polgat/shirts /products/kitan/shirts→ 301 redirect (wrong prefix)
Category Header
The category header is always loaded. The product-search component always resolves a category for the header using the fallback chain:
subcategoryparam → load sub-categorycategory/prefixparam → load root category- Neither → load
defaultCategoryfrom config
Data loaded via getCategory(id, { fields: ['DESCRIPTION', 'BREADCRUMBS_INFO'] }):
- Name, description, image URL (with parent chain inheritance for missing fields)
- Product count
- Breadcrumb trail (with URLs built from config template)
- SEO data
Behavior:
/products/polgat/shirts— sub-category header (name: "shirts", breadcrumbs: polgat > shirts)/products/polgat— root category header (name: "polgat")/products— default category header (fromdefaultCategoryconfig, e.g., "all-products")- Filter checkbox selected — header unchanged, only products filtered
SSG Param Loading
Product params — loadProductParams (existing, extended):
Yields: { slug, category (prefix), subcategory (from mainCategoryId) }
Category params — loadSearchParams (new):
Yields: { category (prefix), subcategory (child category slug) }
for each direct child of each configured prefix root
Contract Additions
Category Header
- tag: categoryHeader
type: sub-contract
description: Active category information for the page header. Populated when navigating to a category via URL.
tags:
- { tag: name, type: data, dataType: string, description: Category display name }
- {
tag: description,
type: data,
dataType: string,
description: Category plain text description,
}
- { tag: imageUrl, type: data, dataType: string, description: Category main image URL }
- {
tag: hasImage,
type: variant,
dataType: boolean,
description: Whether category has an image,
}
- {
tag: productCount,
type: data,
dataType: number,
description: Number of products in category,
}
- tag: breadcrumbs
type: sub-contract
repeated: true
trackBy: categoryId
description: Breadcrumb trail from root to current category
tags:
- { tag: categoryId, type: data, dataType: string, description: Category GUID }
- { tag: name, type: data, dataType: string, description: Category name }
- { tag: slug, type: data, dataType: string, description: Category slug }
- {
tag: url,
type: data,
dataType: string,
description: Full URL path for breadcrumb navigation,
}
- tag: seoData
type: sub-contract
description: Category SEO metadata. Only populated when category is selected via URL.
tags:
- tag: tags
type: sub-contract
repeated: true
trackBy: position
description: SEO tag information
tags:
- {
tag: position,
type: data,
dataType: string,
description: Tag position as two digit string,
}
- { tag: type, type: data, dataType: string, description: SEO tag type }
- tag: props
type: sub-contract
repeated: true
trackBy: key
description: Key-value pair of SEO properties
tags:
- { tag: key, type: data, dataType: string }
- { tag: value, type: data, dataType: string }
- tag: meta
type: sub-contract
repeated: true
trackBy: key
description: SEO tag metadata
tags:
- { tag: key, type: data, dataType: string }
- { tag: value, type: data, dataType: string }
- { tag: children, type: data, dataType: string, description: SEO tag inner content }
- tag: settings
type: sub-contract
description: SEO general settings
tags:
- {
tag: preventAutoRedirect,
type: data,
dataType: boolean,
description: Whether auto-redirect from old URL is enabled,
}
- tag: keywords
type: sub-contract
repeated: true
trackBy: term
description: User-selected keyword terms
tags:
- { tag: term, type: data, dataType: string, description: Keyword value }
- {
tag: isMain,
type: data,
dataType: boolean,
description: Whether this is the main focus keyword,
}
- {
tag: origin,
type: data,
dataType: string,
description: Source that added the keyword,
}
Note: hasCategoryHeader is not needed — the header is always populated (fallback to defaultCategory).
Category Filter Deep-Link
Add to existing filters.categoryFilter.categories items:
- tag: categoryUrl
type: data
dataType: string
description: Deep-link URL to this category's dedicated page (built from urls.category template)
Implementation Plan
Phase 1: Config — URL Templates
- Add
urlssection toWixStoresConfigtype and config loader - Remove
categoryPrefixesfrom config (replaced by routes + jay-params + API) - Add
buildProductUrlandbuildCategoryUrlutility functions - Update
mapProductToCardto use URL templates - Default to
"/products/{slug}"when not configured
Phase 2: Contract & Types
- Add
categoryHeader,hasCategoryHeadertoproduct-search.jay-contract - Add
categoryUrlto category filter items - Update generated
.d.tstypes
Phase 3: Category Discovery from API
- Component resolves category by slug from route param (no config lookup)
- Build child category lookup (categoryId → slug) during slow phase
- Walk parent chain to resolve
{prefix}from root category - Resolve
{category}from product'smainCategoryId - Implement canonical URL redirect for non-canonical subcategory
Phase 4: Category Header Loading
- Load category details via
getCategory(id, { fields: ['DESCRIPTION', 'BREADCRUMBS_INFO'] }) - Build breadcrumb URLs using config templates
- Expose header data through contract
Phase 5: SSG
- Extend
loadProductParamsto yield prefix + subcategory params - Add
loadSearchParamsfor sub-category pages - Filter out empty categories (
itemCounter === 0)
Phase 6: Filter State in URL
- Parse query params in fast render to set initial filter state
- Apply parsed filters to initial
searchProductscall - In interactive phase, update URL query string on filter changes (replaceState)
- Use slugs/values in params, not internal IDs
Phase 7: README & Documentation
- Update wix-stores README with all URL patterns, config options, route examples
- Document category header, filter URL persistence, canonical redirects
- Include setup instructions and jay-params usage
Risks & Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
mainCategoryId changes → product URL changes |
Broken links, SEO impact | 301 redirect from old URL; flat URL pattern avoids this entirely |
Unresolved {placeholder} in URL |
Broken links | buildProductUrl returns null, product skipped with warning |
| Config-route mismatch | Links point to 404 | Document clearly; future: setup validates |
| Empty categories generate pages | Bad SEO | loadSearchParams skips itemCounter === 0 |
| No multi-level nesting | Deep trees not navigable | Acceptable for now; future work |
Filter State in URL Query Parameters
Filters should be persisted in the URL as query parameters so that:
- Users can share/bookmark a filtered view
- Back button restores the previous filter state
- Search engines can index filtered views (optional)
Query Parameter Schema
| Filter | Query Param | Format | Example |
|---|---|---|---|
| Search term | q |
string | ?q=cotton |
| Category filter | cat |
comma-separated slugs | ?cat=shirts,pants |
| Min price | min |
number | ?min=50 |
| Max price | max |
number | ?max=200 |
| In stock only | inStock |
1 |
?inStock=1 |
| Sort | sort |
enum value | ?sort=priceAsc |
All values use slugs or display values, not internal IDs. This makes URLs readable and stable across API changes.
Full example: /products/polgat?q=cotton&cat=shirts&min=50&max=200&sort=priceAsc
Saving Filters to URL
In the interactive phase, when a filter changes, the component updates the URL query string without triggering a page reload:
// On filter change → update URL
function updateUrlFilters(filters, searchTerm, sort) {
const params = new URLSearchParams();
if (searchTerm) params.set('q', searchTerm);
const selectedCategories = filters.categoryFilter.categories
.filter((c) => c.isSelected)
.map((c) => c.categorySlug); // use slugs, not IDs
if (selectedCategories.length) params.set('cat', selectedCategories.join(','));
if (filters.priceRange.minPrice > filters.priceRange.minBound)
params.set('min', String(filters.priceRange.minPrice));
if (filters.priceRange.maxPrice < filters.priceRange.maxBound)
params.set('max', String(filters.priceRange.maxPrice));
if (filters.inStockOnly) params.set('inStock', '1');
if (sort !== 'relevance') params.set('sort', sort);
const query = params.toString();
window.history.replaceState(null, '', query ? `?${query}` : window.location.pathname);
}
Restoring Filters from URL
In the fast render phase (SSR), the component reads query parameters from props.url and applies them as the initial filter state:
function parseUrlFilters(url: string, categories: CategoryInfos): InitialFilters {
const params = new URL(url, 'http://x').searchParams;
return {
searchTerm: params.get('q') || '',
selectedCategorySlugs: params.get('cat')?.split(',') || [],
minPrice: params.has('min') ? Number(params.get('min')) : null,
maxPrice: params.has('max') ? Number(params.get('max')) : null,
inStockOnly: params.get('inStock') === '1',
sort: params.get('sort') || 'relevance',
};
}
The fast render uses these parsed values to:
- Set
searchExpressioninitial value - Pre-select category checkboxes (match slugs to category data)
- Set price range slider positions
- Set inStockOnly checkbox
- Set sort dropdown value
- Execute the initial
searchProductscall with these filters applied
This means a URL like /products/polgat?cat=shirts&min=50&sort=priceAsc renders server-side with the filters already applied — no flash of unfiltered content.
Scope Limitations
- Single level of sub-categories only. Only direct children of the root category are supported as sub-categories. If
mainCategoryIdpoints to a grandchild, the product is skipped. - No category arrangement. Product ordering within a category uses
searchProductsdefault order, not Wix's custom category arrangement.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.