<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Camille Guerineau</title>
    <description>The latest articles on DEV Community by Camille Guerineau (@spiritrackingarch).</description>
    <link>https://dev.to/spiritrackingarch</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3986513%2Fab84c618-91c6-4fa5-8c9b-f603e243e1ff.png</url>
      <title>DEV Community: Camille Guerineau</title>
      <link>https://dev.to/spiritrackingarch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/spiritrackingarch"/>
    <language>en</language>
    <item>
      <title>Building a Generative Jewelry Configurator with Vanilla JS, SVG, and Numerology</title>
      <dc:creator>Camille Guerineau</dc:creator>
      <pubDate>Mon, 22 Jun 2026 01:41:38 +0000</pubDate>
      <link>https://dev.to/spiritrackingarch/building-a-generative-jewelry-configurator-with-vanilla-js-svg-and-numerology-7n0</link>
      <guid>https://dev.to/spiritrackingarch/building-a-generative-jewelry-configurator-with-vanilla-js-svg-and-numerology-7n0</guid>
      <description>&lt;p&gt;published: false&lt;br&gt;
description: How GemStudio 360™ turns a name and a birth date into a one-of-a-kind, hand-crafted bracelet — and why we built the rendering engine without a single framework.&lt;br&gt;
tags: javascript, svg, pwa, webdev&lt;br&gt;
cover_image:&lt;br&gt;
canonical_url: &lt;a href="https://lochness-paris.com/configurateur-de-bracelet.html" rel="noopener noreferrer"&gt;https://lochness-paris.com/configurateur-de-bracelet.html&lt;/a&gt;&lt;br&gt;
A few months ago we set out to build something that doesn't fit neatly into the usual "product configurator" template: a tool that turns your name and birth date into a personalized gemstone bracelet, lets you fine-tune every single bead by hand, and ends with a real artisan crafting the physical piece.&lt;br&gt;
&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://zenodo.org/records/19836838" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fzenodo.org%2Fstatic%2Fimages%2Forcid.svg" height="256" class="m-0" width="255"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://zenodo.org/records/19836838" rel="noopener noreferrer" class="c-link"&gt;
            Loch Ness : Système Algorithmique de Détermination Archétypale et de Synergies Minérales à Géométrie Variable. | Zenodo
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Résumé du Concept (Abstract) Le projet Loch Ness redéfinit l'interaction entre la psychologie individuelle et la lithothérapie à travers une interface numérique propriétaire. Contrairement aux approches standardisées à sortie fixe, ce concept repose sur deux piliers innovants : une taxonomie archétypale originale et un algorithme de séquençage minéral à géométrie variable. 1. L'Ontologie du « Monstre Noble » Le système utilise une taxonomie d'archétypes originaux créés pour refléter la complexité de la psyché humaine, incluant ses parts d'ombre et sa puissance brute. L'utilisateur n'est pas classé selon des critères sociologiques ou démographiques classiques, mais selon sa « nature sauvage » et sa capacité à canaliser une force intérieure singulière. Cette classification produit, pour chaque profil, un vecteur multidimensionnel d'intensité plutôt qu'une étiquette unique. 2. L'Algorithme de Calcul à Géométrie Variable L'application traite les données de l'utilisateur pour générer une réponse minérale dont la structure est unique à chaque profil : Séquençage Dynamique — le nombre de pierres (de 3 à 8 unités) n'est pas fixe ; il est déterminé par l'algorithme en fonction de la « densité » calculée du profil archétypal. Synergie de Compensation — chaque pierre est sélectionnée pour remplir un rôle fonctionnel spécifique (Ancre, Cœur, Mental, Bouclier, Élan), créant une armure vibratoire sur mesure plutôt qu'un assemblage arbitraire. Cette indirection rôle → pierre permet de faire évoluer indépendamment la nomenclature des rôles et le catalogue minéral, sans recalibrer l'ensemble du système à chaque ajout. 3. Pipeline Algorithmique Le traitement repose sur un pipeline en trois étapes fonctionnelles strictement découplées, chacune testable indépendamment : Normalisation identitaire — les données d'identité (prénom(s), nom, date de naissance) sont transformées en un vecteur de profil multidimensionnel (intensité, part d'ombre, élan, stabilité), sans dépendance au rendu ni à l'interface. Calcul de densité et résolution du nombre de slots — une fonction de densité agrège le vecteur de profil en un score scalaire, ensuite projeté sur l'intervalle entier [3, 8] pour déterminer dynamiquement la longueur de la séquence minérale. Résolution des rôles puis sélection minérale — pour chaque slot, un rôle fonctionnel est résolu en premier (Ancre, Cœur, Mental, Bouclier, Élan), puis une pierre est sélectionnée dans le catalogue pour satisfaire ce rôle au regard du vecteur de profil. 4. Architecture Logicielle Le système est implémenté comme une application web monopage (SPA statique), sans dépendance à un framework JavaScript, avec les caractéristiques techniques suivantes : Stack — HTML5, CSS3 (custom properties), JavaScript natif (vanilla) ; aucune dépendance à un framework de rendu. Moteur de rendu génératif — chaque minéral du catalogue est rendu en SVG généré dynamiquement (gradients radiaux, filtres, clip-paths) à partir d'un identifiant de minéral (« preset ») et d'une graine aléatoire (« seed ») propre à chaque occurrence, simulant veinage, translucidité et inclusions sans recourir à une banque d'images statiques. Disposition géométrique dynamique — la position de chaque pierre sur le support (bracelet) est calculée trigonométriquement en fonction de son index et du nombre total de slots résolu par l'algorithme (N variable, et non une constante), avec mise à l'échelle automatique de la taille des perles selon la densité de la séquence. Gestion d'état — l'état de la composition est représenté par un tableau ordonné de perles, avec piles d'historique (undo/redo) et re-rendu ciblé (et non un re-rendu complet) à chaque mutation, pour préserver la fluidité sur mobile. Progressive Web App — manifeste d'installation (icônes, couleur de thème, mode standalone) et Service Worker assurant la mise en cache des ressources statiques et une résilience hors-ligne partielle. Données structurées — balisage Schema.org (SoftwareApplication, FAQPage) pour l'indexation enrichie, en complément du contenu HTML visible. La documentation d'architecture complète (diagrammes de flux, choix techniques justifiés) ainsi que des exemples de code simplifiés à but pédagogique sont publiés séparément.&amp;nbsp;Les tables de normalisation réelles, la fonction de densité, les seuils de résolution des rôles et le catalogue minéral complet ne sont pas publiés et constituent le cœur propriétaire du système. Objectif du Dépôt Ce document consigne la propriété intellectuelle de la logique algorithmique, de la nomenclature des archétypes et de la méthodologie de sélection des minéraux associée à la marque Loch Ness. Il protège l'invention d'un système de personnalisation où la technologie devient le pont entre l'identité profonde de l'individu et l'objet talismanique. Application en ligne : lochness-paris.com/configurateur-de-bracelet.html
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fzenodo.org%2Fstatic%2Ffavicon.ico" width="50" height="50"&gt;
          zenodo.org
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;
The result is GemStudio 360™, the engine behind Loch Ness Paris' bracelet configurator. This post walks through the architecture, the technical trade-offs, and a few of the more interesting problems we had to solve — without giving away the proprietary parts (the numerology algorithm and the full gem-rendering engine stay closed source, but the shape of the system is worth sharing).&lt;br&gt;
If you want to dig deeper, the architecture documentation and a few simplified code examples are public on GitHub: application-configurateur-de-bracelets-en-pierres-fines.&lt;br&gt;
The brief, in one sentence

&lt;blockquote&gt;
&lt;p&gt;Turn an identity (first name, last name, birth date) into 8 symbolic gemstones, let the user build a real bracelet around them bead by bead, and hand the final design off to a craftsperson.&lt;br&gt;
No accounts, no backend database of users, no checkout — just a fast, installable web app that ends in a personalized request sent to a human artisan.&lt;br&gt;
Why no framework?&lt;br&gt;
This was a deliberate choice. The whole app — numerology engine, SVG bead renderer, drag-to-build canvas, smart suggestions, cart — is built in vanilla JavaScript, plain CSS, and a single HTML file.&lt;br&gt;
Three reasons drove that decision:&lt;br&gt;
Load time matters more than developer convenience here. A big chunk of traffic comes from organic/mobile search for terms like "bracelet chemin de vie." Every extra hundred kilobytes of framework runtime is a few more bounces.&lt;br&gt;
The DOM tree is genuinely simple. Four tabs, one SVG canvas, a handful of lists. State management doesn't need a library when the state is "an ordered array of beads plus some UI flags."&lt;br&gt;
PWA installability and offline behavior are easier to reason about when you fully control the bootstrapping sequence instead of going through a framework's hydration lifecycle.&lt;br&gt;
That said — vanilla JS at this scale means you have to be disciplined about state, which brings us to the next point.&lt;br&gt;
State management without a framework&lt;br&gt;
The entire bracelet is represented as an ordered array of bead objects. Every mutation (add, remove, reorder, change size) goes through a small set of functions that:&lt;br&gt;
update the array,&lt;br&gt;
push the previous state onto an undo stack,&lt;br&gt;
re-render only the parts of the SVG that changed.&lt;br&gt;
That last point matters a lot for performance: with up to ~19 beads rendered as gradient-filled SVG circles, a naive full re-render on every drag event is noticeable on mid-range phones. Re-rendering only the affected bead (and recalculating positions trigonometrically around the guide circle) keeps things smooth.&lt;br&gt;
&lt;/p&gt;


&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified illustration of bead positioning on the guide circle&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getBeadPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;center&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;center&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;center&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Undo/redo is just two stacks of array snapshots — simple, but it's the kind of "boring technology" that makes a configurator feel trustworthy. Users mis-click constantly; cheap undo is a UX feature, not a nice-to-have.&lt;br&gt;
Generating gemstones instead of photographing them&lt;br&gt;
This is the part I find most interesting. With 70+ stone types in the catalog, maintaining a photo library (multiple angles, lighting consistency, file size, retina variants…) would have been a maintenance nightmare.&lt;br&gt;
Instead, every bead is a procedurally generated SVG. Each stone type has a "preset" describing its visual family (translucent crystal, opaque mineral, banded stone, metallic finish…), and each individual bead instance gets a random seed that introduces small variations — hue jitter, gradient stop shifts — so that no two beads of the same stone type look perfectly identical, the same way no two real gemstones do.&lt;br&gt;
A heavily simplified version of the idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderBead&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseHue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;seededRandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseHue&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// small natural variation&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`
    &amp;lt;radialGradient id="grad-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" cx="40%" cy="35%" r="65%"&amp;gt;
      &amp;lt;stop offset="0%"  stop-color="hsla(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, 45%, 75%, 1)" /&amp;gt;
      &amp;lt;stop offset="60%" stop-color="hsla(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, 45%, 55%, 1)" /&amp;gt;
      &amp;lt;stop offset="100%" stop-color="hsla(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, 45%, 35%, 1)" /&amp;gt;
    &amp;lt;/radialGradient&amp;gt;
    &amp;lt;circle cx="50" cy="50" r="48" fill="url(#grad-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)" /&amp;gt;
  `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The production renderer is considerably more involved — it layers multiple gradients, clip paths, and noise patterns per mineral family to approximate veining (think lapis lazuli or labradorite) and translucency — but the core trick is the same: vector + seed beats a static image library when you need infinite, lightweight variation.&lt;br&gt;
The numerology layer&lt;br&gt;
The app's signature feature is the "Life Path Bracelet": you type in a first name and a birth date, and the engine computes 8 symbolic stones — Foundation, Summit, Life Path, Calling, Personality, Expression, Final Touch, and Wish — each tied to a different facet of numerology.&lt;br&gt;
I won't detail the letter-to-number mapping or reduction rules here since that's the proprietary core of the product, but structurally it's a pure function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;identity (name + date) → numeric reduction → archetype (1–9) → stone(s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping it as a pure, side-effect-free transformation made it trivial to unit test and to keep completely decoupled from the rendering and cart logic. The numerology module has no idea an SVG even exists.&lt;br&gt;
Smart suggestions and the "litho score"&lt;br&gt;
Beyond manual bead-picking, there's a "Smart" tab with:&lt;br&gt;
Harmonious palettes — pre-grouped stones by color coherence,&lt;br&gt;
Predefined themes — ready-made compositions for intents like "protection" or "calm,"&lt;br&gt;
A coherence score that evaluates how energetically consistent a user's manual selection is.&lt;br&gt;
These are all derived/precomputed layers sitting on top of the same stone catalog — no real-time AI inference, just well-structured data and some scoring heuristics. It's a good reminder that "smart suggestions" don't always need a model; sometimes a well-curated lookup table does the job at a fraction of the latency and cost.&lt;br&gt;
PWA bits that actually mattered&lt;br&gt;
A few small, unglamorous details ended up making a real difference on mobile:&lt;br&gt;
&lt;code&gt;touch-action: manipulation&lt;/code&gt; on all interactive elements to kill the old 300ms tap delay.&lt;br&gt;
Loading the webfont with &lt;code&gt;media="print"&lt;/code&gt; then switching to &lt;code&gt;all&lt;/code&gt; on load, so it never blocks first paint.&lt;br&gt;
A Service Worker caching static assets for offline resilience and faster repeat visits.&lt;br&gt;
A &lt;code&gt;manifest.json&lt;/code&gt; so the app can be installed straight from the browser, no app store round-trip.&lt;br&gt;
None of this is novel, but it's the kind of checklist that's easy to skip under deadline pressure — and it's exactly the stuff that determines whether a configurator feels like an app or like a website pretending to be one.&lt;br&gt;
From pixels to a physical object&lt;br&gt;
The configurator deliberately doesn't end in a checkout button. Once a composition is validated, the user lands on a recap + contact form, and the actual fulfillment is handled by a human artisan partner who crafts the bracelet by hand using the validated design as a spec sheet.&lt;br&gt;
That constraint shaped a lot of UI decisions — for instance, the cart view always shows an estimated price range rather than a fixed price, since the final quote depends on material choices (precious metal findings, natural pearls, etc.) confirmed with the artisan.&lt;br&gt;
What's public, what isn't&lt;br&gt;
We open-sourced the documentation and architecture of this project, along with a few deliberately simplified code examples, in a public GitHub repo. What you'll find there:&lt;br&gt;
a full architecture breakdown (diagrams included),&lt;br&gt;
a feature-by-feature functional spec,&lt;br&gt;
simplified illustrative examples of the numerology pattern and the SVG bead rendering pattern (not the production algorithms),&lt;br&gt;
a roadmap and contribution guidelines.&lt;br&gt;
What stays closed: the actual numerology letter/reduction tables, the full multi-preset rendering engine, the complete stone catalog, and the litho-score algorithm — those remain Loch Ness®'s IP.&lt;br&gt;
Try it&lt;br&gt;
The live configurator is here: lochness-paris.com/configurateur-de-bracelet.html&lt;br&gt;
The project is also archived and citable via Zenodo: zenodo.org/records/19836838&lt;br&gt;
If you're into vanilla-JS architecture, generative SVG, or PWA details, I'd genuinely love feedback — open an issue on the repo or drop a comment below.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Built the Two Missing Payload CMS v3 Plugins — Reviews, JSON-LD &amp; Real Production Bugs</title>
      <dc:creator>Camille Guerineau</dc:creator>
      <pubDate>Tue, 16 Jun 2026 03:35:50 +0000</pubDate>
      <link>https://dev.to/spiritrackingarch/how-i-built-the-two-missing-payload-cms-v3-plugins-reviews-json-ld-real-production-bugs-18a1</link>
      <guid>https://dev.to/spiritrackingarch/how-i-built-the-two-missing-payload-cms-v3-plugins-reviews-json-ld-real-production-bugs-18a1</guid>
      <description>&lt;h2&gt;
  
  
  Running 23 European e-commerce shops on Payload CMS v3 taught me that some things simply don't exist yet. So I built them.
&lt;/h2&gt;

&lt;p&gt;Background&lt;br&gt;
I maintain a multi-clone e-commerce infrastructure — 23 Next.js + Payload CMS v3 shops deployed across Europe, each on its own subdomain and language. Think &lt;code&gt;fr.myshop.com&lt;/code&gt;, &lt;code&gt;de.myshop.com&lt;/code&gt;, &lt;code&gt;sk.myshop.com&lt;/code&gt;... all running on the same codebase with country-specific patches.&lt;br&gt;
While building this, I kept running into two missing pieces that no one had published for Payload v3:&lt;br&gt;
A customer reviews system with admin moderation and Google star ratings&lt;br&gt;
Complete Schema.org JSON-LD for Google rich snippets (Product, BreadcrumbList, ItemList, AggregateRating)&lt;/p&gt;
&lt;h2&gt;
  
  
  Both are now published on npm. Here's what I built, the bugs I hit, and how I solved them.
&lt;/h2&gt;

&lt;p&gt;Part 1 — The Reviews Plugin&lt;br&gt;
What didn't exist&lt;br&gt;
Search npm for &lt;code&gt;payload reviews&lt;/code&gt; or &lt;code&gt;payload ratings&lt;/code&gt; — you'll find nothing for v3. The official plugin ecosystem covers SEO, forms, redirects, Stripe... but not customer reviews.&lt;br&gt;
Building the collection&lt;br&gt;
The &lt;code&gt;reviews&lt;/code&gt; collection itself is straightforward — relationship to &lt;code&gt;products&lt;/code&gt;, &lt;code&gt;rating&lt;/code&gt; (1-5), &lt;code&gt;status&lt;/code&gt; select (pending/approved/rejected), author fields. The tricky parts came later.&lt;br&gt;
Access control gotcha: Payload v3 uses a &lt;code&gt;roles&lt;/code&gt; array, not a &lt;code&gt;role&lt;/code&gt; string. This breaks if you copy v2 patterns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Wrong — always returns false&lt;/span&gt;
&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Correct for v3&lt;/span&gt;
&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prevent self-verification: Users can POST any field on &lt;code&gt;create: () =&amp;gt; true&lt;/code&gt; collections. Lock &lt;code&gt;verified&lt;/code&gt; in a &lt;code&gt;beforeChange&lt;/code&gt; hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;beforeChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="c1"&gt;// admin-only, always reset on create&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Email protection: &lt;code&gt;read: () =&amp;gt; true&lt;/code&gt; on the collection exposes &lt;code&gt;authorEmail&lt;/code&gt; in the public API. Add field-level access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorEmail&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relationship bug — &lt;code&gt;"108 0"&lt;/code&gt; instead of &lt;code&gt;108&lt;/code&gt;&lt;br&gt;
When the form submitted &lt;code&gt;product: productId&lt;/code&gt;, Payload rejected it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"This relationship field has the following invalid relationships: 108 0"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The string &lt;code&gt;"108 0"&lt;/code&gt; was being sent instead of the number &lt;code&gt;108&lt;/code&gt;. The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Sends "108 0" — unknown why the space appears&lt;/span&gt;
&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Force integer&lt;/span&gt;
&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Custom endpoints don't work for parameterized routes in v3&lt;br&gt;
I tried registering the reviews GET endpoint in &lt;code&gt;payload.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/reviews/product/:productId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;code&gt;Route not found "/api/reviews/product/108"&lt;/code&gt; — Payload v3 doesn't mount parameterized endpoints correctly under &lt;code&gt;/api&lt;/code&gt;.&lt;br&gt;
Solution: use a Next.js App Router route instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/(app)/api/reviews/product/[productId]/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;configPromise&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reviews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reviews&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;and&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database table doesn't auto-create&lt;br&gt;
Payload running with migration &lt;code&gt;batch: -1&lt;/code&gt; (dev mode) won't auto-migrate on build. You need to create the &lt;code&gt;reviews&lt;/code&gt; table manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"enum_reviews_status"&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;ENUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rejected'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;"reviews"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="nb"&gt;serial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"product_id"&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"products"&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"author_name"&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"author_email"&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"rating"&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"title"&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"comment"&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"verified"&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"status"&lt;/span&gt; &lt;span class="nv"&gt;"enum_reviews_status"&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"payload_locked_documents_rels"&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;"reviews_id"&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"reviews"&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;payload-plugin-reviews
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// payload.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;reviewsPlugin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payload-plugin-reviews&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;buildConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;reviewsPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;productsCollection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;→ payload-plugin-reviews on npm&lt;/p&gt;

&lt;h2&gt;
  
  
  → GitHub
&lt;/h2&gt;

&lt;p&gt;Part 2 — The JSON-LD Plugin&lt;br&gt;
What didn't exist&lt;br&gt;
Payload's official SEO plugin handles meta tags. It does not handle Schema.org structured data. No one had published a complete JSON-LD solution for Payload v3 e-commerce either.&lt;br&gt;
After running 23 shops in production and passing Google Rich Results validation on all of them, I extracted the patterns into &lt;code&gt;payload-plugin-seo-jsonld&lt;/code&gt;.&lt;br&gt;
Bug 1 — &lt;code&gt;price&lt;/code&gt; vs &lt;code&gt;lowPrice&lt;/code&gt;&lt;br&gt;
Google's Rich Results validator rejects &lt;code&gt;AggregateOffer&lt;/code&gt; with &lt;code&gt;price&lt;/code&gt;. It requires &lt;code&gt;lowPrice&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Google rejects this&lt;/span&gt;
&lt;span class="nx"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AggregateOffer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Google accepts this&lt;/span&gt;
&lt;span class="nl"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AggregateOffer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lowPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bug 2 — Prices stored in cents&lt;br&gt;
Payload e-commerce stores prices in cents (&lt;code&gt;3990&lt;/code&gt; = €39.90). Pass the raw value to Google and you'll have a €3,990 bracelet in your rich snippets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Sends 3990 to Google&lt;/span&gt;
&lt;span class="nx"&gt;lowPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceInUSD&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Correct&lt;/span&gt;
&lt;span class="nx"&gt;lowPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceInUSD&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bug 3 — ItemList duplicates crash Google validation&lt;br&gt;
If a page has two &lt;code&gt;ItemList&lt;/code&gt; JSON-LD scripts, Google invalidates both. This happened on shops that inherited a carousel from a base template and then had a new one patched in.&lt;br&gt;
The plugin returns &lt;code&gt;null&lt;/code&gt; when products are empty, and the pattern makes the single-instance contract explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Returns null if empty — safe conditional render&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;itemListJsonLd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildItemListJsonLd&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;siteUrl&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;itemListJsonLd&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/ld+json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="nx"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;itemListJsonLd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BreadcrumbList with nested categories&lt;br&gt;
The BreadcrumbList recursively walks the category parent chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Shop → Jewelry → Bracelets → Product Name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collectParents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;collectParents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;parents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;collectParents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This requires fetching products at &lt;code&gt;depth: 3&lt;/code&gt; to populate categories and their parents.&lt;br&gt;
AggregateRating — connecting reviews to JSON-LD&lt;br&gt;
Plug in your review data to get Google star ratings in search results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;productJsonLd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildProductJsonLd&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;averageRating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// from your reviews query&lt;/span&gt;
  &lt;span class="na"&gt;reviewCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin only adds &lt;code&gt;aggregateRating&lt;/code&gt; when &lt;code&gt;reviewCount &amp;gt; 0&lt;/code&gt; — no empty rating blocks.&lt;br&gt;
Install&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;payload-plugin-seo-jsonld
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;buildProductJsonLd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;buildBreadcrumbJsonLd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;buildItemListJsonLd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;buildWebSiteJsonLd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;buildOrganizationJsonLd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payload-plugin-seo-jsonld&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;→ payload-plugin-seo-jsonld on npm&lt;/p&gt;

&lt;h2&gt;
  
  
  → GitHub
&lt;/h2&gt;

&lt;p&gt;Part 3 — Enabling Google indexing on new shops&lt;br&gt;
Two things block Google on fresh Payload v3 deployments that aren't obvious:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Default &lt;code&gt;noindex&lt;/code&gt; in layout.tsx
New Payload projects ship with indexing disabled:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/(app)/layout.tsx&lt;/span&gt;
&lt;span class="nx"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// ← blocks Google&lt;/span&gt;
  &lt;span class="nx"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/index: false,/index: true,/'&lt;/span&gt; src/app/&lt;span class="o"&gt;(&lt;/span&gt;app&lt;span class="o"&gt;)&lt;/span&gt;/layout.tsx
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/follow: false,/follow: true,/'&lt;/span&gt; src/app/&lt;span class="o"&gt;(&lt;/span&gt;app&lt;span class="o"&gt;)&lt;/span&gt;/layout.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;robots.txt
Verify your &lt;code&gt;public/robots.txt&lt;/code&gt; allows crawling:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight robot_framework"&gt;&lt;code&gt;User-agent: *&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Allow:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Sitemap:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;https://myshop.com/sitemap.xml&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check no noindex header&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"https://myshop.com/shop"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"x-robots&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;noindex"&lt;/span&gt;

&lt;span class="c"&gt;# Check single ItemList&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://myshop.com/shop"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'"@type":"ItemList"'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# → must return 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Test structured data: Google Rich Results Test
&lt;/h2&gt;

&lt;p&gt;What I learned&lt;br&gt;
Payload v3 routes are Next.js routes — don't fight the framework, use App Router for parameterized endpoints&lt;br&gt;
Always check migration mode — &lt;code&gt;batch: -1&lt;/code&gt; means Payload manages schema automatically in dev, but won't auto-migrate in prod&lt;br&gt;
Google is strict on JSON-LD — &lt;code&gt;lowPrice&lt;/code&gt; not &lt;code&gt;price&lt;/code&gt;, one &lt;code&gt;ItemList&lt;/code&gt; per page, &lt;code&gt;reviewCount &amp;gt; 0&lt;/code&gt; before adding &lt;code&gt;aggregateRating&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Field-level access control is underused — protect sensitive fields at the field level, not just the collection level
&lt;/h2&gt;

&lt;p&gt;Links&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Both plugins are officially listed in the &lt;strong&gt;Payload Plugin Directory&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.payloaddirectory.dev/plugins/1270699948-root" rel="noopener noreferrer"&gt;payload-plugin-reviews&lt;/a&gt; — reviews, moderation &amp;amp; star ratings&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.payloaddirectory.dev/plugins/1270763175-root" rel="noopener noreferrer"&gt;payload-plugin-seo-jsonld&lt;/a&gt; — complete Schema.org JSON-LD&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;p&gt;payload-plugin-reviews — npm&lt;br&gt;
payload-plugin-seo-jsonld — npm&lt;br&gt;
Payload CMS — the framework&lt;/p&gt;

&lt;h2&gt;
  
  
  Google Rich Results Test
&lt;/h2&gt;




&lt;p&gt;About the author&lt;br&gt;
I'm Camille, a freelance developer specializing in European e-commerce infrastructure. I build and maintain 23 Payload CMS v3 shops deployed across Europe — each country-specific, SEO-optimized, and running in production.&lt;br&gt;
The plugins in this article came directly from real production needs. If something didn't exist, I built it.&lt;/p&gt;

&lt;p&gt;🔧 &lt;strong&gt;GitHub&lt;/strong&gt; — &lt;a href="https://github.com/spiritracking-arch" rel="noopener noreferrer"&gt;github.com/spiritracking-arch&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;payload-plugin-reviews&lt;/strong&gt; — &lt;a href="https://www.npmjs.com/package/payload-plugin-reviews" rel="noopener noreferrer"&gt;npmjs.com/package/payload-plugin-reviews&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;payload-plugin-seo-jsonld&lt;/strong&gt; — &lt;a href="https://www.npmjs.com/package/payload-plugin-seo-jsonld" rel="noopener noreferrer"&gt;npmjs.com/package/payload-plugin-seo-jsonld&lt;/a&gt;&lt;br&gt;
🌐 &lt;strong&gt;ScaleYourShop&lt;/strong&gt; — &lt;a href="https://scaleyourshop.app" rel="noopener noreferrer"&gt;https://scaleyourshop.app/&lt;/a&gt;&lt;br&gt;
The infrastructure behind these 23 shops &lt;/p&gt;

</description>
      <category>npm</category>
      <category>nextjs</category>
      <category>opensource</category>
      <category>payload</category>
    </item>
  </channel>
</rss>
