@ 聚焦- reactive/ payload- plugin- ab
A/B 测试插件 有效载荷CMS v3 + 下一个.js. 直接在有效载荷管理员内部创建页面变体,分配流量百分数,并设置边缘的路由用户——没有额外的收藏,没有手动接线.
变体与原页的同名相活. 该插件注入了管理员UI,悬钩,以及一个显示Next.js中件读取到路由流量的变体而无需数据库回转.
AI 整合提示
复制并粘贴此提示到您的 AI 助手( Cursor, Claude等) 中, 将插件整合到已有的 Biveload + Next.js 项目中 .
I want to add A/B testing to my Payload CMS v3 + Next.js project using @focus-reactive/payload-plugin-ab.
## How it works
Variants are regular documents in the same collection as the original page, distinguished by an
injected `_abVariantOf` relationship field. The plugin auto-injects:
- A Variants panel (sidebar) on original pages — lists variants, traffic % inputs, "+ Add new" button
- A "Variant of" badge (sidebar) on variant pages — links back to the original
- Hooks that maintain a variant manifest (a path-keyed JSON map) in a storage adapter
- A `POST /api/_ab/duplicate` endpoint that clones a page with slug `original--{hash}`
The manifest is read by Next.js middleware at the edge to rewrite requests to the correct variant.
## Installation
pnpm add @focus-reactive/payload-plugin-ab
## Step 1 — Register the plugin in payload.config.ts
import { abTestingPlugin } from '@focus-reactive/payload-plugin-ab'
import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
const abAdapter = payloadGlobalAdapter({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
})
// Inside buildConfig({ plugins: [...] })
abTestingPlugin({
storage: abAdapter,
collections: {
// Key = slug of any collection you want to A/B test
pages: {
generatePath: ({ doc, locale }) => {
const slug = doc.slug as string | undefined
if (!slug) return null
return locale ? `/${locale}/${slug}` : `/${slug}`
},
// generateVariantData is optional — auto-generates:
// { bucket: variantSlug, rewritePath: generatePath(variantDoc), passPercentage }
},
},
})
## Step 2 — Add Next.js middleware
import { createResolveAbRewrite } from '@focus-reactive/payload-plugin-ab/middleware'
import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
const storage = payloadGlobalAdapter({ serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '' })
const resolveAbRewrite = createResolveAbRewrite({
storage,
getBucket: (v) => v.bucket,
getRewritePath: (v) => v.rewritePath,
getPassPercentage: (v) => v.passPercentage,
})
export async function middleware(request) {
const { pathname } = request.nextUrl
const result = await resolveAbRewrite(request, pathname, pathname, pathname)
return result ?? NextResponse.next()
}
export const config = { matcher: ['/((?!_next|api|favicon.ico).*)'] }
## Important notes
- generatePath must return a non-null path for variant slugs (e.g. "about--4ji9") since it
is also used to compute the variant's rewrite path when generateVariantData is omitted.
- Variant documents are hidden from the collection list view automatically.
- The slug field (and tenantField if configured) is read-only on variant documents.
- Percentage validation: the sum of all variant passPercentage values for one page cannot exceed 100.
A Payload ValidationError is thrown on save if it would.
- For Vercel deployments, swap payloadGlobalAdapter for vercelEdgeAdapter for sub-millisecond
manifest reads from Vercel Edge Config.
- If the project uses Payload localization, locale-aware manifest entries are written automatically
— no extra config needed.
如何运作
变体存在 在与原始页面相同的收藏中数字 :
pages collection
├── original slug: "about" _abVariantOf: null
├── variant A slug: "about--4ji9" _abVariantOf: <original id>
└── variant B slug: "about--x2k1" _abVariantOf: <original id>
在每次保存或删除时,会重算 变量显示 ——由 URL 路径键入的平版 JSON 对象——并写入存储适配器. Next.js 中间软件在边缘读取运算表,以便在任何渲染发生之前重写请求.
Payload Admin → afterChange / afterDelete hooks → Storage Adapter
↓
{ "/about": [variantA, variantB] }
↓
Next.js Middleware (edge)
↓
Route user to original or variant
安装
pnpm add @focus-reactive/payload-plugin-ab
对于Vercel Edge适配器,也安装:
pnpm add @vercel/edge-config
同伴依赖性 : payload ^3.0.0。 。 。 . next ^14 || ^15 财务报告和审定财务报表 react ^18 || ^19 是可选的,只要求中件和分析.
快速启动
步骤1——注册插件
// payload.config.ts
import { buildConfig } from "payload";
import { abTestingPlugin } from "@focus-reactive/payload-plugin-ab";
import { payloadGlobalAdapter } from "@focus-reactive/payload-plugin-ab/adapters/payload-global";
const abAdapter = payloadGlobalAdapter({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? "",
});
export default buildConfig({
plugins: [
abTestingPlugin({
storage: abAdapter,
collections: {
pages: {
generatePath: ({ doc, locale }) => {
const slug = doc.slug as string | undefined;
if (!slug) return null;
return locale ? `/${locale}/${slug}` : `/${slug}`;
},
},
},
}),
],
});
插件注入每个配置的收藏 :
- 备选面板 (原页边栏) — 列出输入% 的变体, 添加, 删除
- "变相" 只读关系( 变体页上的边栏)
_abVariantOf财务报告和审定财务报表_abPassPercentage字段( 隐藏在编辑器中)admin.baseListFilter从列表视图中隐藏变量文档beforeChange编号 :afterChange编号 :afterDelete钩子
交通百分比
每个变体都有自己的 _abPassPercentage,与变体文档一起版本:
版本草稿中包含您正在编辑的值, 已出版的版本中包含该值
显示服务。 原始页面没有存储副本.
备选案文小组的编辑百分比 _abPendingPercentages 虚拟
字段,没有数据库列,随原保存发送 :
- 保存草稿 每个变体草案的新百分比。 现场直播 流量继续使用之前发布的分数.
- 发布 原文中将其应用于已公布的变体。 只有百分比为 发布——一个变体的未发布内容保持未发布,并在发布时进行直播 那变体本身.
- 从未出版的变体,无论百分比如何,都不使用流量.
可变百分比必须总计为99%或更低,使原来的百分比至少为1%.
第2步——接通中间软件
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createResolveAbRewrite } from "@focus-reactive/payload-plugin-ab/middleware";
import { payloadGlobalAdapter } from "@focus-reactive/payload-plugin-ab/adapters/payload-global";
const storage = payloadGlobalAdapter({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? "",
});
const resolveAbRewrite = createResolveAbRewrite({
storage,
getBucket: (v) => v.bucket,
getRewritePath: (v) => v.rewritePath,
getPassPercentage: (v) => v.passPercentage,
});
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const result = await resolveAbRewrite(request, pathname, pathname, pathname);
return result ?? NextResponse.next();
}
export const config = {
matcher: ["/((?!_next|api|favicon.ico).*)"],
};
配置参考
插件选项
interface AbTestingPluginConfig<TVariantData extends object> {
storage: StorageAdapter<TVariantData>;
collections: Record<string, CollectionABConfig<TVariantData>>;
enabled?: boolean; // default: true
debug?: boolean; // show manifest Global in Payload admin. default: false
}
收藏AB 配置
interface CollectionABConfig<TVariantData = DefaultVariantData> {
/**
* Slug field name used to generate variant slugs: `{original}--{hash}`.
* Default: 'slug'
*/
slugField?: string;
/**
* Dot-notation path to the tenant field.
* Scopes percentage validation per tenant; locks the field read-only on variants.
*/
tenantField?: string;
/**
* Returns the URL path used as the manifest key.
* Return null to skip this document.
* Called once per locale when localization is enabled.
*
* Must also return a valid path for variant slugs (e.g. 'about--4ji9')
* since it is used to compute rewritePath when generateVariantData is omitted.
*/
generatePath: (args: { doc: Record<string, unknown>; locale: string | undefined }) => string | null;
/**
* Builds the data stored per variant in the manifest.
* When omitted, auto-generates: { bucket: variantSlug, rewritePath, passPercentage }
*/
generateVariantData?: (args: {
doc: Record<string, unknown>; // parent document
variantDoc: Record<string, unknown>; // variant document
locale: string | undefined;
}) => TVariantData;
}
// Default shape when generateVariantData is omitted:
type DefaultVariantData = {
bucket: string; // variant's slug, e.g. 'about--4ji9'
rewritePath: string; // result of generatePath(variantDoc, locale)
passPercentage: number; // _abPassPercentage field value
};
存储加速
interface StorageAdapter<TVariantData extends object> {
write(path: string, variants: TVariantData[], payload: Payload): Promise<void>;
read(path: string): Promise<TVariantData[] | null>; // must be edge-compatible
clear(path: string, payload: Payload): Promise<void>;
createGlobal?(debug: boolean): GlobalConfig; // optional, used by payloadGlobalAdapter
}
执行此接口以使用任何后端( Redis, Upstash, Vercel KV等) .
存储适配器
全球绘图器
将清单存储在有效载荷 Global 中作为 JSON 字段。 中间软件通过有效载荷 REST API获取.
import { payloadGlobalAdapter } from "@focus-reactive/payload-plugin-ab/adapters/payload-global";
const storage = payloadGlobalAdapter({
serverURL: "https://cms.example.com", // required for middleware reads
globalSlug: "_abManifest", // default
apiRoute: "/api", // default
});
Global默认隐藏于管理员. 设定 debug: true 在插件选项中以显示 系统 组合.
缩写
将列表存储在 Vercel Edge Config 中,用于分毫秒读取边缘.
import { vercelEdgeAdapter } from "@focus-reactive/payload-plugin-ab/adapters/vercel-edge";
const storage = vercelEdgeAdapter({
configID: process.env.EDGE_CONFIG_ID!,
configURL: process.env.EDGE_CONFIG!,
vercelRestAPIAccessToken: process.env.VERCEL_REST_API_ACCESS_TOKEN!,
teamID: process.env.VERCEL_TEAM_ID, // optional
manifestKey: "ab-testing", // default
});
必需的 vars : EDGE_CONFIG, (中文(简体) ), EDGE_CONFIG_ID, (中文(简体) ), VERCEL_REST_API_ACCESS_TOKEN。 。 。 .
在 Vercel 仪表板中创建边框配置商店并安装 @vercel/edge-config。 。 。 .
中间软件
创建 Solve 重写
import { createResolveAbRewrite } from "@focus-reactive/payload-plugin-ab/middleware";
const resolveAbRewrite = createResolveAbRewrite({
storage, // same adapter as the plugin
getBucket: (v) => v.bucket, // extract bucket key
getRewritePath: (v) => v.rewritePath, // extract internal rewrite URL
getPassPercentage: (v) => v.passPercentage, // enables weighted routing
});
// In middleware:
const result = await resolveAbRewrite(
request,
pathname, // visiblePathname — used as the per-path cookie key
pathname, // manifestKey — key to look up in the manifest
pathname, // originalRewritePath — where to send 'original' bucket users
);
三个路径参数允许您解开用户所看到的,显示的密钥,以及内部重写目标——对本地语前缀有用:
const internalPath = `/${locale}${pathname}`;
await resolveAbRewrite(request, pathname, internalPath, internalPath);
跑道: 何时 getPassPercentage 提供,每个变体都得到其流量的百分比;其余为原始。 插件在保存上验证总和从未超过100% 。 何时 getPassPercentage 被省略,所有变体和原共享的概率相等.
粘性任务 : Bucket任务被存储在一种活于浏览器会话的pro-path cookie中,因此返回的用户总是看到相同的变体. 如果桶饼干不见了,中间的软件会从实验饼干中恢复分配;再取出一个其变体已不存在的桶.
Cookie 系统
| 饼干 | 默认名称 | 终身 | 目的 |
|---|---|---|---|
| 弹夹 | payload_ab_bucket_{path} | 90天时间 | 此用户所在的桶 |
| 访客身份证 | ab_visitor_id | 365天(1天) | 分析的持续访问者识别符 |
| 实验 | exp_{path} | 90天时间 | Bucket 名称可读客户端用分析钩 |
取代名称和寿命( A)visitorIdMaxAge, (中文(简体) ), expCookieMaxAge, (中文(简体) ), bucketCookieMaxAge(以秒计) cookies 配置为 createResolveAbRewrite数字 :
import type { AbCookieConfig } from "@focus-reactive/payload-plugin-ab/middleware";
// Define once, share between middleware and analytics
export const abCookies: AbCookieConfig = {
visitorIdCookieName: "my_visitor_id",
getExpCookieName: (key) => `ab_exp_${key}`,
};
使用 resolveAbCookieNames(config, experimentId) 在服务器组件中以纯字符串方式获取 cookie 名称以传递给客户端组件 :
import { resolveAbCookieNames } from "@focus-reactive/payload-plugin-ab/middleware";
const { variantCookieName, visitorCookieName } = resolveAbCookieNames(abCookies, "/en/about");
分析一体化
挂载 <ExperimentTracker experimentId={manifestKey} /> 每个变式
页 次 它在以后的每一个事件上 都标出三个GA4的自定义尺寸:
fr_ab_experiment——实验id(明显密钥等./en/about(中文(简体) )fr_ab_variant- 指定桶(original或变种涕丸)fr_ab_visitor_id- 那个ab_visitor_id饼干价值
在您的 GA4 属性中注册这三个维度 。 这个
@focus-reactive/payload-plugin-analytics A/B 标签读取以计算
接触、转化率、升力、意义和SRM。 转换是
分析插件已存在 lead_action 实验页上的事件——那里
没有单独的AB转换事件.
插件自动创建 ab-experiments 收藏(隐藏,除非 debug: true(中文(简体) )
记录每个实验 startedAt 在第一个变体发布.
使用实验跟踪器
// app/[locale]/[slug]/page.tsx (Server Component)
import { ExperimentTracker } from "@focus-reactive/payload-plugin-ab/analytics/client";
import { resolveAbCookieNames } from "@focus-reactive/payload-plugin-ab/middleware";
export default async function Page({ params }) {
const experimentId = `/${params.locale}/${params.slug}`;
const { variantCookieName, visitorCookieName } = resolveAbCookieNames(undefined, experimentId);
return (
<>
{/* page content */}
<ExperimentTracker
experimentId={experimentId}
variantCookieName={variantCookieName}
visitorCookieName={visitorCookieName}
/>
</>
);
}
ExperimentTracker 是客户组件。 道具 :
| Prop | 类型 | 需求 | 说明 |
|---|---|---|---|
experimentId | string | 对 | 突出密钥( URL 路径, 例如 ). /en/about(中文(简体) ) |
variantCookieName | string | 没有 | 覆盖变体桶的饼干名称( 使用) resolveAbCookieNames(中文(简体) ) |
visitorCookieName | string | 没有 | 覆盖访问者身份的 cookie 名称( 使用) resolveAbCookieNames(中文(简体) ) |
多用途支助
abTestingPlugin({
storage,
collections: {
pages: {
tenantField: 'tenant', // dot-notation path to the tenant field
generatePath: ...,
},
},
})
设定时,百分数验证为每个租户范围,租户田被锁定在变体页上只读.
本地化支助
不需要额外的配置 。 何时 payload.config.localization 设置时,插件将每个地方的每个保存上显示每个地方,并每个地方写一个单独的显示条目. locale 实值 undefined 未配置本地化时.
generatePath: ({ doc, locale }) => {
const slug = doc.slug as string | undefined;
if (!slug) return null;
return locale ? `/${locale}/${slug}` : `/${slug}`; // '/en/about', '/fr/about'
};
参考文献
| 导入路径 | 出口 |
|---|---|
@focus-reactive/payload-plugin-ab | abTestingPlugin, (中文(简体) ), AbTestingPluginConfig, (中文(简体) ), CollectionABConfig, (中文(简体) ), StorageAdapter, (中文(简体) ), AbCookieConfig, (中文(简体) ), resolveAbCookieNames, (中文(简体) ), ResolvedAbCookieNames, (中文(简体) ), AB_EXPERIMENTS_SLUG, (中文(简体) ), AB_DIMENSION_PARAMS |
@focus-reactive/payload-plugin-ab/adapters/payload-global | payloadGlobalAdapter |
@focus-reactive/payload-plugin-ab/adapters/vercel-edge | vercelEdgeAdapter |
@focus-reactive/payload-plugin-ab/middleware | createResolveAbRewrite, (中文(简体) ), resolveAbCookieNames, (中文(简体) ), AbCookieConfig, (中文(简体) ), ResolveAbRewriteConfig |
@focus-reactive/payload-plugin-ab/analytics | ExperimentTracker, (中文(简体) ), ExperimentTrackerProps, (中文(简体) ), AbCookieConfig, (中文(简体) ), resolveAbCookieNames, (中文(简体) ), ResolvedAbCookieNames |
@focus-reactive/payload-plugin-ab/analytics/client | ExperimentTracker, (中文(简体) ), ExperimentTrackerProps, (中文(简体) ), AbCookieConfig, (中文(简体) ), resolveAbCookieNames, (中文(简体) ), ResolvedAbCookieNames |