VOOZH about

URL: https://dev.to/spiritrackingarch/how-i-built-the-two-missing-payload-cms-v3-plugins-reviews-json-ld-real-production-bugs-18a1

⇱ How I Built the Two Missing Payload CMS v3 Plugins — Reviews, JSON-LD & Real Production Bugs - DEV Community


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:

  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
  1. 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: 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

Both plugins are officially listed in the Payload Plugin Directory:


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.

🔧 GitHubgithub.com/spiritracking-arch
📦 payload-plugin-reviewsnpmjs.com/package/payload-plugin-reviews
📦 payload-plugin-seo-jsonldnpmjs.com/package/payload-plugin-seo-jsonld
🌐 ScaleYourShoplochness-paris.com/scaleyourshop.html
The infrastructure behind these 23 shops