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
Top comments (0)