Konut projeleri için landing page ve CRM altyapısı kurarken en sık karşılaştığım teknik borçlardan biri çerez izni katmanıdır. Çoğu sitede bir banner vardır, "Kabul et" ve "Reddet" düğmeleri vardır; ama arka planda pikseller banner'dan önce yüklenmiş, reddetme seçeneği yalnızca banner'ı kapatıyor, izin durumu hiçbir yerde kayıt altına alınmıyor ve sunucu tarafı olaylar (Meta CAPI, Google offline dönüşüm) izinden tamamen habersiz çalışıyor olur.
Ben Koray Yalçın; lead yönetimi, CRM ve dijital pazarlama altyapıları üzerine çalışıyorum. Bu yazıda çerez iznini bir "banner tasarımı" meselesi olarak değil, bir veri akışı ve durum yönetimi problemi olarak ele alacağım: izin durumu nasıl modellenir, scriptler nasıl kapı arkasında tutulur, Google Consent Mode v2 nereye oturur, izin bilgisi sunucu tarafına ve CRM'e nasıl taşınır ve tüm bunlar nasıl test edilir.
Not: Bu yazı teknik bir uygulama rehberidir, hukuki görüş değildir. KVKK, GDPR ve ePrivacy kapsamındaki yükümlülükleriniz için hukuk danışmanınızla çalışın.
Problemi doğru tanımlamak
Çerez izni üç ayrı soruya cevap vermelidir:
- Ne zaman? Hangi script, hangi izin verilmeden önce çalışabilir?
- Ne kadar? Kullanıcı hangi kategorilere, hangi metin sürümüne, ne zaman izin verdi?
- Nereye kadar? Bu izin bilgisi tarayıcıda mı kalıyor, yoksa sunucu tarafı olaylara ve CRM kaydına da ulaşıyor mu?
Çoğu hazır CMP (Consent Management Platform) ilk soruyu kısmen çözer. İkinci ve üçüncü soru ise neredeyse her zaman sizin kodunuza kalır. Özellikle form doldurup lead'e dönüşen bir ziyaretçi için, çerez izni ile pazarlama iletişim izninin (ETK/İYS kapsamındaki ticari ileti izni) farklı şeyler olduğunu baştan netleştirmek gerekir. Çerez izni tarayıcıdaki ölçüm ve reklam teknolojileriyle ilgilidir; ticari ileti izni ise kişiye mesaj gönderip gönderemeyeceğinizle. İkisini tek bir onay kutusunda birleştirmek hem hukuken hem teknik olarak sorun çıkarır.
İzin durumu modeli: Kategoriler ve sürüm
İlk adım, izin durumunu tek bir boolean olarak değil, kategorilere ayrılmış ve sürümlenmiş bir nesne olarak tutmaktır.
// consent-state.js export const CONSENT_VERSION = "2026-11-01"; // çerez metninin sürümü export const CATEGORIES = { necessary: { required: true }, // oturum, güvenlik, yük dengeleme analytics: { required: false }, // GA4, PostHog vb. marketing: { required: false }, // Meta Pixel, Google Ads, retargeting functional: { required: false }, // canlı sohbet, harita, video gömme }; export function defaultState() { return { version: CONSENT_VERSION, decidedAt: null, // kullanıcı karar vermediyse null choices: { necessary: true, analytics: false, marketing: false, functional: false, }, consentId: crypto.randomUUID(), // kanıt kaydını bağlamak için }; } Burada üç kritik nokta var:
- Varsayılan reddettir. Karar verilmemiş durum ile reddedilmiş durum farklı alanlarla ayırt edilir (
decidedAt: null), ama ikisinde de opsiyonel kategoriler kapalıdır. - Sürüm alanı zorunludur. Çerez politikası değiştiğinde, örneğin yeni bir reklam ortağı eklediğinizde, eski sürümle verilmiş izin yeni durumu kapsamaz. Kayıtlı sürüm güncel sürümden farklıysa banner yeniden gösterilir.
-
consentIdtarayıcıdaki kararı sunucudaki kanıt kaydına bağlayan anahtardır. Kişisel veri değildir; rastgele bir kimliktir.
Durumu saklarken birinci taraf bir çerez tercih ediyorum (ör. cc_state), çünkü sunucu da ilk istekte bu değeri okuyabilmeli. localStorage yalnızca tarayıcıdan erişilebilir; sunucu tarafı render ve edge mantığı için yetersiz kalır.
export function readState() { const raw = document.cookie .split("; ") .find((c) => c.startsWith("cc_state=")); if (!raw) return defaultState(); try { const s = JSON.parse(decodeURIComponent(raw.split("=")[1])); if (s.version !== CONSENT_VERSION) { // politika değişti: kimliği koru, kararı sıfırla return { ...defaultState(), consentId: s.consentId }; } return s; } catch { return defaultState(); } } export function writeState(state) { const value = encodeURIComponent(JSON.stringify(state)); const maxAge = 60 * 60 * 24 * 180; // saklama süresini politikanıza göre belirleyin document.cookie = `cc_state=${value}; Max-Age=${maxAge}; Path=/; SameSite=Lax; Secure`; } Saklama süresi burada bir örnektir; kaç gün tutacağınız sizin politika kararınızdır ve politika metninde yazanla birebir aynı olmalıdır.
Script kapısı: İzin gelmeden hiçbir şey çalışmasın
Sahada gördüğüm en yaygın hata, banner'ın doğru çalışması ama piksellerin <head> içinde doğrudan yüklenmesidir. Ağ sekmesini açtığınızda, kullanıcı daha hiçbir şeye tıklamadan facebook.com/tr ve googleads isteklerinin gittiğini görürsünüz.
Çözüm, opsiyonel scriptleri tarayıcının çalıştırmayacağı bir türle işaretlemek ve izin geldiğinde etkinleştirmektir:
<!-- Çalışmaz: type text/plain olduğu için tarayıcı yürütmez --> <script type="text/plain" data-consent="marketing" data-src="https://connect.facebook.net/tr_TR/fbevents.js"></script> <script type="text/plain" data-consent="analytics"> // analitik başlatma kodu </script> // consent-gate.js export function activateScripts(choices) { document .querySelectorAll('script[type="text/plain"][data-consent]') .forEach((el) => { const cat = el.dataset.consent; if (!choices[cat] || el.dataset.activated) return; const s = document.createElement("script"); if (el.dataset.src) s.src = el.dataset.src; else s.textContent = el.textContent; s.async = true; el.dataset.activated = "1"; el.after(s); }); } Bu yaklaşımın önemli bir sınırı var: izin geri çekildiğinde zaten yüklenmiş bir scripti "geri yükleyemezsiniz". Bu yüzden geri çekmede iki şey yapıyorum: ilgili birinci taraf çerezleri siliyorum ve sayfayı yeniliyorum. Yenilenen sayfada kapı yeni duruma göre yeniden değerlendirilir. Zarif değil ama doğru davranış budur.
Gömülü içerikler (YouTube videosu, harita, canlı sohbet widget'ı) için de aynı mantık geçerli: izin yoksa iframe yerine bir yer tutucu ve "Bu içeriği görmek için işlevsel çerezlere izin verin" düğmesi gösterilir.
Google Consent Mode v2 nereye oturur?
Google etiketleri kullanıyorsanız, kendi kapınızın yanında Consent Mode sinyallerini de doğru vermelisiniz. Consent Mode v2'de reklam tarafı için ad_storage, ad_user_data ve ad_personalization, analitik için analytics_storage parametreleri kullanılır.
Sıralama kritiktir: varsayılan durum, herhangi bir Google etiketi yüklenmeden önce tanımlanmalıdır.
<script> window.dataLayer = window.dataLayer || []; function gtag(){ dataLayer.push(arguments); } gtag("consent", "default", { ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied", analytics_storage: "denied", functionality_storage: "denied", personalization_storage: "denied", security_storage: "granted", wait_for_update: 500 }); </script> Kullanıcı karar verdiğinde kendi durum nesnenizi Consent Mode diline çeviren tek bir fonksiyon yazın:
export function toGoogleConsent(choices) { const g = (v) => (v ? "granted" : "denied"); return { ad_storage: g(choices.marketing), ad_user_data: g(choices.marketing), ad_personalization: g(choices.marketing), analytics_storage: g(choices.analytics), functionality_storage: g(choices.functional), personalization_storage: g(choices.functional), }; } export function applyConsent(state) { window.gtag?.("consent", "update", toGoogleConsent(state.choices)); activateScripts(state.choices); } Burada bir mimari karar vermeniz gerekiyor: Google etiketlerini "Consent Mode'un temel uygulaması" ile mi (izin yoksa etiket hiç yüklenmez), yoksa "gelişmiş uygulama" ile mi (etiket yüklenir, izin yoksa çerezsiz sinyal gönderir) kullanacaksınız? Bu teknik bir tercih gibi görünse de aslında bir uyum kararıdır. Ben bu kararı kod değil, ayar olarak tutuyor ve hukuk ekibiyle birlikte veriyorum.
Kararı kanıtlamak: Sunucu tarafı izin kaydı
Tarayıcıdaki çerez, kullanıcının neye izin verdiğinin kanıtı değildir; kullanıcı onu silebilir, değiştirebilir. Bu yüzden her kararı sunucuya bir olay olarak yazıyorum. Kişisel veri tutmadan, yalnızca kararı ve bağlamını saklayan, ekleme yapılan (append-only) bir tablo:
CREATE TABLE cookie_consent_event ( id BIGSERIAL PRIMARY KEY, consent_id UUID NOT NULL, version TEXT NOT NULL, -- çerez metni sürümü choices JSONB NOT NULL, -- {"analytics":true,...} action TEXT NOT NULL -- accept_all | reject_all | custom | withdraw CHECK (action IN ('accept_all','reject_all','custom','withdraw')), ui_variant TEXT, -- hangi banner tasarımı gösterildi page_path TEXT, ua_hash TEXT, -- tam user-agent yerine hash created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ON cookie_consent_event (consent_id, created_at DESC); IP adresini ham haliyle saklamıyorum. İhtiyaç varsa kısaltılmış ya da tuzlu hash'lenmiş bir türev yeterlidir. ui_variant alanı ise ileride "Reddet düğmesi eşit görünürlükte miydi?" sorusu geldiğinde hangi tasarımın gösterildiğini kanıtlamak için var.
Tarayıcı tarafında kayıt çağrısı basit tutulur ve başarısız olsa bile kullanıcı deneyimini bozmaz:
export async function recordDecision(state, action) { try { await fetch("/api/consent", { method: "POST", headers: { "Content-Type": "application/json" }, keepalive: true, body: JSON.stringify({ consentId: state.consentId, version: state.version, choices: state.choices, action, uiVariant: document.documentElement.dataset.ccVariant ?? null, pagePath: location.pathname, }), }); } catch { // ağ hatası kararı geçersiz kılmaz; bir sonraki sayfa yüklemesinde yeniden dene } } İzni sunucu tarafı olaylara taşımak
Asıl kör nokta burasıdır. Bir ziyaretçi pazarlama çerezlerini reddedip formu doldurduğunda, CRM'iniz o lead'i "nitelikli" aşamasına aldığında Meta Conversions API'ye veya Google Ads'e offline dönüşüm göndermeye devam ediyorsa, tarayıcıda kurduğunuz bütün kapı anlamını yitirir.
Bunu çözmek için form gönderiminde çerez izni anlık görüntüsünü lead kaydına ekliyorum:
// form gönderiminde const state = readState(); payload.cookieConsent = { consentId: state.consentId, version: state.version, marketing: state.choices.marketing, analytics: state.choices.analytics, capturedAt: new Date().toISOString(), }; CRM tarafında dışa giden her reklam olayı, tek bir kontrol fonksiyonundan geçer:
def can_send_ad_event(lead) -> bool: cc = lead.cookie_consent or {} if not cc.get("marketing"): return False latest = consent_repo.latest(cc.get("consent_id")) # sonradan geri çekildiyse eski anlık görüntüye güvenme if latest and latest.action == "withdraw": return False return True Bu fonksiyonu outbox işleyicisinin içinde, gönderimden hemen önce çağırmak önemli. Lead oluştuğu anda izin vardı ama üç gün sonra satış aşaması değiştiğinde kullanıcı izni geri çekmiş olabilir. Kontrolü olayın üretildiği anda değil, gönderildiği anda yapmak bu açığı kapatır.
Bir de şu ayrımı not edeyim: çerez izni, tarayıcı tarafı takip teknolojileri içindir; sunucu tarafı reklam olaylarında kişisel verinin işlenmesi ayrıca bir hukuki dayanak gerektirebilir. Teknik olarak yapabileceğiniz en temiz şey, iki izni de ayrı alanlarda tutmak ve gönderim kontrolünde ikisini birlikte değerlendirmektir.
Banner'ın kendisi: Teknik açıdan dikkat edilecekler
Tasarım bu yazının konusu değil, ama koda yansıyan birkaç kural var:
- Eşit seçenekler. "Tümünü kabul et" ile "Tümünü reddet" aynı katmanda, benzer görünürlükte olmalı. Reddetmeyi ikinci bir ekrana saklamak hem güven hem uyum sorunudur.
- Erişilebilirlik. Banner klavyeyle gezilebilir olmalı, odak ilk açılışta banner'a taşınmalı,
role="dialog"vearia-labelledbykullanılmalı. - Kararı değiştirebilme. Sayfa altında her zaman erişilebilen bir "Çerez tercihleri" bağlantısı olmalı ve aynı modalı açmalı.
- Performans. Banner scripti küçük ve senkron olmalı; ama sayfanın ilk boyamasını engellemeyecek şekilde. Ağır bir CMP yüklüyorsanız varsayılan Consent Mode bloğunu yine de satır içi ve en üstte tutun.
Test etmek: "Bana güven" yerine kanıt
İzin katmanı, otomatik testi en çok ihmal edilen bileşenlerden biridir. Ben üç senaryoyu sürekli entegrasyon hattında çalıştırıyorum:
// playwright test örneği test("karar öncesi pazarlama isteği gitmez", async ({ page }) => { const blocked = []; page.on("request", (r) => { if (/facebook\.com\/tr|googleadservices|doubleclick/.test(r.url())) { blocked.push(r.url()); } }); await page.goto("/proje/ornek-landing"); await page.waitForTimeout(2000); expect(blocked).toEqual([]); }); test("reddet sonrası da gitmez", async ({ page }) => { // banner'da reddet, sonra formu doldur, aynı kontrol }); test("politika sürümü değişince banner yeniden açılır", async ({ page, context }) => { // eski sürümle cc_state çerezi yaz, sayfayı aç, banner görünür mü? }); Bunlara ek olarak haftalık bir mutabakat sorgusu çalıştırıyorum: son yedi günde reklam platformlarına gönderilen sunucu tarafı olayların kaç tanesi marketing=false olan lead'lere aitti? Doğru kurulmuş bir sistemde bu sayı sıfır olmalıdır; sıfır değilse bir yerde kapı sızdırıyor demektir.
Sık yapılan hatalar
- Pikselleri
<head>'e doğrudan koyup banner'ı yalnızca görsel bir katman olarak eklemek. - Consent Mode varsayılanını Google etiketinden sonra tanımlamak.
- Politika değişince eski izinleri geçerli saymak; sürüm alanı tutmamak.
- Çerez iznini ticari ileti izniyle tek onay kutusunda birleştirmek.
- Sunucu tarafı reklam olaylarını izin durumundan bağımsız göndermek.
- Kararı yalnızca tarayıcıda tutup sunucuda kanıt kaydı oluşturmamak.
- Geri çekmede sayfayı yenilemeden, yüklenmiş scriptlerin "kendiliğinden duracağını" varsaymak.
Sık Sorulan Sorular (SSS)
Hazır bir CMP kullanıyorsam bu yazıdakileri yine de yapmam gerekir mi?
CMP genellikle banner'ı, kategori seçimini ve Consent Mode sinyallerini çözer. Ancak izin anlık görüntüsünü lead kaydına eklemek, sunucu tarafı olayları izinle kapılamak ve mutabakat sorgusu çalıştırmak çoğu zaman sizin backend'inize kalır. CMP'nin olay geri çağırmalarını (callback) kendi applyConsent ve recordDecision fonksiyonlarınıza bağlamanız yeterli.
Kullanıcı reddederse analitik verim tamamen kaybolur mu?
Kurduğunuz uygulamaya bağlıdır. Consent Mode'un gelişmiş uygulamasında Google etiketleri çerezsiz sinyaller gönderebilir ve modelleme yapılabilir; temel uygulamada izin yoksa etiket hiç yüklenmez. Hangisinin size uygun olduğu bir uyum kararıdır, teknik ekip tek başına vermemelidir.
İzin durumunu localStorage'da mı, çerezde mi tutmalıyım?
Sunucunun da okuyabilmesi için birinci taraf bir çerez daha pratiktir. Sunucu tarafı render, edge kuralları ve form gönderiminde izin anlık görüntüsü almak için bu önemli bir avantaj.
Çerez izni ile KVKK açık rızası aynı şey mi?
Hayır, teknik olarak ayrı tutulmalıdır. Çerez izni tarayıcıdaki takip teknolojileriyle, ticari ileti izni kişiye mesaj gönderimiyle, aydınlatma metni ise bilgilendirmeyle ilgilidir. Her biri ayrı alan, ayrı kayıt ve ayrı geri çekme akışı ister.
Politika metnini her güncellediğimde herkese banner'ı yeniden mi göstermeliyim?
Kapsamı değiştiren güncellemelerde (yeni kategori, yeni reklam ortağı, yeni amaç) evet; bunun için sürüm alanını artırırsınız. Yazım hatası düzeltmesi gibi kapsamı etkilemeyen değişikliklerde sürüm artırmak gerekmeyebilir; bu ayrımı politika sahibiyle birlikte yapın.
Sunucu tarafı takip (server-side tagging) kurarsam çerez iznine gerek kalmaz mı?
Kalır. Sunucu tarafı takip verinin nereden gönderildiğini değiştirir, izin yükümlülüğünü ortadan kaldırmaz. Aksine, tarayıcıdaki kapıyı atlayan bir kanal olduğu için izin kontrolünü sunucu tarafında da açıkça uygulamanız gerekir.
Kapanış
Çerez izni doğru kurulduğunda bir "uyum maliyeti" değil, ölçüm altyapınızın güvenilirlik katmanı olur. Hangi verinin hangi izinle toplandığını bilen bir sistemde reklam platformlarına giden sinyaller daha temiz, CRM'deki lead kaydı daha açıklanabilir ve denetim sorularına verilecek cevap hazırdır. Benim önerim basit: izin durumunu sürümlü bir nesne olarak modelleyin, scriptleri kapı arkasında tutun, kararı sunucuda kayıt altına alın ve reklam olaylarını gönderildikleri anda izinle kontrol edin.
— Koray Yalçın