Most A/B testing tools are overkill for blog posts. You don't need complex feature flags or expensive platformsβjust a way to serve different content variants and track which performs better.
MDXBird is a simple approach to A/B testing MDX blog posts. This tutorial walks you through building it from scratch.
What You'll Build
Variant file system (post.mdx vs post.b.mdx)
Deterministic user assignment (same user always sees same variant)
GDPR-friendly tracking (no personal data, IP-based hashing)
Analytics dashboard (Tinybird for querying metrics)
Prerequisites
Next.js 14+ with App Router
Tinybird account (sign up free )
MDX blog setup (Contentlayer or similar)
Step 1: Set Up Tinybird Datasource
First, create a datasource in Tinybird to store your events.
1.1 Create the datasource file:
Create datasources/events.datasource:
DESCRIPTION >
MDXBird A / B testing events datasource - tracks blog post engagement metrics
SCHEMA >
`timestamp` DateTime64( 3 ) `json:$.timestamp` ,
`session_id` String `json:$.session_id` ,
`user_id` String `json:$.user_id` ,
`page` String `json:$.page` ,
`variant` LowCardinality(String) `json:$.variant` ,
`device_type` LowCardinality(String) `json:$.device_type` ,
`event_type` LowCardinality(String) `json:$.event_type` ,
-- Click event fields
`click_link_url` String `json:$.click_link_url` ,
`click_link_text` String `json:$.click_link_text` ,
-- Scroll event fields
`scroll_depth` UInt8 `json:$.scroll_depth` ,
-- Session event fields
`session_duration` UInt32 `json:$.session_duration` ,
`session_is_bounce` UInt8 `json:$.session_is_bounce` ,
`user_agent` String `json:$.user_agent` ,
`referrer` String `json:$.referrer` ,
`country` LowCardinality(String) `json:$.country` ,
`pathname` String `json:$.pathname` ,
`hostname` String `json:$.hostname`
ENGINE "MergeTree"
ENGINE_SORTING_KEY "timestamp"
ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
1.2 Push to Tinybird:
# Install Tinybird CLI
npm install -g @tinybird/cli
# Login
tb auth
# Push datasource
tb push datasources/events.datasource
1.3 Get your credentials:
After pushing, Tinybird will give you:
TINYBIRD_TOKEN (write token)
TINYBIRD_DATASOURCE (datasource name)
TINYBIRD_HOST (usually api.tinybird.co)
Add these to your .env.local:
TINYBIRD_TOKEN = p.your_token_here
TINYBIRD_DATASOURCE = events
TINYBIRD_HOST = api.tinybird.co
Step 2: Create Variant Files
Create your blog post variants using a simple naming convention:
content/blog/
βββ nsfw.mdx (control variant)
βββ nsfw.b.mdx (variant B)
Both files should have the same frontmatter structure, but different content. The .b suffix indicates it's a variant.
Example nsfw.mdx (control):
---
title: 'The Best NSFW AI Chat Platforms'
description: 'We tested 100+ hours of NSFW AI chatbots...'
---
Most AI girlfriend apps are boring...
[ Your control content here ]
Example nsfw.b.mdx (variant):
---
title: 'The Best NSFW AI Chat Platforms'
description: 'We tested 100+ hours of NSFW AI chatbots...'
---
Looking for uncensored AI chat? Here's what actually works...
[ Your variant content here - different headline, different intro ]
Step 3: Server-Side Variant Assignment
Create lib/mdxbird/server.ts:
import { cookies } from 'next/headers'
import crypto from 'crypto'
import { allBlogPosts } from 'contentlayer/generated'
export type Variant = 'control' | 'b'
/**
* Extract base slug from variant slug
* nsfw.b -> nsfw
* nsfw -> nsfw
*/
export function extractBaseSlug ( slug : string ) : string {
return slug. replace ( / \. (a | b) $ / , '' )
}
/**
* Extract variant from slug
* nsfw.b -> b
* nsfw -> control
*/
export function extractVariantFromSlug ( slug : string ) : Variant {
return slug. endsWith ( '.b' ) ? 'b' : 'control'
}
/**
* Get assigned variant for a user
* Uses deterministic hash: IP + User-Agent + slug
* Falls back to cookie if exists
*/
export async function getAssignedVariant (
baseSlug : string ,
ip : string ,
userAgent : string
) : Promise < Variant > {
const cookieName = `mdxbird_variant_${ baseSlug }`
const cookieStore = await cookies ()
const existing = cookieStore. get (cookieName)?.value
// Return existing assignment if cookie exists
if (existing === 'b' ) return 'b'
if (existing === 'control' ) return 'control'
// Deterministic hash: IP + UA + slug
// Same user always gets same variant
const hash = crypto
. createHash ( 'sha256' )
. update ( `${ ip }${ userAgent }${ baseSlug }` )
. digest ( 'hex' )
const variant : Variant = parseInt (hash. slice ( 0 , 8 ), 16 ) % 2 === 0 ? 'control' : 'b'
// Set cookie for 7 days to persist assignment
// Note: In Next.js 15, cookies().set() works directly
;( await cookies ()). set (cookieName, variant, {
httpOnly: true ,
secure: process.env. NODE_ENV === 'production' ,
maxAge: 60 * 60 * 24 * 7 , // 7 days
path: '/' ,
sameSite: 'lax' ,
})
return variant
}
/**
* Get blog post by variant
*/
export function getBlogPostByVariant (
baseSlug : string ,
variant : Variant
) : typeof allBlogPosts[number] | null {
const slug = variant === 'control' ? baseSlug : `${ baseSlug }.b`
return allBlogPosts. find (( post ) => post.slug === slug) || null
}
Step 4: Create API Route for Events
Create app/api/mdxbird/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const eventSchema = z. object ({
timestamp: z. number (),
session_id: z. string (),
user_id: z. string (),
page: z. string (),
variant: z. enum ([ 'control' , 'b' ]),
event_type: z. enum ([ 'session_start' , 'click' , 'scroll' , 'session_end' ]),
// Click fields
click_link_url: z. string (). optional (),
click_link_text: z. string (). optional (),
// Scroll fields
scroll_depth: z. number (). optional (),
// Session fields
session_duration: z. number (). optional (),
session_is_bounce: z. number (). optional (),
})
export async function POST ( request : NextRequest ) {
try {
const body = await request. json ()
const events = Array. isArray (body) ? body : [body]
// Validate events
const validatedEvents = events. map (( event ) => eventSchema. parse (event))
// Get Tinybird credentials
const token = process.env. TINYBIRD_TOKEN
const datasource = process.env. TINYBIRD_DATASOURCE
const host = process.env. TINYBIRD_HOST || 'api.tinybird.co'
if ( ! token || ! datasource) {
console. error ( 'MDXBird: Tinybird credentials not configured' )
return NextResponse. json (
{ error: 'Analytics not configured' },
{ status: 500 }
)
}
// Send to Tinybird
const response = await fetch (
`https://${ host }/v0/events?name=${ datasource }` ,
{
method: 'POST' ,
headers: {
Authorization: `Bearer ${ token }` ,
'Content-Type' : 'application/json' ,
},
body: JSON . stringify (validatedEvents),
}
)
if ( ! response.ok) {
const errorText = await response. text ()
console. error ( '[MDXBird API] Tinybird error:' , response.status, errorText)
return NextResponse. json (
{ error: 'Failed to send events' },
{ status: 500 }
)
}
return NextResponse. json ({ success: true , count: validatedEvents. length })
} catch (error) {
console. error ( 'MDXBird: API route error' , error)
if (error instanceof z . ZodError ) {
return NextResponse. json ({ error: 'Invalid event format' , details: error.errors }, { status: 400 })
}
return NextResponse. json ({ error: 'Internal server error' }, { status: 500 })
}
}
Step 5: Client-Side Event Batching
Create lib/mdxbird/batcher.ts:
const BATCH_SIZE = 10
const BATCH_TIMEOUT = 5000 // 5 seconds
interface MDXBirdEvent {
timestamp : number
session_id : string
user_id : string
page : string
variant : string
event_type : 'session_start' | 'click' | 'scroll' | 'session_end'
[key: string] : any
}
let eventQueue : MDXBirdEvent [] = []
let batchTimer : ReturnType < typeof setTimeout> | null = null
export function enqueueEvent ( event : MDXBirdEvent ) : void {
eventQueue. push (event)
// Send immediately if batch is full
if (eventQueue. length >= BATCH_SIZE ) {
flushEvents ()
return
}
// Set timer to flush after timeout
if ( ! batchTimer) {
batchTimer = setTimeout (() => {
flushEvents ()
}, BATCH_TIMEOUT )
}
}
async function flushEvents () : Promise < void > {
if (eventQueue. length === 0 ) return
const eventsToSend = [ ... eventQueue]
eventQueue = []
if (batchTimer) {
clearTimeout (batchTimer)
batchTimer = null
}
try {
const response = await fetch ( '/api/mdxbird' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify (eventsToSend),
})
if ( ! response.ok) {
// Re-queue on failure
eventQueue. unshift ( ... eventsToSend)
}
} catch (error) {
console. error ( '[MDXBird Batcher] Failed to send events' , error)
// Re-queue on failure
eventQueue. unshift ( ... eventsToSend)
}
}
Step 6: Client-Side Tracking
Create lib/mdxbird/client.ts:
'use client'
import { enqueueEvent } from './batcher'
let sessionId : string | null = null
let userId : string | null = null
let sessionStartTime : number | null = null
function getSessionId () : string {
if ( typeof window === 'undefined' ) return ''
const key = 'mdxbird_session_id'
let sid = sessionStorage. getItem (key)
if ( ! sid) {
sid = `sess_${ Date . now () }_${ Math . random (). toString ( 36 ). slice ( 2 , 11 ) }`
sessionStorage. setItem (key, sid)
}
return sid
}
function getUserId () : string {
if ( typeof window === 'undefined' ) return ''
const key = 'mdxbird_user_id'
let uid = localStorage. getItem (key)
if ( ! uid) {
uid = `user_${ Date . now () }_${ Math . random (). toString ( 36 ). slice ( 2 , 11 ) }`
localStorage. setItem (key, uid)
}
return uid
}
export function trackSessionStart ( page : string , variant : string ) : void {
if ( typeof window === 'undefined' ) return
sessionId = getSessionId ()
userId = getUserId ()
sessionStartTime = Date. now ()
enqueueEvent ({
timestamp: Date. now (),
session_id: sessionId,
user_id: userId,
page,
variant,
event_type: 'session_start' ,
})
}
export function trackClick ( page : string , variant : string , linkUrl : string , linkText ?: string ) : void {
if ( typeof window === 'undefined' ) return
enqueueEvent ({
timestamp: Date. now (),
session_id: sessionId || getSessionId (),
user_id: userId || getUserId (),
page,
variant,
event_type: 'click' ,
click_link_url: linkUrl,
click_link_text: linkText,
})
}
export function trackScroll ( page : string , variant : string , depth : number ) : void {
if ( typeof window === 'undefined' ) return
enqueueEvent ({
timestamp: Date. now (),
session_id: sessionId || getSessionId (),
user_id: userId || getUserId (),
page,
variant,
event_type: 'scroll' ,
scroll_depth: depth,
})
}
export function trackSessionEnd ( page : string , variant : string , duration : number , isBounce : boolean ) : void {
if ( typeof window === 'undefined' ) return
enqueueEvent ({
timestamp: Date. now (),
session_id: sessionId || getSessionId (),
user_id: userId || getUserId (),
page,
variant,
event_type: 'session_end' ,
session_duration: duration,
session_is_bounce: isBounce ? 1 : 0 ,
})
}
Step 7: React Components
Create lib/mdxbird/components.tsx:
'use client'
import { createContext, useContext, useEffect, useRef } from 'react'
import { trackSessionStart, trackClick, trackScroll, trackSessionEnd } from './client'
interface MDXBirdContextType {
trackClick : ( linkUrl : string , linkText ?: string ) => void
}
const MDXBirdContext = createContext < MDXBirdContextType | null >( null )
export function useMDXBirdContext () {
const context = useContext (MDXBirdContext)
if ( ! context) {
throw new Error ( 'useMDXBirdContext must be used within MDXBirdProvider' )
}
return context
}
interface MDXBirdProviderProps {
children : React . ReactNode
page : string
variant : string
}
export function MDXBirdProvider ({
children ,
page ,
variant ,
} : MDXBirdProviderProps ) {
const sessionStartTimeRef = useRef < number >(Date. now ())
const maxScrollDepthRef = useRef < number >( 0 )
useEffect (() => {
// Track session start
trackSessionStart (page, variant)
// Track scroll depth
const handleScroll = () => {
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight - window.innerHeight
const depth = Math. round ((scrollTop / docHeight) * 100 )
maxScrollDepthRef.current = Math. max (maxScrollDepthRef.current, depth)
// Throttle scroll events (only send every 10%)
if (depth % 10 === 0 ) {
trackScroll (page, variant, depth)
}
}
window. addEventListener ( 'scroll' , handleScroll, { passive: true })
// Track session end on unmount
return () => {
const duration = Date. now () - sessionStartTimeRef.current
const isBounce = maxScrollDepthRef.current < 25 && duration < 30000
trackSessionEnd (page, variant, duration, isBounce)
window. removeEventListener ( 'scroll' , handleScroll)
}
}, [page, variant])
const handleClick = ( linkUrl : string , linkText ?: string ) => {
trackClick (page, variant, linkUrl, linkText)
}
return (
< MDXBirdContext.Provider value = {{ trackClick: handleClick }}>
{children}
</ MDXBirdContext.Provider >
)
}
export function TrackedLink ({
href ,
children ,
... props
} : {
href : string
children : React . ReactNode
[key: string] : any
}) {
const { trackClick } = useMDXBirdContext ()
return (
< a
href = {href}
onClick = {() => trackClick (href, children?. toString ())}
{ ... props}
>
{children}
</ a >
)
}
Step 8: Integrate into Blog Post Page
Update app/blog/[slug]/page.tsx:
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { extractBaseSlug, getAssignedVariant, getBlogPostByVariant } from '@/lib/mdxbird/server'
import { MDXBirdProvider } from '@/lib/mdxbird/components'
import { MDX } from '@/components/blog/mdx'
import { allBlogPosts } from 'contentlayer/generated'
export default async function BlogPost ({
params ,
} : {
params : Promise <{ slug : string }>
}) {
const { slug } = await params
const baseSlug = extractBaseSlug (slug)
// Get user IP and User-Agent
const headersList = await headers ()
const ip = headersList. get ( 'x-forwarded-for' )?. split ( ',' )[ 0 ] || 'unknown'
const userAgent = headersList. get ( 'user-agent' ) || 'unknown'
// Assign variant
const variant = await getAssignedVariant (baseSlug, ip, userAgent)
// Load the correct variant
const post = getBlogPostByVariant (baseSlug, variant)
if ( ! post) return notFound ()
return (
< article >
< MDXBirdProvider page = {baseSlug} variant = {variant}>
< MDX code = {post.body.code} images = {[]} tableOfContents = {post.tableOfContents} />
</ MDXBirdProvider >
</ article >
)
}
Update your MDX components to use TrackedLink:
// components/blog/mdx.tsx
import { TrackedLink } from '@/lib/mdxbird/components'
const components = {
a : ( props : { href : string ; children : React . ReactNode }) => (
< TrackedLink { ... props} />
),
// ... other components
}
Step 9: Query Analytics in Tinybird
Create Tinybird endpoints to query your data:
9.1 Variant comparison:
-- endpoints/variant_metrics.pipe
SELECT
variant,
count () as sessions ,
avg (session_duration) / 1000 as avg_duration_seconds,
countIf(session_is_bounce = 1 ) / count () * 100 as bounce_rate,
countIf(event_type = 'click' ) as total_clicks
FROM events
WHERE page = {{ String( page , 'nsfw' ) }}
AND event_type = 'session_end'
GROUP BY variant
9.2 Click-through rate:
-- endpoints/ctr.pipe
SELECT
variant,
count ( DISTINCT session_id) as sessions ,
countIf(event_type = 'click' ) as clicks,
clicks / sessions as ctr
FROM events
WHERE page = {{ String( page , 'nsfw' ) }}
GROUP BY variant
9.3 Scroll depth:
-- endpoints/scroll_depth.pipe
SELECT
variant,
avg (scroll_depth) as avg_scroll_depth,
max (scroll_depth) as max_scroll_depth
FROM events
WHERE page = {{ String( page , 'nsfw' ) }}
AND event_type = 'scroll'
GROUP BY variant
9.4 Push endpoints:
tb push endpoints/variant_metrics.pipe
tb push endpoints/ctr.pipe
tb push endpoints/scroll_depth.pipe
9.5 Get read token:
In Tinybird dashboard, create a read token with access to your datasource. Add it to .env.local:
TINYBIRD_READ_TOKEN = p.your_read_token_here
9.6 Query from your app:
async function getVariantMetrics ( page : string ) {
const response = await fetch (
`https://api.tinybird.co/v0/pipes/variant_metrics.json?page=${ page }` ,
{
headers: {
Authorization: `Bearer ${ process . env . TINYBIRD_READ_TOKEN }` ,
},
}
)
return response. json ()
}
What You're Tracking
Sessions : Total visits per variant
Clicks : Link clicks and CTR
Scroll depth : How far users read
Session duration : Time on page
Bounce rate : Single-page sessions
Engagement rate : Non-bounce sessions
Key Benefits
Simple : File naming + tracking code
GDPR-friendly : IP-based hashing, no personal data
Deterministic : Same user always sees same variant
Flexible : Track any metric you care about
Free : Tinybird free tier is generous
That's it! You now have a complete A/B testing system for your blog posts. Create variants, track engagement, compare results, and optimize your content.