DEV Community

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

Posted on Edited 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.

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:

  1. A customer reviews system with admin moderation and Google star ratings
  2. 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'),
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
→ 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 }
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 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) }}
  />
)}
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, 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
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
→ 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,
}
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

2. 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 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: -1 means Payload manages the 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


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.

Questions, bugs or a feature you need? Open an issue on GitHub or leave a comment below. 👇

Top comments (0)