SEO
Learn how the rule-based SEO audit scores a page, which models keep slug-redirect history, and what the AIO report checks for machine readers.
Both the SEO audit and the AIO report are rule engines. Neither calls a model — the source is explicit that these produce checkable facts rather than generated prose.
That choice buys reproducibility. A rule engine gives the same page the same score twice, and every point lost is traceable to a named rule with a fix hint attached.
Both engines are pure. No database, no crawling, no network — they judge data the app already has.
The SEO audit
lib/seo/audit.ts scores a page out of 100 against fixed rules.
The thresholds live in one object, SEO_LIMITS, so they are trivial to tune:
titleMin 30
titleMax 60
descriptionMin 70
descriptionMax 160
bodyMin 50bodyMin is the thin-content floor: below 50 characters, on-page copy counts as thin.
Every issue carries a severity, and severity is what costs points:
error 25
warning 10
info 3seoScore subtracts the total penalty from 100 and clamps at zero. A missing title and a missing description alone therefore take a page to 50.
auditSeo is the one source of truth for "is this page's SEO healthy?" The per-entity checklist row and the global /admin/seo report both call it, so the two can never disagree — a product's checklist cannot read green while the report lists it as broken.
Cross-entity checks are the exception, because a single subject cannot see them. Duplicate titles, duplicate descriptions and duplicate body copy are computed by the report and passed into the engine as flags on the subject.
The engine also knows where a rule does not apply. A blog post's headline is its title by design, so title_uncustomised — the rule that fires when a meta title is just the entity's name — is skipped for blog posts and applies only to catalog entities.
The seven checklist sections
The checklist groups the issue codes into seven sections:
- Missing metadata — a missing title or description, the only section marked critical
- Title quality — short, long, or uncustomised titles
- Duplicate titles
- Descriptions — short or long
- Page copy — thin content
- URL slugs — uppercase, underscores, whitespace or double hyphens
- Image previews — no image, or an image with no alt text
A score is therefore reproducible: the same page scores the same way twice, and a change in score is traceable to a rule.
Slug history
Renaming a slug folds the old value into previousSlugs, and the old URL then issues a permanent redirect, with chains resolving in one hop.
There is no redirect table. Each slug-addressed entity carries its own history on its own row, which is what makes a chain of renames cheap:
/product/a → renamed to b → renamed to c
previousSlugs on the row: ["a", "b"]
/product/a → 301 → /product/cThe history is written by slugHistoryPatch as part of the same update that changes the slug, so a rename can never miss its own redirect.
A rename back to a previous value drops that value from the history rather than leaving it behind — after a → b → a, the slug a is current and redirects nowhere.
resolveSlugRedirectUrl is called only on the 404 path, after a normal slug lookup has already missed. The array-contains scan is unindexed, and putting it behind the miss keeps it off normal traffic entirely.
A deleted entity takes its history with it. Soft-deleted rows are filtered by the global soft-delete extension, so their old URLs honestly 404 instead of redirecting into nothing.
Categories are excluded from slug redirects
previousSlugs exists on Product, Collection and Page only. Renaming a category slug does not leave a redirect behind, so an inbound link to the old category URL breaks.
The exclusion is deliberate rather than an omission: a category's URL is a slug path built from its parents and its own slug, with descendant cascades, so preserving old category URLs is a different design problem from swapping one key on one row.
Sitemap and feeds
The sitemap revalidates hourly and caps each type at 50,000 URLs.
That cap is the sitemaps.org per-file protocol maximum, not an arbitrary large number standing in for "all". A catalog large enough to exceed it needs a sitemap index; until then, bounding at the spec limit is better than silently dropping URLs at some lower number.
Non-rendering and noindex paths are kept out of the sitemap by prefix:
/checkout
/cart
/account
/dashboard
/auth
/404
/searchA stored page whose route falls under one of those must never leak into the sitemap, because robots.ts disallows them.
A site-wide noindex setting and a robots disallow list are both available. seo_noindex_site flips robots.txt to disallow everything, which is what a staging deployment wants; otherwise the disallow list covers /admin/, /auth/ and /dashboard/. Both variants point at the dynamic sitemap and use the real site URL.
RSS is real and served from:
/rss/products.xml
/rss/collections.xmlAIO
AIO is about machine readers — assistants that need discrete, checkable facts rather than marketing copy.
It renders as a stat group and four AI · checklist rows inside /admin/seo; it has no route of its own.
The four rows are structured specs, intent tags, recommendation graph, and category coverage. They reuse the same SeoIssue shape as the SEO audit, which is why they render through the same report machinery.
Its rules come from lib/aio/:
- A peer norm is a property key declared by at least half of a category that has at least three products. Missing one is what the report flags.
aio_min_spec_countdefaults to 3 — the number of specifications a product should carry before it reads as described.- Property values are capped at 60 per product, and keys are de-duplicated case-insensitively.
auditAioProduct is generic over attribute keys. It never names a specific attribute, because specifications are an arbitrary key-to-values kernel — whatever the operator grew into it flows through untouched. The peer analysis happens in the report layer and is handed to the engine as peerGapKeys, keeping the engine itself pure.
The three-product floor is what stops a norm being inferred from nothing. One product in a category would make its own keys the norm and every other category's products look deficient by comparison.
Discrete, not prose
lib/aio/attributes.ts is the one place the product graph becomes machine-readable pairs, for both JSON-LD additionalProperty and the merchant feed.
A multi-value axis becomes several pairs rather than one comma-joined string:
Size = S / M / L
→ { name: "Size", value: "S" }
{ name: "Size", value: "M" }
{ name: "Size", value: "L" }An AI reads three structured facts there, not one sentence it has to parse. Curated specifications win over variant options when a key appears in both.
The 60-value cap exists so a pathological product — many axes times many values — cannot bloat a JSON-LD document. Real catalogs sit far below it.
What the two AI surfaces share
Only the return policy is genuinely shared between the product page's structured data and the merchant feed, through lib/aio/commerce.ts. Shipping details are not yet extracted, and price and availability are computed separately in each surface.
merchantReturnPolicy returns a schema.org MerchantReturnPolicy built from the operator's return window, and returns nothing at all when the window is zero or negative. It never emits a fabricated policy to fill the field.
Where a change goes
Change SEO_LIMITS when the argument is about thresholds — how long a title should be, how much copy counts as thin. One edit moves the checklist row, the report, and the fix hints together, because they all read the same constants.
Add an issue code to auditSeo when a genuinely new rule is being enforced, and add it to a checklist group so it has somewhere to surface.
Put cross-entity logic in the report layer, not the engine. Anything that needs to compare two entities — duplicates, peer norms — arrives as a flag so both engines stay pure and testable.
Add to lib/aio/ when the audience is a machine reader rather than a search engine, and keep it generic over attribute keys.
The question worth asking before adding a rule is not "would this improve rankings?" It is:
Can this be checked from data the store already has, and can an operator see exactly why it failed?
Related
Search
Learn how storefront search finds candidates, why trigram matching runs in JavaScript instead of pg_trgm, and how match strength is scored.
Composition
Learn how composed storefront URLs resolve through one resolver, how layout rows store geometry, and what Save and Publish each write.
AI
Learn which three AI providers are wired, how the failover chain works, and which AI features the repository does and does not ship.
Search
Learn how storefront search finds candidates, why trigram matching runs in JavaScript instead of pg_trgm, and how match strength is scored.
Events
Learn how the 56 domain events publish through Inngest, how handlers stay idempotent, what the crons do, and why delivery is best-effort rather than guaranteed.