DEV Community

Cover image for How I Built the Two Missing Payload CMS v3 Plugins — Reviews, JSON-LD & Real Production Bugs
Camille G
Camille G

Posted on • Originally published at lochness-paris.com

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

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'),
Enter fullscreen mode Exit fullscreen mode

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
    },
  ],
},
Enter fullscreen mode Exit fullscreen mode

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') },
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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) })
Enter fullscreen mode Exit fullscreen mode

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: ... }]
Enter fullscreen mode Exit fullscreen mode

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' } },
      ],
    },
  })
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Install

npm install payload-plugin-reviews
Enter fullscreen mode Exit fullscreen mode
// payload.config.ts
import { reviewsPlugin } from 'payload-plugin-reviews'

export default buildConfig({
  plugins: [reviewsPlugin({ productsCollection: 'products' })],
})
Enter fullscreen mode Exit fullscreen mode

→ 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 }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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) }}
  />
)}
Enter fullscreen mode Exit fullscreen mode

BreadcrumbList with nested categories
The BreadcrumbList recursively walks the category parent chain:

Shop → Jewelry → Bracelets → Product Name
Enter fullscreen mode Exit fullscreen mode
const collectParents = (c: any) => {
  if (c.parent && typeof c.parent === 'object') collectParents(c.parent)
  parents.push(c)
}
collectParents(cat)
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

The plugin only adds aggregateRating when reviewCount > 0 — no empty rating blocks.
Install

npm install payload-plugin-seo-jsonld
Enter fullscreen mode Exit fullscreen mode
import {
  buildProductJsonLd,
  buildBreadcrumbJsonLd,
  buildItemListJsonLd,
  buildWebSiteJsonLd,
  buildOrganizationJsonLd,
} from 'payload-plugin-seo-jsonld'
Enter fullscreen mode Exit fullscreen mode

→ 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,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  1. robots.txt Verify your public/robots.txt allows crawling:
User-agent: *
Allow: /
Sitemap: https://myshop.com/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)