Running 23 European e-commerce shops on Payload CMS v3 taught me that some things simply don’t exist yet. So I built them.
Background
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 fr.myshop.com, de.myshop.com, sk.myshop.com… all running on the same codebase with country-specific patches.
While building this, I kept running into two missing pieces that no one had published for Payload v3:
A customer reviews system with admin moderation and Google star ratings
Complete Schema.org JSON-LD for Google rich snippets (Product, BreadcrumbList, ItemList, AggregateRating)
Both are now published on npm. Here’s what I built, the bugs I hit, and how I solved them.
Part 1 — The Reviews Plugin
What didn’t exist
Search npm for payload reviews or payload ratings — you’ll find nothing for v3. The official plugin ecosystem covers SEO, forms, redirects, Stripe… but not customer reviews.
Building the collection
The reviews collection itself is straightforward — relationship to products, rating (1-5), status select (pending/approved/rejected), author fields. The tricky parts came later.
Access control gotcha: Payload v3 uses a roles array, not a role string. This breaks if you copy v2 patterns:
// ❌ Wrong — always returns false update: ({ req }) => req.user?.role === 'admin', // ✅ Correct for v3 update: ({ req }) => req.user?.roles?.includes('admin'),
Prevent self-verification: Users can POST any field on create: () => true collections. Lock verified in a beforeChange hook:
hooks: { beforeChange: [ ({ data }) => { if (!data.status) data.status = 'pending' data.verified = false // admin-only, always reset on create return data }, ], },
Email protection: read: () => true on the collection exposes authorEmail in the public API. Add field-level access:
{ name: 'authorEmail', type: 'email', access: { read: ({ req }) => req.user?.roles?.includes('admin') }, }
The relationship bug — "108 0" instead of 108
When the form submitted product: productId, Payload rejected it with:
"This relationship field has the following invalid relationships: 108 0"
The string "108 0" was being sent instead of the number 108. The fix:
// ❌ Sends "108 0" — unknown why the space appears body: JSON.stringify({ ...form, product: productId }) // ✅ Force integer body: JSON.stringify({ ...form, product: parseInt(productId, 10) })
Custom endpoints don’t work for parameterized routes in v3
I tried registering the reviews GET endpoint in payload.config.ts:
endpoints: [{ path: '/reviews/product/:productId', method: 'get', handler: ... }]
Result: Route not found "/api/reviews/product/108" — Payload v3 doesn’t mount parameterized endpoints correctly under /api.
Solution: use a Next.js App Router route instead:
// src/app/(app)/api/reviews/product/[productId]/route.ts export async function GET(req, { params }) { const { productId } = await params const payload = await getPayload({ config: configPromise }) const reviews = await payload.find({ collection: 'reviews', where: { and: [ { product: { equals: parseInt(productId, 10) } }, { status: { equals: 'approved' } }, ], }, }) // ... }
The database table doesn’t auto-create
Payload running with migration batch: -1 (dev mode) won’t auto-migrate on build. You need to create the reviews table manually:
CREATE TYPE "public"."enum_reviews_status" AS ENUM('pending', 'approved', 'rejected'); CREATE TABLE IF NOT EXISTS "reviews" ( "id" serial PRIMARY KEY, "product_id" integer NOT NULL REFERENCES "products"("id") ON DELETE SET NULL, "author_name" varchar NOT NULL, "author_email" varchar NOT NULL, "rating" numeric NOT NULL, "title" varchar, "comment" varchar NOT NULL, "verified" boolean DEFAULT false, "status" "enum_reviews_status" DEFAULT 'pending', "updated_at" timestamp(3) with time zone NOT NULL DEFAULT now(), "created_at" timestamp(3) with time zone NOT NULL DEFAULT now() ); ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "reviews_id" integer REFERENCES "reviews"("id") ON DELETE CASCADE;
Install
npm install payload-plugin-reviews
// payload.config.ts import { reviewsPlugin } from 'payload-plugin-reviews' export default buildConfig({ plugins: [reviewsPlugin({ productsCollection: 'products' })], })
→ payload-plugin-reviews on npm
→ GitHub
Part 2 — The JSON-LD Plugin
What didn’t exist
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.
After running 23 shops in production and passing Google Rich Results validation on all of them, I extracted the patterns into payload-plugin-seo-jsonld.
Bug 1 — price vs lowPrice
Google’s Rich Results validator rejects AggregateOffer with price. It requires lowPrice:
// ❌ Google rejects this offers: { '@type': 'AggregateOffer', price: price } // ✅ Google accepts this offers: { '@type': 'AggregateOffer', lowPrice: price }
Bug 2 — Prices stored in cents
Payload e-commerce stores prices in cents (3990 = €39.90). Pass the raw value to Google and you’ll have a €3,990 bracelet in your rich snippets:
// ❌ Sends 3990 to Google lowPrice: product.priceInUSD // ✅ Correct lowPrice: (product.priceInUSD / 100).toFixed(2)
Bug 3 — ItemList duplicates crash Google validation
If a page has two ItemList 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.
The plugin returns null when products are empty, and the pattern makes the single-instance contract explicit:
// Returns null if empty — safe conditional render const itemListJsonLd = buildItemListJsonLd({ products: products.docs, siteUrl }) {itemListJsonLd && ( <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }} /> )}
BreadcrumbList with nested categories
The BreadcrumbList recursively walks the category parent chain:
Shop → Jewelry → Bracelets → Product Name
const collectParents = (c: any) => { if (c.parent && typeof c.parent === 'object') collectParents(c.parent) parents.push(c) } collectParents(cat)
This requires fetching products at depth: 3 to populate categories and their parents.
AggregateRating — connecting reviews to JSON-LD
Plug in your review data to get Google star ratings in search results:
const productJsonLd = buildProductJsonLd({ product, siteUrl, currency: 'EUR', averageRating: 4.8, // from your reviews query reviewCount: 42, })
The plugin only adds aggregateRating when reviewCount > 0 — no empty rating blocks.
Install
npm install payload-plugin-seo-jsonld
import { buildProductJsonLd, buildBreadcrumbJsonLd, buildItemListJsonLd, buildWebSiteJsonLd, buildOrganizationJsonLd, } from 'payload-plugin-seo-jsonld'
→ payload-plugin-seo-jsonld on npm
→ GitHub
Part 3 — Enabling Google indexing on new shops
Two things block Google on fresh Payload v3 deployments that aren’t obvious:
- Default
noindexin layout.tsx New Payload projects ship with indexing disabled:
// src/app/(app)/layout.tsx robots: { index: false, // ← blocks Google follow: false, }
Fix:
sed -i 's/index: false,/index: true,/' src/app/(app)/layout.tsx sed -i 's/follow: false,/follow: true,/' src/app/(app)/layout.tsx
- robots.txt Verify your
public/robots.txtallows crawling:
User-agent: * Allow: / Sitemap: https://myshop.com/sitemap.xml
Verification:
# Check no noindex header curl -sI "https://myshop.com/shop" | grep -i "x-robots\|noindex" # Check single ItemList curl -s "https://myshop.com/shop" | grep -o '"@type":"ItemList"' | wc -l # → must return 1
Test structured data: Google Rich Results Test
What I learned
Payload v3 routes are Next.js routes — don’t fight the framework, use App Router for parameterized endpoints
Always check migration mode — batch: -1 means Payload manages schema automatically in dev, but won’t auto-migrate in prod
Google is strict on JSON-LD — lowPrice not price, one ItemList per page, reviewCount > 0 before adding aggregateRating
Field-level access control is underused — protect sensitive fields at the field level, not just the collection level
Links
payload-plugin-reviews — npm
payload-plugin-seo-jsonld — npm
Payload CMS — the framework
Google Rich Results Test
About the author
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.
The plugins in this article came directly from real production needs. If something didn’t exist, I built it.
🔧 GitHub — github.com/spiritracking-arch
📦 payload-plugin-reviews — npmjs.com/package/payload-plugin-reviews
📦 payload-plugin-seo-jsonld — npmjs.com/package/payload-plugin-seo-jsonld
🌐 ScaleYourShop — lochness-paris.com/scaleyourshop.html — the infrastructure behind these 23 shops