Personalized CTA (Call-to-Action) Implementation on Website
Same "Contact Us" button works equally poorly for cold visitor on first site visit and for user who viewed pricing three times. Personalized CTA changes text, offer, and action based on what the system knows about visitor.
Data Sources for Personalization
Before showing anything, understand who arrived:
function buildUserContext() {
return {
// Traffic source
utm_source: new URLSearchParams(location.search).get('utm_source'),
utm_campaign: new URLSearchParams(location.search).get('utm_campaign'),
// Site history
visits: parseInt(localStorage.getItem('visit_count') ?? '0'),
pages_viewed: JSON.parse(sessionStorage.getItem('pages_viewed') ?? '[]'),
last_visit: localStorage.getItem('last_visit'),
// Authorization
is_logged_in: !!document.cookie.match(/auth_token/),
user_segment: localStorage.getItem('user_segment'), // 'free', 'trial', 'paid'
// Geo (from IP via server endpoint)
country: window.__GEO__?.country,
city: window.__GEO__?.city,
};
}
// Increment visit counter
const visits = parseInt(localStorage.getItem('visit_count') ?? '0') + 1;
localStorage.setItem('visit_count', String(visits));
localStorage.setItem('last_visit', new Date().toISOString());
Geo data passed from server: when rendering page, HTML includes window.__GEO__ block with data from MaxMind GeoIP or similar.
CTA Matrix by Context
interface CtaVariant {
text: string;
subtext?: string;
url: string;
style: 'primary' | 'secondary' | 'outline';
}
function getCtaVariant(ctx: UserContext): CtaVariant {
// Logged in users
if (ctx.is_logged_in) {
if (ctx.user_segment === 'trial') {
return { text: 'Upgrade to Pro', subtext: '5 days left', url: '/upgrade', style: 'primary' };
}
return { text: 'Go to Dashboard', url: '/dashboard', style: 'outline' };
}
// Repeat visits
if (ctx.visits >= 3) {
return { text: 'Start Free', subtext: 'No credit card', url: '/signup', style: 'primary' };
}
// Retargeting traffic
if (ctx.utm_campaign?.includes('retargeting')) {
return { text: 'Back to Order', url: '/cart', style: 'primary' };
}
// Cold search traffic
if (ctx.utm_source === 'google' || ctx.utm_source === 'yandex') {
return { text: 'Get Demo', subtext: 'We answer in 15 min', url: '/demo', style: 'primary' };
}
// Default
return { text: 'Learn More', url: '/how-it-works', style: 'secondary' };
}
React Personalized CTA Component
interface PersonalizedCtaProps {
location: 'hero' | 'sidebar' | 'footer' | 'sticky';
}
function PersonalizedCta({ location }: PersonalizedCtaProps) {
const [variant, setVariant] = useState<CtaVariant | null>(null);
useEffect(() => {
const ctx = buildUserContext();
const v = getCtaVariant(ctx);
setVariant(v);
// Track impression
gtag('event', 'cta_impression', {
cta_text: v.text,
cta_url: v.url,
cta_location: location,
visit_count: ctx.visits,
utm_source: ctx.utm_source,
});
}, []);
if (!variant) return <CtaSkeleton />;
const handleClick = () => {
gtag('event', 'cta_click', {
cta_text: variant.text,
cta_url: variant.url,
cta_location: location,
});
};
return (
<div className={`cta cta--${location} cta--${variant.style}`}>
<a href={variant.url} onClick={handleClick} className="cta__button">
{variant.text}
</a>
{variant.subtext && (
<p className="cta__subtext">{variant.subtext}</p>
)}
</div>
);
}
<CtaSkeleton /> prevents layout shift while JS determines variant. Height must match real CTA.
Server-Side Personalization for SSR
With server rendering, logic better runs on server—user gets right variant immediately without flicker:
// Middleware for personalization data
class PersonalizationMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$context = [
'visits' => (int) $request->cookie('visit_count', 0),
'utm_source' => $request->query('utm_source') ?? $request->session()->get('utm_source'),
'utm_campaign' => $request->query('utm_campaign') ?? $request->session()->get('utm_campaign'),
'segment' => $request->user()?->segment ?? 'anonymous',
'country' => geoip($request->ip())->country,
];
// Pass to Inertia (or Blade)
Inertia::share('personalization', $context);
return $next($request);
}
}
Frontend already has data on first render—no need to wait for JS:
// In React via Inertia
import { usePage } from '@inertiajs/react';
function HeroCta() {
const { personalization } = usePage().props;
const variant = getCtaVariant(personalization);
// Renders immediately, no useEffect and skeleton
return <a href={variant.url}>{variant.text}</a>;
}
A/B Testing for Personalized CTAs
Personalization doesn't cancel testing—check which variant works better within each segment:
function getAbVariant(userId: string, testName: string): 'A' | 'B' {
// Deterministic distribution by userId — one user always sees one variant
const hash = Array.from(userId + testName)
.reduce((acc, char) => acc + char.charCodeAt(0), 0);
return hash % 2 === 0 ? 'A' : 'B';
}
Timeline
Basic personalization by UTM and visits: 4-6 hours. Full matrix with logged-in users, segments, and geo: 1-2 days. Server personalization via Inertia middleware: 4-6 more hours. GA4 variant tracking: 2-3 hours.







