Every feature, in depth
An ad server, made of listicles.
Here's everything inside it.
The deep-dive companion to the landing page. Ten sections. No marketing fluff — schemas, latency targets, allocation math, and the actual nouns the product runs on.
01 · BUILDER
Two layers of drag-and-drop. Mobile-first. Autosaves on every keystroke.
Drag ad units onto a page. Drag blocks into ad units. TipTap edits everything inline. The mobile viewport is the default — desktop is the secondary preview, not the other way around.
Capabilities
- Page builder. Stack-based reorder of ad units. Page shell edits inline via TipTap; structural position fixed by template.
- Ad-unit builder. Drag-drop reorder of blocks. Palette grouped Media / Text / CTA / Interactive / Structural.
- Inline rich text. TipTap everywhere — bold, italic, links with rel + new-tab, brand color palette, h2/h3, lists.
- Mobile-first preview. Default viewport 375px. Desktop and tablet are toggle-only. Real traffic distribution, not designer ego.
- Autosave. 1-second debounce on every keystroke and drag. Drafts mutate indefinitely; publish snapshots a new immutable version.
- Hover preview. Every ad-unit placement shows live RPM, CTR, version status without clicking in.
- AI slash-commands. /rewrite punchier, /add urgency, /translate Spanish, /match competitor tone — on every text field.
| type | description | fields |
|---|---|---|
| image | Image with promo banner overlays + star rating | src, alt, promoBannerText, starRating, reviewCount |
| headline | Bold display headline (h2/h3) | text, level, color, emojiPrefix |
| subheadline | Smaller hook line under the headline | text, color |
| bodyText | Multi-paragraph rich text via TipTap | content (TipTap JSON) |
| bulletList | Bulleted list with optional icons | items[], style (check/dot/number) |
| ctaButtonSingle | One CTA with sub-param overrides | text, destinationUrl, style, subParamOverrides |
| ctaButtonGroup | Multi-button qualifier (e.g., 6-button roof age) | questionText, buttons[], layout, finalCta |
| ctaTextLink | Inline text link with arrow | text, destinationUrl, arrowStyle |
| urgencyCallout | URGENT: callout box | text, bgColor, borderColor, icon |
| stepByStep | Numbered step instructions | steps[] |
| stateMap | US state map (clickable in v2) | highlightedStates[], linkPerState |
| divider | Horizontal rule | style, spacing |
| spacer | Vertical whitespace | height |
02 · AD UNITS
Every block is a versioned ad unit. The same unit ships across pages.
Ad units are first-class library items, not page-locked content. Place one unit on five pages, A/B-test it once, and the winning variant compounds everywhere it lives. RPM rolls up by unit, by slot, by CTA button — not just by page.
How reuse works
- PagePlacement is a join. One adUnit row, many pagePlacement rows. Edit the unit, every placement updates.
- Cross-page rollups. RPM aggregates by ad unit, by placement slot, by individual CTA button — not just by the page that hosts it.
- Per-placement overrides. The join row carries optional pageSpecificOverrides for one-off tweaks without forking the unit.
- Independent versioning. Each unit has its own version history, A/B tests, and bandit experiments. Page versioning is a separate axis.
- Ad-unit-level A/B rotates per impression. No within-session stickiness — the swap is invisible to the visitor.
- Co-pilot promotion. "This unit lifts RPM 18% on long-form senior pages — promote to the other 12 matching pages?" One click.
One unit, four placements, one rolled-up RPM
adUnit · qualifier-roof-age-v6
03 · TRACKING
Sub-50ms click ID. Tokens pin to a version. Sub-IDs persist across sessions.
Edge function mints a clickId on every CTA tap. Tokens never silently re-resolve to a newer version — they pin at creation. Bot scoring runs before write; filtered clicks still record + redirect, just flagged.
GET /c/8f3aJq2pK # 16-char base62 token resolves to (pinned at mint): pageVersionId = v_e1c4… adUnitVersionId = v_a04b… blockId = b_qual_roof ctaButtonId = cta_5_9_yrs → mint clickId, write row, 302 redirect: https://offer.partner.com/click ?clickId=cid_8f3a4c… &payout={payout} # downstream fills &sub1=roof_5_9 # button override wins &sub2=meta_camp_x # from landing URL &sub3=FL # geo_state token &sub4..sub10 # passthrough &source=tlb
Mechanics
- Token format. 16-char base62. Resolves to (pageVersionId, adUnitVersionId, blockId, ctaButtonId) via lookup table.
- Pin-on-mint. Old tokens never re-point to a newer version. Publish mints fresh tokens; cached URLs continue to work and route to the version they were minted against.
- Sub1–sub10 + UTMs. Captured at first landing, persisted on the visitor record, attributable on a return-session click 90 days later.
- Override hierarchy. per-button override → page-level override → visitor's captured value → empty.
- Bot filter hook. Cloudflare Bot Mgmt + FingerprintJS Pro + internal heuristics (datacenter IP, anomalous UA, click velocity per visitor).
- Filtered traffic. Still recorded, still redirected (don't tip off bots). Flagged isFiltered=true, excluded from bandit + default dashboards.
04 · POSTBACKS
Postbacks fire to your domain. We verify, normalize, and re-fire downstream.
Inbound postbacks land on /postback at the tenant domain — HMAC-verified, FX-normalized, deduped per integration rule. Outbound forwarding re-fires to your downstream stack with a 5-attempt retry budget over ~26 hours.
POST /postback ?clickId=cid_8f3a4c… &payout=42.10 ¤cy=USD &conversionType=sale &txid=ef_99201 &eventType=conversion &signature=a7f9c… X-Forwarded-For: 35.190.x.x # auth: hmac (constant-time) # rate limit: 600/min # dedupe rule: allow_if_payout_differs → accepted · stored · arm reward updated
GET https://leadrouter.example.com/cv ?cid=cid_8f3a4c… # clickId → cid &revenue=4210 # payoutAmountUsdCents &event=sale # conversionType &sub1=roof_5_9 &sub2=meta_camp_x retries: 1m → 6m → 36m → 3.6h → 21.6h on max-fail: dead-letter + alert
Configurable per integration
- Auth method. hmac · sharedSecret · ipAllowlist · none
- Dedupe. reject · allow_if_payout_differs · always_allow
- Late conversions. Accepted regardless of age. No time-based rejection.
- Currency. Daily FX. Original + USD-normalized stored side-by-side.
- Payout updates. eventType=payout_update revises a recorded conversion without writing a duplicate.
- Rate limit. Default 600/min per integration. Sustained 429s alert.
- Outbound retry. 5 attempts, exponential 1m → 6m → 36m → 3.6h → 21.6h. After max, dead-letter + Slack alert + AI-suggested fix.
- Audit retention. 90 days online (full inbound + outbound payloads), then cold storage.
AI assist
Paste downstream platform docs → co-pilot writes the param map, picks the strongest auth method that platform supports, and explains the dedupe rule that matches its retry behavior.
05 · VERSIONING
Drafts mutate. Publishes snapshot. Rollback is a new version, not a delete.
Every publish creates an immutable version row. Drafts can be edited indefinitely without polluting history. Rolling back to v4 from v7 creates v8 as a copy of v4 — the audit trail stays intact and v7 stays inspectable.
Two test surfaces
Page-level A/B
Multiple page versions live at once. Sticky per visitor via visitorId hash — same visitor sees the same variant for their session, no jarring within-session swaps.
Ad-unit-level A/B
Versions rotate per impression. The unit swap is invisible to the user, so no stickiness needed. Lets the bandit allocate fresh on every ad-unit render even if the page is sticky.
Auto-conclude
95% credible interval threshold by default. Tenants can hold for human review or set hands-off auto-promote.
Late conversion reconciliation
Postbacks accepted at any age re-weight arms. AI flags integrations where late attribution is meaningfully changing winners and suggests a delay-before-conclude window.
- v7Apr 28, 14:02
New hero · urgency callout · headline shorter
by Linda C. · RPM $42.10 · CTR 4.8%
Live - v6Apr 21, 09:14
Swapped CTA color · added trust strip
by Linda C. · RPM $36.40 · CTR 4.2%
Archived - v5Apr 14, 17:30
AI-generated challenger headline (won)
by AI co-pilot · RPM $33.10 · CTR 3.9%
Archived - v4Apr 09, 11:48
Rollback target for v8 if v7 underperforms
by R. Patel · RPM $31.20 · CTR 3.8%
Rollback target - v3Apr 02, 08:01
Initial publish
by Linda C. · RPM $28.90 · CTR 3.5%
Archived
06 · BANDIT
Thompson Sampling on RPM. CTR-warmed cold-start. 5% allocation floor.
The bandit optimizes revenue per mille — but RPM is a slow signal. Until each arm crosses 50 conversions, it samples Beta-Bernoulli on click-through. After threshold, it switches to normal-inverse-gamma on RPM. No arm ever drops below 5% allocation, and the AI always has three fresh challengers queued.
$42.10 · n=2,184
95% CI ±$2.40
$28.40 · n=2,011
95% CI ±$3.10
$19.80 · n=540
95% CI ±$5.80
Math, in plain English
- Cold-start. < 50 conversions per arm → Beta(α, β) on click events. reward = 1 if click else 0.
- Warm phase. Continuous Thompson Sampling on RPM with normal-inverse-gamma priors. reward = revenueUsdCents / impressions.
- Sticky page assignment. Page-level bandit hashes visitorId to keep variant assignment stable across the visitor's session.
- Per-impression at unit level. Ad-unit bandits draw a fresh sample on every render. No stickiness needed — invisible swaps.
- Allocation floor. 5% minimum per arm. Prevents starvation that would block learning.
- Update cadence. Allocation re-computed every 1-minute rollup window.
- Auto-challengers. AI generates three new variants — different headline, different CTA, different hero — each round, so the bandit always has new contestants to spar against the leader.
07 · ANALYTICS
IAB-viewable impressions. Pre-aggregated rollups. Drill on every dimension.
Impressions fire only when a unit hits 50% pixels in view for one continuous second. Events go to Postgres, then Inngest cron rolls up to minute / hour / day tables. At ~500K page views/day, the same dashboard layer points at Tinybird without rewriting.
Impressions
IAB-viewable: 50% pixels in view ≥ 1 continuous second
Clicks
CTA tap, server-side recorded, bot-filtered traffic flagged
CTR
clicks / impressions, filtered traffic excluded
Conversions
Inbound postback received, after dedupe rule applied
Conversion rate
conversions / clicks
Revenue
USD-normalized via daily FX, original amount also stored
EPC
revenue / clicks — earnings per click
RPM / ECPM
revenue per 1,000 impressions — primary bandit metric
RPS
revenue per session — visitor-level monetization
Visitors
Unique visitorIds in window (90-day cookie)
Sessions
visitorId observed after ≥ 30 min inactivity
Pages / session
Page views ÷ sessions
Drill-down dimensions
Every dimension can be sliced on every metric. Rollup tables enforce UNIQUE … NULLS NOT DISTINCT so missing UTMs aggregate cleanly.
Dashboards
- Overview
- Pages
- Ad Units
- Experiments
- Traffic Sources
- Geo
- Device
- Conversions
- Integrations
Phase 2
At ~500K page views/day, ingestion migrates to Tinybird. Postgres remains source of truth during the cutover; the dashboard query layer is unchanged.
08 · CO-PILOT
One co-pilot. It writes through your role, your domain scope, your audit log.
Build, Diagnose, Optimize, Compose, Explain, Operate, Migrate, Onboard. Same tenant context as a human teammate. Every action is a draft until you approve it — there is no AI auto-publish.
· 5 ad units placed, slot order by historical RPM in this vertical.
· Unit 3: ctaButtonGroup 6-button age qualifier (65–69, 70–74, 75–79, 80–84, 85+, Other) with sub1 overrides pre-filled.
· Unit 5: urgencyCallout tied to FL open enrollment window.
→ open in builder · publish requires your approval
· sub3 mix shifted 41%→62% toward taboola_desktop_us (lower-RPM cohort, −$8.20 RPM).
· Ad unit 4 RPM collapsed −38% after v12 publish Tue 14:02 — new hero pushed CTA below the fold on iOS Safari.
→ rollback v12 → v11 + pause taboola_desktop_us · est recovery +$4,800/wk
· Add BBB + secured-checkout strip above the qualifier.
· Keep the urgencyCallout below the qualifier for the test arm.
→ create experiment · 3 challenger variants drafted
· Mapped to long-form-comparison template.
· Brand colors swapped to your kit · FTC disclosure auto-added · author persona set to L. Chen.
· All CTA destinations cleared — point them at your offers before publish.
→ open draft · ready in builder
09 · TENANTS
Wildcard subdomain on signup. Custom domain with a super-admin gate.
Every tenant gets {slug}.thelisticlebuilder.com instantly. Custom domain attaches via Vercel after a super-admin abuse-filter approval — paste a domain and the AI pre-screens reputation before it hits the queue. BetterAuth handles magic link + Google OAuth. Audit log captures every state change with before/after diff.
What ships out of the box
- BetterAuth. Magic link + Google OAuth in v1. Passkey + SAML deferred to v2 (no v1 demand justifies the friction).
- Membership state machine. invited → accepted → suspended → revoked, transitions audit-logged.
- Domain scope. Each membership scopes to all or a list of authorizedDomainIds. Editor on veteran-discounts only? Done.
- Wildcard subdomain. {slug}.thelisticlebuilder.com live within seconds of signup. AI suggests slug variants tuned to vertical.
- Custom domain pipeline. Vercel domains API + Let's Encrypt, gated by super-admin queue. AI pre-screens reputation (prior content, blocklists) before queue insert.
- Audit log. Every state change captured with before/after diff. 1-year online retention, then archived.
| Capability | Owner | Admin | Editor | Viewer |
|---|---|---|---|---|
| Manage tenant settings + billing | — | — | — | |
| Invite + revoke teammates | — | — | ||
| Attach custom domain | — | — | ||
| Create / edit pages + ad units | — | |||
| Publish a version | — | |||
| Configure integrations + secrets | — | — | ||
| Conclude experiments | — | |||
| View dashboards + audit log | ||||
| Domain scope (restrict per user) |
10 · COMPLIANCE
Disclosures default-on. Bots filtered before write. Hybrid SSR keeps CWV honest.
FTC disclosure is a required block in every template, with a vertical-aware library tuned per offer. Cookie consent is geo-detected at the edge. Bot filtering layers Cloudflare → FingerprintJS Pro → internal heuristics. Page shell ISR-caches 60s; ad units stream in via Suspense — LCP target <2.0s on mobile 4G.
Default-on
FTC disclosures
- Required block in every template + footer.
- Vertical library: general, health (FDA-aware), financial (CFPB-aware), legal (mass-tort aware).
- AI flags risky claims ("guaranteed approval", "FDA-approved") and proposes safer rewrites.
- AI auto-generates the privacy policy from actual data flows + integrations.
Geo-detected at edge
GDPR + CCPA
- EU + California visitors see consent banner before any non-essential cookie.
- visitorId is essential (fraud + analytics) — set without consent.
- Marketing cookies (e.g., FingerprintJS for non-fraud) gated behind explicit opt-in.
- Consent cookie respected across the domain. Re-openable from footer.
Three layers
Bot filtering
- Cloudflare Bot Management header on inbound traffic.
- FingerprintJS Pro fingerprint on the page.
- Internal heuristics: datacenter IP, anomalous UA, click velocity per visitor.
- Filtered traffic still records + redirects (don't tip off bots). Excluded from bandit + default dashboards.
Hybrid SSR rendering
- Page shell. Server components, ISR-cached 60s, revalidated on publish via revalidatePath. Sub-100ms TTFB at the edge.
- Ad units stream. Each unit renders inside <Suspense>, with a 5–30s bandit decision cache balancing freshness against function cost.
- Builder code never ships. TipTap and the editor are admin-only. Published pages target <100KB gzipped JS.
- CWV targets. LCP < 2.0s mobile 4G · CLS < 0.05 · INP < 200ms. AI weekly-scans every published page and flags the three biggest offenders per tenant.
Asset pipeline
- R2 + Cloudflare Images. Direct presigned upload, AVIF / WebP / JPEG responsive delivery, EXIF stripped on ingest.
- Alt text required. AI auto-generates alt text on upload; manual override always available.
- Above-the-fold. Eager-loaded with explicit width/height — no CLS from late-arriving hero images.
- Fraud reports. Tenants flag suspect traffic; AI clusters patterns and lets super-admins one-click bulk-flag with a written reason.
Ready to ship your first listicle?
Register a domain in the morning. Run paid traffic to a polished, self-optimizing page by dinner. Free to start.