Storefront SEO: categories, brands and products
How the storefront builds page titles, descriptions, canonicals and structured data for catalog pages, and what the ERP must provide.
The contract
Section titled “The contract”-
The ERP is the only source of SEO copy. Titles, meta descriptions and social copy are typed into the ERP (Category, Brand and Product edit pages, “SEO” section) and served by the API. The storefront never stores SEO copy of its own: no per-slug maps in storefront code, no copy generated from a template.
-
When a field is empty, the storefront falls back to plain facts, never to marketing phrasing:
Head field Source, in order <title>seo_title; else<name> | <site name>; else<name>when no site name is configuredmeta description seo_meta_description; else whole leading sentences of the description (see below); else omittedog:titleseo_og_title; else the title aboveog:descriptionseo_og_description; else the meta description aboveog:imageseo_og_image_url(categories, brands); else the page’s own imagecanonical category seo_canonical_url; else the page’s own URL onVITE_SITE_URL(or the request origin)The description fallback (
summarizeinstorefront/src/lib/seo-head.ts) takes as many whole sentences as fit in 160 characters. It never cuts a sentence and never adds an ellipsis: a single first sentence of up to 300 characters is used whole, and a longer one is omitted. Category pages summarizedescription, thenshort_description, thenbody_copy. -
The site name comes from tenant config, not code:
VITE_SITE_NAME(andVITE_SITE_URLfor absolute canonicals), read instorefront/src/lib/site-config.ts. Unset, titles carry no suffix, so a deployment that forgets it never shows another store’s name.VITE_SITE_URLis required in production. Without it the canonical andog:urlfall back to the request’s own origin, i.e. whateverHostthe request arrived with: a staging hostname, a CDN origin, the raw app hostname or a spoofed header would each become the page’s canonical. The fallback exists for local development only.
All three page types (product, category, brand) build their head through
buildSeoHead, so the rules above hold everywhere. RouterHead adds its
request-URL canonical only to pages that set none.
| Page | URL | Notes |
|---|---|---|
| category | /c/<slug>/ |
lists its own products and every sub-category’s |
| brand index | /brands/ |
every visible brand |
| brand | /brands/<slug>/ |
the brand’s public products |
| product | /products/<id>/ |
unchanged |
Slugs are the ERP’s (Category.slug, Brand.slug). Later listing pages add
?page=N (the API’s 25-row pages) and are self-canonical. Unknown or hidden
slugs answer 404 with robots: noindex.
Category and brand pages emit BreadcrumbList and CollectionPage JSON-LD;
product pages emit BreadcrumbList. JSON-LD is serialized with < escaped,
so ERP copy can never close the script tag.
The storefront reads the same DRF API it uses for products:
GET /api/products/categories/and/api/products/brands/are public for reads, like/api/products/products/. Anonymous visitors, shoppers and the storefront identity see onlyvisible_on_websiterows (a category is hidden too when any ancestor is, soparent,parent_slugandancestorsnever name a hidden row), throughPublicCategorySerializer/PublicBrandSerializer: display fields, theseo_*copy, image URLs, and for categoriesparent_slug,ancestors(root first) and visiblechildren. Migration provenance (bc_id,erpnext_name,d1_id) is staff-only. Staff, and service accounts (svc-*, API keys) whose role contract allows the read, keep the full rows, hidden ones included. Writes are unchanged (PrincipalPermission).- Both accept
?slug=; the storefront looks pages up that way. GET /api/products/products/?category_slug=<slug>lists the category’s whole subtree;?brand_slug=<slug>one brand. For non-staff readers both resolve against public rows only: a hidden category or brand lists nothing, and the subtree walk skips hidden sub-categories (and everything below them). An unknown slug is an empty page, never the whole catalog. Catalog rows also carrycategory_slugandbrand_slug(null when there is no public page to link to).- A category’s
body_copyis withheld (null) if it carries draft metadata, even when it was written around the model’sclean(). The storefront renders it withstorefront/src/lib/body-copy.ts: paragraphs,##/###,-lists,**bold**and links, all HTML-escaped; only site-relative and http(s) links survive.
ERP warnings
Section titled “ERP warnings”Saving a Brand or Category in /erp/ flashes warnings, not errors
(products/seo_checks.py) when its SEO copy:
- contains a template placeholder (
{name},%s,[brand], …); - ends in an ellipsis, or a description doesn’t end on a full stop;
- is over 60 (title) or 160 (meta description) characters, or a meta description is under 50;
- matches another row’s copy with only the name swapped (“Shop X Equipment | Store” on every category), which reads to search engines as duplicate pages;
- stops mid-sentence on a connector or separator (“…support, and.”,
“…for Solar, Golf,”), per
products/seo_quality.py; - matches one of the deployment’s own generated-copy patterns (below).
The row saves either way. The form help text carries the same length guidance.
Copy-quality rules (products/seo_quality.py)
Section titled “Copy-quality rules (products/seo_quality.py)”One definition of “generated or cut-off copy” for everything that judges a
seo_title / seo_meta_description: the warnings above, and a deployment’s
own restore or audit commands (is_replaceable, is_unusable, problems).
- Rules are data. golden ships tenant-neutral truncation shapes only
(
products/data/seo_quality_defaults.json): no template patterns, since a shop’s filler always names the shop. A deployment lists its own files inGOLDEN_SEO_PATTERN_FILES(same shape,"version": 1):patternsare appended (ids unique across files),truncationlists merge onto the defaults. A bad file isImproperlyConfiguredat startup. - Two severities.
template: the whole value is filler, safe to blank.padding: real copy with a generated phrase added, replaceable but kept. - Two strengths of truncation.
is_cut_off(ends on a connector or a configured cut shape) is certain and makes a value unusable; a meta with no closing punctuation, or ending on a configured soft word, is only possibly truncated, because honest copy ends that way too (“15kW output”). - Patterns mirror to JavaScript (
patterns_payload()): no lookbehind, no named groups, no inline flags;flagsis""or"i". The loader refuses anything else. - Length (60 / 160) is reported, never fixed by cutting.
Variant SEO
Section titled “Variant SEO”ProductVariant.seo_title and seo_meta_description override the parent
product’s on a variant page; blank inherits (get_effective_field). They are
in the variant cards’ “SEO (override)” section. Every ProductVariantForm
field must be listed in VARIANT_SECTIONS: the form uses exclude, so a
field the cards don’t render is posted empty and wipes the stored value on
each product save.