Running 23 European e-commerce shops on Payload CMS v3 taught me that some things simply don't exist yet. So I built them.
Update, October 2026: both plugins are now at v1.0.2. They are published from GitHub Actions with npm trusted publishing (no tokens) and provenance, and their npm pages now link back to the source. If one of them saves you time, a ⭐ on GitHub is the best way to help other Payload devs find it: reviews · seo-jsonld.
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 and 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: a relationship to products, a rating (1-5), a status select (pending/approved/rejected) and 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
→ Source on GitHub (⭐ welcome)
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 break 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, so there are no empty rating blocks. The two plugins are designed to work together: reviews collects and moderates the ratings, seo-jsonld turns them into stars in Google.
Install
npm install payload-plugin-seo-jsonld
import {
buildProductJsonLd,
buildBreadcrumbJsonLd,
buildItemListJsonLd,
buildWebSiteJsonLd,
buildOrganizationJsonLd,
} from 'payload-plugin-seo-jsonld'
→ payload-plugin-seo-jsonld on npm
→ Source on GitHub (⭐ welcome)
Part 3: Enabling Google indexing on new shops
Two things block Google on fresh Payload v3 deployments that aren't obvious.
1. Default noindex in 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
2. robots.txt
Verify your public/robots.txt allows 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 with the Google Rich Results Test.
What I learned
- Payload v3 routes are Next.js routes. Don't fight the framework: use the App Router for parameterized endpoints.
-
Always check migration mode.
batch: -1means Payload manages the schema automatically in dev, but won't auto-migrate in prod. -
Google is strict on JSON-LD.
lowPricenotprice, oneItemListper page,reviewCount > 0before addingaggregateRating. - Field-level access control is underused. Protect sensitive fields at the field level, not just the collection level.
Links
- payload-plugin-reviews on npm · GitHub · Payload Plugin Directory
- payload-plugin-seo-jsonld on npm · GitHub
- Payload CMS, the framework
- Google Rich Results Test
About the author
I'm Camille Guerineau, a freelance developer specializing in European e-commerce infrastructure. Before that, I spent ten years as a jewelry maker and founder of Loch Ness Paris, so I came to e-commerce from the shop side first. Today 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: npm
- 📦 payload-plugin-seo-jsonld: npm
- 🌐 ScaleYourShop: scaleyourshop.app, the infrastructure behind these 23 shops
- ✅ Verumano: a verifiable "human-made" label, my other open project, with its own npm client (
verumano)
Questions, bugs or a feature you need? Open an issue on GitHub or leave a comment below. 👇
Top comments (0)