惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

A
About on SuperTechFans
有赞技术团队
有赞技术团队
人人都是产品经理
人人都是产品经理
月光博客
月光博客
美团技术团队
博客园 - 聂微东
阮一峰的网络日志
阮一峰的网络日志
WordPress大学
WordPress大学
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园_首页
爱范儿
爱范儿
G
Google Developers Blog
aimingoo的专栏
aimingoo的专栏
T
The Blog of Author Tim Ferriss
MongoDB | Blog
MongoDB | Blog
小众软件
小众软件
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
IT之家
IT之家
I
InfoQ
B
Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
大猫的无限游戏
大猫的无限游戏
T
Tailwind CSS Blog
F
Fortinet All Blogs

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
How I Built the Two Missing Payload CMS v3 Plugins — Revi...
Camille G · 2026-06-16 · via 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
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