Multilanding Development with Dynamic Content
A multilanding is a landing page that adapts content in real-time based on traffic source. One URL, different headlines, offers, images, phone numbers — depending on UTM parameters, user city, ad platform, keyword, or other signals. Used to increase relevance of ad campaigns without creating dozens of separate pages.
Why it matters
A user who clicked "iPhone 14 repair in New York" expects to see exactly that on the landing. If they land on a generic "Smartphone repair" page — conversion drops, Quality Score declines, click cost rises.
Multilanding solves scaling: instead of 50 pages for 50 keywords — one page with templating.
Architecture
Dynamic content can be substituted at three levels:
Client-side (JS after load) — simplest, but worst for SEO and CLS. User sees content "blinking" during substitution.
Edge (Middleware before render) — page renders on edge node with content already substituted. No CLS, SEO correctly indexes default version. Implemented via Vercel Edge Middleware, Cloudflare Workers, Netlify Edge Functions.
Server-side (SSR) — request processed on server, content substituted before HTML sent. Similar to Edge but runs on dedicated server, not distributed network.
Optimal choice — Edge Middleware for Vercel/Cloudflare projects or SSR for self-hosted.
Implementation with Next.js + Vercel Edge Middleware
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { searchParams } = request.nextUrl;
const utmTerm = searchParams.get('utm_term') ?? '';
const utmSource = searchParams.get('utm_source') ?? '';
const city = request.geo?.city ?? '';
// Pass data in headers — read in component
const response = NextResponse.next();
response.headers.set('x-utm-term', utmTerm);
response.headers.set('x-utm-source', utmSource);
response.headers.set('x-visitor-city', city);
return response;
}
export const config = {
matcher: ['/landing/:path*'],
};
// app/landing/page.tsx
import { headers } from 'next/headers';
import { getContent } from '@/lib/content-engine';
export default function LandingPage() {
const headersList = headers();
const utmTerm = headersList.get('x-utm-term') ?? '';
const city = headersList.get('x-visitor-city') ?? 'your city';
const content = getContent(utmTerm);
return (
<main>
<h1>{content.headline.replace('{city}', city)}</h1>
<p>{content.subheadline}</p>
<ContactForm phone={content.phone} />
</main>
);
}
Content engine: substitution matrix
Content substitution logic — separate module. Not hardcoded if/else, but configurable rules:
// lib/content-engine.ts
interface ContentVariant {
headline: string;
subheadline: string;
cta: string;
phone: string;
image: string;
}
const defaultContent: ContentVariant = {
headline: 'Smartphone repair in {city}',
subheadline: 'Free diagnosis. 6-month warranty.',
cta: 'Book repair',
phone: '+1-555-000-0000',
image: '/hero-default.webp',
};
const variants: Record<string, Partial<ContentVariant>> = {
'repair iphone': {
headline: 'iPhone repair in {city} — 1 hour',
subheadline: 'Original parts. Repair while you wait.',
image: '/hero-iphone.webp',
},
'repair samsung': {
headline: 'Samsung repair in {city}',
subheadline: 'Any Galaxy model. 12-month warranty.',
image: '/hero-samsung.webp',
},
'screen replacement': {
headline: 'Screen replacement in 30 minutes',
cta: 'Get replacement cost',
},
};
export function getContent(utmTerm: string): ContentVariant {
const key = utmTerm.toLowerCase();
const variant = Object.entries(variants).find(([k]) => key.includes(k));
return { ...defaultContent, ...(variant?.[1] ?? {}) };
}
Geolocation and phone number substitution
For multi-location businesses — local phone number substitution is critical. Calling "local office" converts better:
const phoneByCity: Record<string, string> = {
'New York': '+1 (212) 000-0000',
'Los Angeles': '+1 (310) 000-0000',
'Chicago': '+1 (312) 000-0000',
};
// In middleware — from request.geo (Vercel only with Geolocation)
const city = request.geo?.city ?? '';
const phone = phoneByCity[city] ?? defaultPhone;
Alternative for self-hosting — MaxMind GeoIP2 database or services like ipapi.co/ipgeolocation.io via API.
CallTracking
For each traffic source (Google Ads, Yandex.Direct, organic, social) — different phone via CallTracking service (CoMagic, Callibri, Ringostat). Number shown via JavaScript after page load:
// Dynamic CallTracking number replacement
window.ct_replace = {
default: '+1-555-000-0000',
pools: {
'google': '+1-555-111-1111',
'yandex': '+1-555-222-2222',
}
};
Usually CallTracking service provides ready script with similar logic.
CMS for managing variants
Content variant matrix quickly grows. For marketer who doesn't edit code, need management interface:
| UTM Term | Headline | Subheadline | Image | Phone |
|---|---|---|---|---|
| repair iphone | ... | ... | hero-iphone.webp | +1-555... |
| screen replacement | ... | ... | hero-screen.webp | +1-555... |
Implemented as simple CRUD table in admin panel (Filament, Directus, even Google Sheets via API). Early on often enough JSON config in repo with deploy on change.
A/B testing variants
Multilanding + A/B test — combination for maximum optimization. On Edge Middleware can implement random split:
// Random 50/50 split
const variant = Math.random() < 0.5 ? 'A' : 'B';
response.cookies.set('ab_variant', variant, { maxAge: 60 * 60 * 24 * 7 });
response.headers.set('x-ab-variant', variant);
In page component read x-ab-variant and render appropriate version. Test results — in GA4 via custom dimensions.
SEO and multilanding
Dynamic content on one URL creates SEO risks. Googlebot sees default version. If multilanding — ad story without organic traffic, SEO doesn't matter. If page should rank — need separate URLs for each variant with canonical and hreflang.
Rule: multilanding for paid traffic, separate optimized pages for organic.
Typical timeline
Basic multilanding (UTM → headline/CTA) on static or Next.js — 5–7 business days. With geolocation, CallTracking, CMS for variants — 10–14 days. Full system with A/B testing, analytics, multilingual, 50+ variants — 3–4 weeks.







