{
+ const client = await getStatsigServerClient();
+ if (!client) return fallback;
+ return evaluateHeroCtaWithClient(client, toStatsigUser(user), fallback);
+}
diff --git a/src/lib/statsig/hero-query-overrides.ts b/src/lib/statsig/hero-query-overrides.ts
index ea04cdf198..8d17ad2f1c 100644
--- a/src/lib/statsig/hero-query-overrides.ts
+++ b/src/lib/statsig/hero-query-overrides.ts
@@ -1,13 +1,26 @@
+import { DEFAULT_HERO_CTA } from './constants';
import { normalizeHeroLayout, type HeroLayoutVariant } from './hero-layout';
+/** Legacy fixed label on several CTAs — replaced by `best_cta` / `heroCta` when present. */
+export const SHORT_START_BUILDING_LABEL = 'Start building';
+
+/**
+ * Use homepage experiment CTA copy for buttons that historically used {@link SHORT_START_BUILDING_LABEL}.
+ */
+export function heroCtaIfShortStartBuilding(staticLabel: string, heroCta: string): string {
+ return staticLabel === SHORT_START_BUILDING_LABEL ? heroCta : staticLabel;
+}
+
/** Query overrides for marketing hero experiments (Statsig parity for local QA). */
export const HERO_LAYOUT_QUERY_KEY = 'hero_layout';
export const HERO_SUBTITLE_QUERY_KEY = 'hero_subtitle';
export const HERO_TITLE_QUERY_KEY = 'hero_title';
+export const HERO_CTA_QUERY_KEY = 'hero_cta';
const MAX_SUBTITLE_LEN = 560;
const MAX_TITLE_LEN = 160;
+const MAX_CTA_LEN = 100;
/**
* Same rules as `hero_subtitle` URL overrides: collapse whitespace, trim, max length.
@@ -20,10 +33,19 @@ export function normalizeHeroSubtitle(raw: unknown, fallback: string): string {
return t.length > MAX_SUBTITLE_LEN ? t.slice(0, MAX_SUBTITLE_LEN) : t;
}
+/** Short button label — used for `best_cta` / `hero_cta` query override. */
+export function normalizeHeroCta(raw: unknown, fallback: string): string {
+ if (typeof raw !== 'string') return fallback;
+ const t = raw.replace(/\s+/g, ' ').trim();
+ if (t.length === 0) return fallback;
+ return t.length > MAX_CTA_LEN ? t.slice(0, MAX_CTA_LEN) : t;
+}
+
export type HeroQueryBaseline = {
heroLayout: HeroLayoutVariant;
heroSubtitle: string;
heroTitle: string;
+ heroCta: string;
};
export type HeroQueryResolved = HeroQueryBaseline;
@@ -32,12 +54,13 @@ export function hasHeroExperimentQueryOverrides(params: URLSearchParams): boolea
return (
params.has(HERO_LAYOUT_QUERY_KEY) ||
params.has(HERO_SUBTITLE_QUERY_KEY) ||
- params.has(HERO_TITLE_QUERY_KEY)
+ params.has(HERO_TITLE_QUERY_KEY) ||
+ params.has(HERO_CTA_QUERY_KEY)
);
}
/**
- * Applies `hero_layout`, `hero_subtitle`, and `hero_title` search params when present.
+ * Applies `hero_layout`, `hero_subtitle`, `hero_title`, and `hero_cta` search params when present.
* Values are clamped; unknown `hero_layout` values keep the baseline layout.
*/
export function resolveHeroQueryOverrides(
@@ -47,7 +70,8 @@ export function resolveHeroQueryOverrides(
return {
heroLayout: readHeroLayoutOverride(params, baseline.heroLayout),
heroSubtitle: readHeroSubtitleOverride(params, baseline.heroSubtitle),
- heroTitle: readHeroTitleOverride(params, baseline.heroTitle)
+ heroTitle: readHeroTitleOverride(params, baseline.heroTitle),
+ heroCta: readHeroCtaOverride(params, baseline.heroCta)
};
}
@@ -74,3 +98,10 @@ function readHeroTitleOverride(params: URLSearchParams, fallback: string): strin
if (t.length === 0) return fallback;
return t.length > MAX_TITLE_LEN ? t.slice(0, MAX_TITLE_LEN) : t;
}
+
+function readHeroCtaOverride(params: URLSearchParams, fallback: string): string {
+ if (!params.has(HERO_CTA_QUERY_KEY)) return fallback;
+ const raw = params.get(HERO_CTA_QUERY_KEY);
+ if (raw == null) return fallback;
+ return normalizeHeroCta(raw, fallback);
+}
diff --git a/src/lib/statsig/hero-statsig.server.ts b/src/lib/statsig/hero-statsig.server.ts
index c0fb79b594..8a5ee18092 100644
--- a/src/lib/statsig/hero-statsig.server.ts
+++ b/src/lib/statsig/hero-statsig.server.ts
@@ -9,6 +9,7 @@ export {
toStatsigUser
} from './server';
export {
+ evaluateHeroCtaExperiment,
evaluateHeroDescriptionExperiment,
evaluateHeroLayoutExperiment,
loadMarketingHomeStatsigBundle,
diff --git a/src/lib/utils/blog-mid-cta.ts b/src/lib/utils/blog-mid-cta.ts
index a41d0132ed..ad7dda93c4 100644
--- a/src/lib/utils/blog-mid-cta.ts
+++ b/src/lib/utils/blog-mid-cta.ts
@@ -1,3 +1,5 @@
+import { DEFAULT_HERO_CTA } from '$lib/statsig/constants';
+import { heroCtaIfShortStartBuilding } from '$lib/statsig/hero-query-overrides';
import { getAppwriteDashboardUrl } from '$lib/utils/dashboard';
import { DEFAULT_CTA_POINTS, resolveBlogCta, type BlogCtaConfig } from '$lib/utils/blog-cta';
@@ -39,6 +41,8 @@ export interface PrepareBlogCtaOptions {
category: string;
slug: string;
rawContent?: string | null;
+ /** Homepage `best_cta` value when set (e.g. marketing `/` load); syncs legacy "Start building" labels. */
+ heroExperimentCta?: string | null;
}
export interface BlogCtaState {
@@ -80,8 +84,10 @@ export const prepareBlogCtaState = ({
announcement,
category,
slug,
- rawContent
+ rawContent,
+ heroExperimentCta
}: PrepareBlogCtaOptions): BlogCtaState => {
+ const heroCta = heroExperimentCta ?? DEFAULT_HERO_CTA;
const manualOverride = typeof callToAction === 'object' && callToAction !== null;
const shouldAutoGenerate = !manualOverride && (callToAction ?? true) && !announcement;
const targetHeadingIndex = getMidCtaTargetIndex(rawContent ?? null);
@@ -113,7 +119,7 @@ export const prepareBlogCtaState = ({
point2: cta.points[1],
point3: cta.points[2],
point4: cta.points[3],
- cta: cta.label,
+ cta: heroCtaIfShortStartBuilding(cta.label, heroCta),
url: cta.href,
event: cta.event
} satisfies InlineCtaProps
@@ -124,7 +130,7 @@ export const prepareBlogCtaState = ({
manualOverride && cta
? {
heading: cta.heading,
- label: cta.label,
+ label: heroCtaIfShortStartBuilding(cta.label, heroCta),
href: cta.href,
description: cta.description,
event: cta.event
diff --git a/src/markdoc/layouts/Post.svelte b/src/markdoc/layouts/Post.svelte
index fe11ab0451..2dc50e6ae4 100644
--- a/src/markdoc/layouts/Post.svelte
+++ b/src/markdoc/layouts/Post.svelte
@@ -18,6 +18,7 @@
getInlinedScriptTag
} from '$lib/utils/metadata';
import { isAnnouncement, parseCategories } from '$lib/utils/blog-cta';
+ import { DEFAULT_HERO_CTA } from '$lib/statsig/constants';
import { prepareBlogCtaState, type BlogCallToActionInput } from '$lib/utils/blog-mid-cta';
import { getPostAuthors } from '$lib/utils/blog-authors';
import type { AuthorData, PostsData } from '$routes/blog/content';
@@ -85,7 +86,8 @@
announcement,
category,
slug,
- rawContent
+ rawContent,
+ heroExperimentCta: (page.data as { heroCta?: string | null }).heroCta ?? DEFAULT_HERO_CTA
});
if (midCta) {
diff --git a/src/routes/(marketing)/(components)/hero.svelte b/src/routes/(marketing)/(components)/hero.svelte
index 44aa974e12..17506dc6ef 100644
--- a/src/routes/(marketing)/(components)/hero.svelte
+++ b/src/routes/(marketing)/(components)/hero.svelte
@@ -4,7 +4,11 @@
import { PUBLIC_APPWRITE_DASHBOARD } from '$env/static/public';
import { onMount } from 'svelte';
import { trackEvent } from '$lib/actions/analytics';
- import { DEFAULT_HERO_SUBTITLE, DEFAULT_HERO_TITLE } from '$lib/statsig/constants';
+ import {
+ DEFAULT_HERO_CTA,
+ DEFAULT_HERO_SUBTITLE,
+ DEFAULT_HERO_TITLE
+ } from '$lib/statsig/constants';
import { initStatsig, whenStatsigReady } from '$lib/statsig/client';
import {
MARKETING_HERO_EXPERIMENTS,
@@ -39,7 +43,8 @@
resolveHeroQueryOverrides(building ? new URLSearchParams() : page.url.searchParams, {
heroLayout: (data.heroLayout ?? 0) as HeroLayoutVariant,
heroSubtitle: data.heroSubtitle ?? DEFAULT_HERO_SUBTITLE,
- heroTitle: data.heroTitle ?? DEFAULT_HERO_TITLE
+ heroTitle: data.heroTitle ?? DEFAULT_HERO_TITLE,
+ heroCta: data.heroCta ?? DEFAULT_HERO_CTA
})
);
@@ -63,15 +68,21 @@
const baselineSubtitle = data.heroSubtitle ?? DEFAULT_HERO_SUBTITLE;
const baselineLayout = (data.heroLayout ?? 0) as HeroLayoutVariant;
const baselineTitle = data.heroTitle ?? DEFAULT_HERO_TITLE;
+ const baselineCta = data.heroCta ?? DEFAULT_HERO_CTA;
if (hasHeroExperimentQueryOverrides(page.url.searchParams)) {
log('URL query overrides (client layout)', {
...resolveHeroQueryOverrides(page.url.searchParams, {
heroLayout: baselineLayout,
heroSubtitle: baselineSubtitle,
- heroTitle: baselineTitle
+ heroTitle: baselineTitle,
+ heroCta: baselineCta
}),
- dataBaseline: { heroLayout: baselineLayout, subtitleLen: baselineSubtitle.length }
+ dataBaseline: {
+ heroLayout: baselineLayout,
+ subtitleLen: baselineSubtitle.length,
+ ctaLen: baselineCta.length
+ }
});
return;
}
@@ -93,12 +104,14 @@
const { debug } = readMarketingHeroExperimentsForExposure(client, {
heroSubtitle: baselineSubtitle,
- heroLayout: baselineLayout
+ heroLayout: baselineLayout,
+ heroCta: baselineCta
});
log('experiment exposure (display stays on `page.data`)', {
[MARKETING_HERO_EXPERIMENTS.bestDescription]:
debug[MARKETING_HERO_EXPERIMENTS.bestDescription],
+ [MARKETING_HERO_EXPERIMENTS.bestCta]: debug[MARKETING_HERO_EXPERIMENTS.bestCta],
[MARKETING_HERO_EXPERIMENTS.heroLayout]:
debug[MARKETING_HERO_EXPERIMENTS.heroLayout]
});
@@ -215,7 +228,7 @@
class={layoutAside ? 'w-full! lg:w-fit!' : 'w-full! sm:w-fit!'}
onclick={() => {
trackEvent(`main-get_started_btn_hero-click`);
- }}>Start building for free{resolved.heroCta}
diff --git a/src/routes/(marketing)/(components)/pricing.svelte b/src/routes/(marketing)/(components)/pricing.svelte
index 18f248a70f..a729d2b9d4 100644
--- a/src/routes/(marketing)/(components)/pricing.svelte
+++ b/src/routes/(marketing)/(components)/pricing.svelte
@@ -1,9 +1,16 @@
@@ -22,7 +29,7 @@
class="w-full! lg:w-fit!"
onclick={() => {
trackEvent(`pricing-get-started-click`);
- }}>Start building{heroCtaIfShortStartBuilding('Start building', navCtaLabel)}