{"id":2945,"date":"2026-06-16T03:46:05","date_gmt":"2026-06-16T03:46:05","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/06\/16\/how-i-built-the-two-missing-payload-cms-v3-plugins-reviews-json-ld-real-production-bugs\/"},"modified":"2026-06-16T03:46:05","modified_gmt":"2026-06-16T03:46:05","slug":"how-i-built-the-two-missing-payload-cms-v3-plugins-reviews-json-ld-real-production-bugs","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/06\/16\/how-i-built-the-two-missing-payload-cms-v3-plugins-reviews-json-ld-real-production-bugs\/","title":{"rendered":"How I Built the Two Missing Payload CMS v3 Plugins \u2014 Reviews, JSON-LD &#038; Real Production Bugs"},"content":{"rendered":"<div>\n<div><\/div>\n<div data-article-id=\"3910982\" id=\"article-body\">\n<h2> <a name=\"running-23-european-ecommerce-shops-on-payload-cms-v3-taught-me-that-some-things-simply-dont-exist-yet-so-i-built-them\" href=\"#running-23-european-ecommerce-shops-on-payload-cms-v3-taught-me-that-some-things-simply-dont-exist-yet-so-i-built-them\"> <\/a> Running 23 European e-commerce shops on Payload CMS v3 taught me that some things simply don&#8217;t exist yet. So I built them. <\/h2>\n<p>Background<br \/> I maintain a multi-clone e-commerce infrastructure \u2014 23 Next.js + Payload CMS v3 shops deployed across Europe, each on its own subdomain and language. Think <code>fr.myshop.com<\/code>, <code>de.myshop.com<\/code>, <code>sk.myshop.com<\/code>&#8230; all running on the same codebase with country-specific patches.<br \/> While building this, I kept running into two missing pieces that no one had published for Payload v3:<br \/> A customer reviews system with admin moderation and Google star ratings<br \/> Complete Schema.org JSON-LD for Google rich snippets (Product, BreadcrumbList, ItemList, AggregateRating)<\/p>\n<h2> <a name=\"both-are-now-published-on-npm-heres-what-i-built-the-bugs-i-hit-and-how-i-solved-them\" href=\"#both-are-now-published-on-npm-heres-what-i-built-the-bugs-i-hit-and-how-i-solved-them\"> <\/a> Both are now published on npm. Here&#8217;s what I built, the bugs I hit, and how I solved them. <\/h2>\n<p>Part 1 \u2014 The Reviews Plugin<br \/> What didn&#8217;t exist<br \/> Search npm for <code>payload reviews<\/code> or <code>payload ratings<\/code> \u2014 you&#8217;ll find nothing for v3. The official plugin ecosystem covers SEO, forms, redirects, Stripe&#8230; but not customer reviews.<br \/> Building the collection<br \/> The <code>reviews<\/code> collection itself is straightforward \u2014 relationship to <code>products<\/code>, <code>rating<\/code> (1-5), <code>status<\/code> select (pending\/approved\/rejected), author fields. The tricky parts came later.<br \/> Access control gotcha: Payload v3 uses a <code>roles<\/code> array, not a <code>role<\/code> string. This breaks if you copy v2 patterns: <\/p>\n<div>\n<pre><code><span>\/\/ \u274c Wrong \u2014 always returns false<\/span> <span>update<\/span><span>:<\/span> <span>({<\/span> <span>req<\/span> <span>})<\/span> <span>=&gt;<\/span> <span>req<\/span><span>.<\/span><span>user<\/span><span>?.<\/span><span>role<\/span> <span>===<\/span> <span>'<\/span><span>admin<\/span><span>'<\/span><span>,<\/span> <span>\/\/ \u2705 Correct for v3<\/span> <span>update<\/span><span>:<\/span> <span>({<\/span> <span>req<\/span> <span>})<\/span> <span>=&gt;<\/span> <span>req<\/span><span>.<\/span><span>user<\/span><span>?.<\/span><span>roles<\/span><span>?.<\/span><span>includes<\/span><span>(<\/span><span>'<\/span><span>admin<\/span><span>'<\/span><span>),<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Prevent self-verification: Users can POST any field on <code>create: () =&gt; true<\/code> collections. Lock <code>verified<\/code> in a <code>beforeChange<\/code> hook: <\/p>\n<div>\n<pre><code><span>hooks<\/span><span>:<\/span> <span>{<\/span> <span>beforeChange<\/span><span>:<\/span> <span>[<\/span> <span>({<\/span> <span>data<\/span> <span>})<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>if <\/span><span>(<\/span><span>!<\/span><span>data<\/span><span>.<\/span><span>status<\/span><span>)<\/span> <span>data<\/span><span>.<\/span><span>status<\/span> <span>=<\/span> <span>'<\/span><span>pending<\/span><span>'<\/span> <span>data<\/span><span>.<\/span><span>verified<\/span> <span>=<\/span> <span>false<\/span> <span>\/\/ admin-only, always reset on create<\/span> <span>return<\/span> <span>data<\/span> <span>},<\/span> <span>],<\/span> <span>},<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Email protection: <code>read: () =&gt; true<\/code> on the collection exposes <code>authorEmail<\/code> in the public API. Add field-level access: <\/p>\n<div>\n<pre><code><span>{<\/span> <span>name<\/span><span>:<\/span> <span>'<\/span><span>authorEmail<\/span><span>'<\/span><span>,<\/span> <span>type<\/span><span>:<\/span> <span>'<\/span><span>email<\/span><span>'<\/span><span>,<\/span> <span>access<\/span><span>:<\/span> <span>{<\/span> <span>read<\/span><span>:<\/span> <span>({<\/span> <span>req<\/span> <span>})<\/span> <span>=&gt;<\/span> <span>req<\/span><span>.<\/span><span>user<\/span><span>?.<\/span><span>roles<\/span><span>?.<\/span><span>includes<\/span><span>(<\/span><span>'<\/span><span>admin<\/span><span>'<\/span><span>)<\/span> <span>},<\/span> <span>}<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The relationship bug \u2014 <code>\"108 0\"<\/code> instead of <code>108<\/code><br \/> When the form submitted <code>product: productId<\/code>, Payload rejected it with: <\/p>\n<div>\n<pre><code>\"This relationship field has the following invalid relationships: 108 0\" <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The string <code>\"108 0\"<\/code> was being sent instead of the number <code>108<\/code>. The fix: <\/p>\n<div>\n<pre><code><span>\/\/ \u274c Sends \"108 0\" \u2014 unknown why the space appears<\/span> <span>body<\/span><span>:<\/span> <span>JSON<\/span><span>.<\/span><span>stringify<\/span><span>({<\/span> <span>...<\/span><span>form<\/span><span>,<\/span> <span>product<\/span><span>:<\/span> <span>productId<\/span> <span>})<\/span> <span>\/\/ \u2705 Force integer<\/span> <span>body<\/span><span>:<\/span> <span>JSON<\/span><span>.<\/span><span>stringify<\/span><span>({<\/span> <span>...<\/span><span>form<\/span><span>,<\/span> <span>product<\/span><span>:<\/span> <span>parseInt<\/span><span>(<\/span><span>productId<\/span><span>,<\/span> <span>10<\/span><span>)<\/span> <span>})<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Custom endpoints don&#8217;t work for parameterized routes in v3<br \/> I tried registering the reviews GET endpoint in <code>payload.config.ts<\/code>: <\/p>\n<div>\n<pre><code><span>endpoints<\/span><span>:<\/span> <span>[{<\/span> <span>path<\/span><span>:<\/span> <span>'<\/span><span>\/reviews\/product\/:productId<\/span><span>'<\/span><span>,<\/span> <span>method<\/span><span>:<\/span> <span>'<\/span><span>get<\/span><span>'<\/span><span>,<\/span> <span>handler<\/span><span>:<\/span> <span>...<\/span> <span>}]<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Result: <code>Route not found \"\/api\/reviews\/product\/108\"<\/code> \u2014 Payload v3 doesn&#8217;t mount parameterized endpoints correctly under <code>\/api<\/code>.<br \/> Solution: use a Next.js App Router route instead: <\/p>\n<div>\n<pre><code><span>\/\/ src\/app\/(app)\/api\/reviews\/product\/[productId]\/route.ts<\/span> <span>export<\/span> <span>async<\/span> <span>function<\/span> <span>GET<\/span><span>(<\/span><span>req<\/span><span>,<\/span> <span>{<\/span> <span>params<\/span> <span>})<\/span> <span>{<\/span> <span>const<\/span> <span>{<\/span> <span>productId<\/span> <span>}<\/span> <span>=<\/span> <span>await<\/span> <span>params<\/span> <span>const<\/span> <span>payload<\/span> <span>=<\/span> <span>await<\/span> <span>getPayload<\/span><span>({<\/span> <span>config<\/span><span>:<\/span> <span>configPromise<\/span> <span>})<\/span> <span>const<\/span> <span>reviews<\/span> <span>=<\/span> <span>await<\/span> <span>payload<\/span><span>.<\/span><span>find<\/span><span>({<\/span> <span>collection<\/span><span>:<\/span> <span>'<\/span><span>reviews<\/span><span>'<\/span><span>,<\/span> <span>where<\/span><span>:<\/span> <span>{<\/span> <span>and<\/span><span>:<\/span> <span>[<\/span> <span>{<\/span> <span>product<\/span><span>:<\/span> <span>{<\/span> <span>equals<\/span><span>:<\/span> <span>parseInt<\/span><span>(<\/span><span>productId<\/span><span>,<\/span> <span>10<\/span><span>)<\/span> <span>}<\/span> <span>},<\/span> <span>{<\/span> <span>status<\/span><span>:<\/span> <span>{<\/span> <span>equals<\/span><span>:<\/span> <span>'<\/span><span>approved<\/span><span>'<\/span> <span>}<\/span> <span>},<\/span> <span>],<\/span> <span>},<\/span> <span>})<\/span> <span>\/\/ ...<\/span> <span>}<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The database table doesn&#8217;t auto-create<br \/> Payload running with migration <code>batch: -1<\/code> (dev mode) won&#8217;t auto-migrate on build. You need to create the <code>reviews<\/code> table manually: <\/p>\n<div>\n<pre><code><span>CREATE<\/span> <span>TYPE<\/span> <span>\"public\"<\/span><span>.<\/span><span>\"enum_reviews_status\"<\/span> <span>AS<\/span> <span>ENUM<\/span><span>(<\/span><span>'pending'<\/span><span>,<\/span> <span>'approved'<\/span><span>,<\/span> <span>'rejected'<\/span><span>);<\/span> <span>CREATE<\/span> <span>TABLE<\/span> <span>IF<\/span> <span>NOT<\/span> <span>EXISTS<\/span> <span>\"reviews\"<\/span> <span>(<\/span> <span>\"id\"<\/span> <span>serial<\/span> <span>PRIMARY<\/span> <span>KEY<\/span><span>,<\/span> <span>\"product_id\"<\/span> <span>integer<\/span> <span>NOT<\/span> <span>NULL<\/span> <span>REFERENCES<\/span> <span>\"products\"<\/span><span>(<\/span><span>\"id\"<\/span><span>)<\/span> <span>ON<\/span> <span>DELETE<\/span> <span>SET<\/span> <span>NULL<\/span><span>,<\/span> <span>\"author_name\"<\/span> <span>varchar<\/span> <span>NOT<\/span> <span>NULL<\/span><span>,<\/span> <span>\"author_email\"<\/span> <span>varchar<\/span> <span>NOT<\/span> <span>NULL<\/span><span>,<\/span> <span>\"rating\"<\/span> <span>numeric<\/span> <span>NOT<\/span> <span>NULL<\/span><span>,<\/span> <span>\"title\"<\/span> <span>varchar<\/span><span>,<\/span> <span>\"comment\"<\/span> <span>varchar<\/span> <span>NOT<\/span> <span>NULL<\/span><span>,<\/span> <span>\"verified\"<\/span> <span>boolean<\/span> <span>DEFAULT<\/span> <span>false<\/span><span>,<\/span> <span>\"status\"<\/span> <span>\"enum_reviews_status\"<\/span> <span>DEFAULT<\/span> <span>'pending'<\/span><span>,<\/span> <span>\"updated_at\"<\/span> <span>timestamp<\/span><span>(<\/span><span>3<\/span><span>)<\/span> <span>with<\/span> <span>time<\/span> <span>zone<\/span> <span>NOT<\/span> <span>NULL<\/span> <span>DEFAULT<\/span> <span>now<\/span><span>(),<\/span> <span>\"created_at\"<\/span> <span>timestamp<\/span><span>(<\/span><span>3<\/span><span>)<\/span> <span>with<\/span> <span>time<\/span> <span>zone<\/span> <span>NOT<\/span> <span>NULL<\/span> <span>DEFAULT<\/span> <span>now<\/span><span>()<\/span> <span>);<\/span> <span>ALTER<\/span> <span>TABLE<\/span> <span>\"payload_locked_documents_rels\"<\/span> <span>ADD<\/span> <span>COLUMN<\/span> <span>IF<\/span> <span>NOT<\/span> <span>EXISTS<\/span> <span>\"reviews_id\"<\/span> <span>integer<\/span> <span>REFERENCES<\/span> <span>\"reviews\"<\/span><span>(<\/span><span>\"id\"<\/span><span>)<\/span> <span>ON<\/span> <span>DELETE<\/span> <span>CASCADE<\/span><span>;<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Install <\/p>\n<div>\n<pre><code>npm <span>install <\/span>payload-plugin-reviews <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<div>\n<pre><code><span>\/\/ payload.config.ts<\/span> <span>import<\/span> <span>{<\/span> <span>reviewsPlugin<\/span> <span>}<\/span> <span>from<\/span> <span>'<\/span><span>payload-plugin-reviews<\/span><span>'<\/span> <span>export<\/span> <span>default<\/span> <span>buildConfig<\/span><span>({<\/span> <span>plugins<\/span><span>:<\/span> <span>[<\/span><span>reviewsPlugin<\/span><span>({<\/span> <span>productsCollection<\/span><span>:<\/span> <span>'<\/span><span>products<\/span><span>'<\/span> <span>})],<\/span> <span>})<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>\u2192 payload-plugin-reviews on npm<\/p>\n<h2> <a name=\"%E2%86%92-github\" href=\"#%E2%86%92-github\"> <\/a> \u2192 GitHub <\/h2>\n<p>Part 2 \u2014 The JSON-LD Plugin<br \/> What didn&#8217;t exist<br \/> Payload&#8217;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.<br \/> After running 23 shops in production and passing Google Rich Results validation on all of them, I extracted the patterns into <code>payload-plugin-seo-jsonld<\/code>.<br \/> Bug 1 \u2014 <code>price<\/code> vs <code>lowPrice<\/code><br \/> Google&#8217;s Rich Results validator rejects <code>AggregateOffer<\/code> with <code>price<\/code>. It requires <code>lowPrice<\/code>: <\/p>\n<div>\n<pre><code><span>\/\/ \u274c Google rejects this<\/span> <span>offers<\/span><span>:<\/span> <span>{<\/span> <span>'<\/span><span>@type<\/span><span>'<\/span><span>:<\/span> <span>'<\/span><span>AggregateOffer<\/span><span>'<\/span><span>,<\/span> <span>price<\/span><span>:<\/span> <span>price<\/span> <span>}<\/span> <span>\/\/ \u2705 Google accepts this<\/span> <span>offers<\/span><span>:<\/span> <span>{<\/span> <span>'<\/span><span>@type<\/span><span>'<\/span><span>:<\/span> <span>'<\/span><span>AggregateOffer<\/span><span>'<\/span><span>,<\/span> <span>lowPrice<\/span><span>:<\/span> <span>price<\/span> <span>}<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Bug 2 \u2014 Prices stored in cents<br \/> Payload e-commerce stores prices in cents (<code>3990<\/code> = \u20ac39.90). Pass the raw value to Google and you&#8217;ll have a \u20ac3,990 bracelet in your rich snippets: <\/p>\n<div>\n<pre><code><span>\/\/ \u274c Sends 3990 to Google<\/span> <span>lowPrice<\/span><span>:<\/span> <span>product<\/span><span>.<\/span><span>priceInUSD<\/span> <span>\/\/ \u2705 Correct<\/span> <span>lowPrice<\/span><span>:<\/span> <span>(<\/span><span>product<\/span><span>.<\/span><span>priceInUSD<\/span> <span>\/<\/span> <span>100<\/span><span>).<\/span><span>toFixed<\/span><span>(<\/span><span>2<\/span><span>)<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Bug 3 \u2014 ItemList duplicates crash Google validation<br \/> If a page has two <code>ItemList<\/code> 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.<br \/> The plugin returns <code>null<\/code> when products are empty, and the pattern makes the single-instance contract explicit: <\/p>\n<div>\n<pre><code><span>\/\/ Returns null if empty \u2014 safe conditional render<\/span> <span>const<\/span> <span>itemListJsonLd<\/span> <span>=<\/span> <span>buildItemListJsonLd<\/span><span>({<\/span> <span>products<\/span><span>:<\/span> <span>products<\/span><span>.<\/span><span>docs<\/span><span>,<\/span> <span>siteUrl<\/span> <span>})<\/span> <span>{<\/span><span>itemListJsonLd<\/span> <span>&amp;&amp;<\/span> <span>(<\/span> <span>&lt;<\/span><span>script<\/span> <span>type<\/span><span>=<\/span><span>\"<\/span><span>application\/ld+json<\/span><span>\"<\/span> <span>dangerouslySetInnerHTML<\/span><span>=<\/span><span>{{<\/span> <span>__html<\/span><span>:<\/span> <span>JSON<\/span><span>.<\/span><span>stringify<\/span><span>(<\/span><span>itemListJsonLd<\/span><span>)<\/span> <span>}}<\/span> <span>\/<\/span><span>&gt; <\/span><span>)}<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>BreadcrumbList with nested categories<br \/> The BreadcrumbList recursively walks the category parent chain: <\/p>\n<div>\n<pre><code>Shop \u2192 Jewelry \u2192 Bracelets \u2192 Product Name <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<div>\n<pre><code><span>const<\/span> <span>collectParents<\/span> <span>=<\/span> <span>(<\/span><span>c<\/span><span>:<\/span> <span>any<\/span><span>)<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>if <\/span><span>(<\/span><span>c<\/span><span>.<\/span><span>parent<\/span> <span>&amp;&amp;<\/span> <span>typeof<\/span> <span>c<\/span><span>.<\/span><span>parent<\/span> <span>===<\/span> <span>'<\/span><span>object<\/span><span>'<\/span><span>)<\/span> <span>collectParents<\/span><span>(<\/span><span>c<\/span><span>.<\/span><span>parent<\/span><span>)<\/span> <span>parents<\/span><span>.<\/span><span>push<\/span><span>(<\/span><span>c<\/span><span>)<\/span> <span>}<\/span> <span>collectParents<\/span><span>(<\/span><span>cat<\/span><span>)<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>This requires fetching products at <code>depth: 3<\/code> to populate categories and their parents.<br \/> AggregateRating \u2014 connecting reviews to JSON-LD<br \/> Plug in your review data to get Google star ratings in search results: <\/p>\n<div>\n<pre><code><span>const<\/span> <span>productJsonLd<\/span> <span>=<\/span> <span>buildProductJsonLd<\/span><span>({<\/span> <span>product<\/span><span>,<\/span> <span>siteUrl<\/span><span>,<\/span> <span>currency<\/span><span>:<\/span> <span>'<\/span><span>EUR<\/span><span>'<\/span><span>,<\/span> <span>averageRating<\/span><span>:<\/span> <span>4.8<\/span><span>,<\/span> <span>\/\/ from your reviews query<\/span> <span>reviewCount<\/span><span>:<\/span> <span>42<\/span><span>,<\/span> <span>})<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The plugin only adds <code>aggregateRating<\/code> when <code>reviewCount &gt; 0<\/code> \u2014 no empty rating blocks.<br \/> Install <\/p>\n<div>\n<pre><code>npm <span>install <\/span>payload-plugin-seo-jsonld <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<div>\n<pre><code><span>import<\/span> <span>{<\/span> <span>buildProductJsonLd<\/span><span>,<\/span> <span>buildBreadcrumbJsonLd<\/span><span>,<\/span> <span>buildItemListJsonLd<\/span><span>,<\/span> <span>buildWebSiteJsonLd<\/span><span>,<\/span> <span>buildOrganizationJsonLd<\/span><span>,<\/span> <span>}<\/span> <span>from<\/span> <span>'<\/span><span>payload-plugin-seo-jsonld<\/span><span>'<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>\u2192 payload-plugin-seo-jsonld on npm<\/p>\n<h2> <a name=\"%E2%86%92-github\" href=\"#%E2%86%92-github\"> <\/a> \u2192 GitHub <\/h2>\n<p>Part 3 \u2014 Enabling Google indexing on new shops<br \/> Two things block Google on fresh Payload v3 deployments that aren&#8217;t obvious:<\/p>\n<ol>\n<li>Default <code>noindex<\/code> in layout.tsx New Payload projects ship with indexing disabled: <\/li>\n<\/ol>\n<div>\n<pre><code><span>\/\/ src\/app\/(app)\/layout.tsx<\/span> <span>robots<\/span><span>:<\/span> <span>{<\/span> <span>index<\/span><span>:<\/span> <span>false<\/span><span>,<\/span> <span>\/\/ \u2190 blocks Google<\/span> <span>follow<\/span><span>:<\/span> <span>false<\/span><span>,<\/span> <span>}<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Fix: <\/p>\n<div>\n<pre><code><span>sed<\/span> <span>-i<\/span> <span>'s\/index: false,\/index: true,\/'<\/span> src\/app\/<span>(<\/span>app<span>)<\/span>\/layout.tsx <span>sed<\/span> <span>-i<\/span> <span>'s\/follow: false,\/follow: true,\/'<\/span> src\/app\/<span>(<\/span>app<span>)<\/span>\/layout.tsx <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<ol>\n<li>robots.txt Verify your <code>public\/robots.txt<\/code> allows crawling: <\/li>\n<\/ol>\n<div>\n<pre><code>User-agent: *<span> <\/span><span>Allow:<\/span><span> <\/span><span>\/<\/span><span> <\/span><span>Sitemap:<\/span><span> <\/span><span>https:\/\/myshop.com\/sitemap.xml<\/span><span> <\/span><\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Verification: <\/p>\n<div>\n<pre><code><span># Check no noindex header<\/span> curl <span>-sI<\/span> <span>\"https:\/\/myshop.com\/shop\"<\/span> | <span>grep<\/span> <span>-i<\/span> <span>\"x-robots<\/span><span>\\|<\/span><span>noindex\"<\/span> <span># Check single ItemList<\/span> curl <span>-s<\/span> <span>\"https:\/\/myshop.com\/shop\"<\/span> | <span>grep<\/span> <span>-o<\/span> <span>'\"@type\":\"ItemList\"'<\/span> | <span>wc<\/span> <span>-l<\/span> <span># \u2192 must return 1<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h2> <a name=\"test-structured-data-google-rich-results-test\" href=\"#test-structured-data-google-rich-results-test\"> <\/a> Test structured data: Google Rich Results Test <\/h2>\n<p>What I learned<br \/> Payload v3 routes are Next.js routes \u2014 don&#8217;t fight the framework, use App Router for parameterized endpoints<br \/> Always check migration mode \u2014 <code>batch: -1<\/code> means Payload manages schema automatically in dev, but won&#8217;t auto-migrate in prod<br \/> Google is strict on JSON-LD \u2014 <code>lowPrice<\/code> not <code>price<\/code>, one <code>ItemList<\/code> per page, <code>reviewCount &gt; 0<\/code> before adding <code>aggregateRating<\/code><\/p>\n<h2> <a name=\"fieldlevel-access-control-is-underused-protect-sensitive-fields-at-the-field-level-not-just-the-collection-level\" href=\"#fieldlevel-access-control-is-underused-protect-sensitive-fields-at-the-field-level-not-just-the-collection-level\"> <\/a> Field-level access control is underused \u2014 protect sensitive fields at the field level, not just the collection level <\/h2>\n<p>Links<br \/> payload-plugin-reviews \u2014 npm<br \/> payload-plugin-seo-jsonld \u2014 npm<br \/> Payload CMS \u2014 the framework<\/p>\n<h2> <a name=\"google-rich-results-test\" href=\"#google-rich-results-test\"> <\/a> Google Rich Results Test <\/h2>\n<hr>\n<p>About the author<br \/> I&#8217;m Camille, a freelance developer specializing in European e-commerce infrastructure. I build and maintain 23 Payload CMS v3 shops deployed across Europe \u2014 each country-specific, SEO-optimized, and running in production.<br \/> The plugins in this article came directly from real production needs. If something didn&#8217;t exist, I built it.<br \/> \ud83d\udd27 GitHub \u2014 github.com\/spiritracking-arch<br \/> \ud83d\udce6 payload-plugin-reviews \u2014 npmjs.com\/package\/payload-plugin-reviews<br \/> \ud83d\udce6 payload-plugin-seo-jsonld \u2014 npmjs.com\/package\/payload-plugin-seo-jsonld<br \/> \ud83c\udf10 ScaleYourShop \u2014 lochness-paris.com\/scaleyourshop.html \u2014 the infrastructure behind these 23 shops<\/p>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/dev.to\/spiritrackingarch\/how-i-built-the-two-missing-payload-cms-v3-plugins-reviews-json-ld-real-production-bugs-18a1\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Running 23 European e-commerce shops on Payload CMS v3 taught me that some things simply don&#8217;t exist yet. So I built them. Background I maintain a multi-clone e-commerce infrastructure \u2014 23 Next.js + Payload CMS v3 shops deployed across Europe, each on its own subdomain and language. Think fr.myshop.com, de.myshop.com, sk.myshop.com&#8230; all running on the [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":2944,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[41],"tags":[],"class_list":["post-2945","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devto"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/2945","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=2945"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/2945\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/2944"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=2945"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=2945"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=2945"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}