15 Dilde Yönetilebilir SEO Landing Sayfaları Kurmak
Bir havalimanı transfer sitesinde "İstanbul Havalimanı - Taksim Transfer" gibi onlarca konum bazlı sayfaya, hem de 15 farklı dilde ihtiyacımız vardı. Bunu her dil için elle yazılmış statik sayfalarla yönetmek sürdürülemezdi — yeni bir lokasyon eklemek demek, geliştiriciye ihtiyaç duymak demekti.
Hedef basitti: şirket Django admin'den yeni bir rota eklesin ve deploy olmadan her dilde yayında görsün. Oraya varmak, yanlış yapması kolay birkaç karar gerektirdi; çoğu da render'la değil, URL'ler ve hreflang'le ilgiliydi.
Tek model, her dile bir sayfa
Sayfa içeriği kodda değil veritabanında duruyor. Bir LandingPage iki Place kaydına (nereden ve nereye) bağlanıyor; dile göre değişen her şeyi django-parler'ın çeviri tablosunda tutuyor:
class LandingPage(TranslatableModel, SeoFlagsMixin, TimeStampedModel):
origin = models.ForeignKey(Place, on_delete=models.PROTECT, related_name="landing_origins")
destination = models.ForeignKey(Place, on_delete=models.PROTECT, related_name="landing_destinations")
is_active = models.BooleanField(default=True)
hero_image = models.ImageField(upload_to="landings/", blank=True, null=True)
# meta_title, meta_description, canonical_path... ortak yardımcıdan gelir
translations = seo_translated_fields(
slug=models.SlugField(max_length=255),
h1=models.CharField(max_length=200),
body=CKEditor5Field(blank=True),
meta={"unique_together": [("language_code", "slug")]},
)İşin kritik noktası slug'ın da çevrilmesi. Almanca sayfa İngilizce slug'ı kullanmıyor; Almanca yazılmış kendi slug'ı var. Böylece her dil, o dilde gerçekten aranan ifadeye uyan bir URL alıyor — bir İngilizce URL'in 15 kopyası yerine. Tekillik kuralı dil bazında: iki farklı dil aynı slug'ı kullanabilir, ama aynı dilde iki sayfa kullanamaz.
SEO alanları (meta başlık, meta açıklama, canonical yol, OG görseli) içerik modellerinin ortak kullandığı tek bir yardımcıdan geliyor; böylece landing, hizmet, tur ve blog yazıları admin'de aynı SEO alanlarını gösteriyor. Çevrilmeyen alanlar (nereden, nereye, hero görseli, "...den başlayan" vitrin fiyatı, yaklaşık süre ve mesafe) bir kez giriliyor ve tüm dillerde ortak kullanılıyor.
Yalnız slug değil, URL ön eki de çevriliyor
Slug'ın önündeki yol parçası da çevrili: aynı sayfa İngilizcede /transfer/, İspanyolcada /traslados/, Fransızcada /transfert/, Arapçada /tawsil/ altında yaşıyor. Next.js tarafında bu, next-intl'in routing.ts içindeki pathnames ayarı ve tek doğruluk kaynağı orası.
Sorun şu: Django'nun da aynı haritaya ihtiyacı var. Dil değiştiricinin ve hreflang'in kullandığı alternatif URL'leri backend üretiyor, next-intl ayarı ise Django'nun okuyamadığı bir TypeScript sabiti. Bu yüzden harita bir Python sözlüğünde elle aynalanıyor; bir management komutu (check_url_prefixes) routing.ts'i metin olarak okuyup ikisini karşılaştırıyor ve fark varsa deploy'u durduruyor. Yanlış bir ön ek gürültü çıkarmaz — sadece her dil değiştirmede 404 üretir.
Türkçe bilinçli olarak eski yollarda bırakıldı. Eski WordPress sitesinden gelen yönlendirme kuralları bu URL'leri hedefliyordu; onları değiştirmek her yönlendirmeyi zincire çevirirdi.
hreflang yalnız gerçekten var olan diller için
Her sayfa tüm dillerdeki karşılıklarını bildiriyor — ama yalnızca gerçekten çevrildiği diller için. Kulağa bariz geliyor; ilk sürümün yanlış olduğu yer de tam burası.
django-parler'ın bir yedek (fallback) dili var. Rusça çevirisi olmayan bir sayfaya Rusça slug'ını sorarsanız Türkçe slug'ı döner; sayfa da var olmayan bir Rusça alternatif URL'i gönül rahatlığıyla ilan eder. Test verisinde her dil doldurulmuş olduğu için kimse fark etmemişti. Çözüm, açık bir has_translation kontrolü:
def get_alternate_paths(self, obj):
paths = {}
for lang in settings.SITE_LANGUAGES:
if obj.has_translation(lang):
slug = obj.safe_translation_getter("slug", language_code=lang)
if slug:
paths[lang] = localized_path("transfer", lang, slug)
return pathsAynı alternate_paths nesnesi hem hreflang etiketlerini hem dil değiştiriciyi besliyor, yani ikisi birbiriyle çelişemiyor. (Karşılıkları kök layout'ta tek seferde tanımlamanın da kendi tuzağı var — bunun neden yanlış olduğu ve sayfa başına nasıl üretileceği.)
Build zamanında değil, ilk istekte üretim
Landing sayfaları için generateStaticParams yok. Route, saatlik pencereli ISR kullanıyor:
// app/[locale]/transfer/[slug]/page.tsx
export const revalidate = 3600;
export default async function TransferLandingPage({ params }) {
const { locale, slug } = await params;
const landing = await fetchLanding(decodeURIComponent(slug), locale);
if (!landing) notFound();
// ...
}dynamicParams varsayılan olarak true olduğu için build'de üretilmemiş bir slug ilk istekte render edilip önbelleğe alınıyor. Admin kaç rota eklerse eklesin build süresi değişmiyor; veritabanında olmayan bir slug ise doğrudan 404 dönüyor.
decodeURIComponent çağrısının bir sebebi var. Next.js latin olmayan slug'ları (Arapça, Rusça) params'a URL-encoded veriyor, veri çeken yardımcı da tekrar encode ediyor — önce decode etmezseniz backend'e çift encode edilmiş slug gidiyor ve her Arapça sayfa 404 oluyor.
Revalidate webhook'u ile anında güncelleme
Bir saat güvenlik ağı olarak yeterli, ama admin'deki bir düzenleme hemen görünmeli. Bir landing kaydedildiğinde Django, Next.js'ten etkilenen yolları yeniden doğrulamasını istiyor.
Burada iki ayrıntı önemli. Birincisi, tetik admin'in save_related kancasında; bu kanca sayfa ve çevirileri kaydedildikten sonra çalışıyor, yani yollar eski değil güncel slug'larla kuruluyor. İkincisi, yol listesi sanıldığından uzun: sayfanın çevrildiği her dildeki landing sayfası, artı her dilin ana sayfası — çünkü ana sayfa bu kayıtlardan oluşan bir "popüler transferler" listesi gösteriyor.
HTTP çağrısı bir Celery görevinde çalışıyor; böylece yavaş ya da ulaşılamayan bir frontend admin'i hiç bekletmiyor:
@shared_task(ignore_result=True)
def revalidate_landing(paths):
try:
resp = requests.post(
f"{settings.FRONTEND_INTERNAL_URL}/api/revalidate",
json={"paths": list(paths)},
headers={"x-revalidate-secret": settings.REVALIDATE_SECRET},
timeout=5,
)
if resp.status_code != 200:
logger.warning("revalidate failed: %s", resp.status_code)
except requests.RequestException as exc:
logger.warning("revalidate request failed: %s", exc)Next.js tarafı, paylaşılan sırrı kontrol edip listedeki her yol için revalidatePath çağıran sade bir route handler. Django ona iç Docker ağı üzerinden servis adıyla ulaşıyor; yani bu uç, tarayıcının çağırdığı bir şey değil.
Arapça ve Farsça için RTL
15 dilden ikisi sağdan sola yazılıyor: Arapça ve Farsça. Metni çevirmek yetmiyor; sayfanın yönü de dönmeli. RTL diller tek bir listede tutuluyor ve kök layout onu okuyor:
// i18n/routing.ts
export const RTL_LOCALES: Locale[] = ["ar", "fa"];
// app/[locale]/layout.tsx
const dir = isRtl(locale) ? "rtl" : "ltr";
// <html lang={locale} dir={dir}>locale === "ar" yazmak kestirme olurdu ve Farsça sayfaları soldan sağa dizilmiş bırakırdı. Yöne bağlı ayrıntılar ise Tailwind'in rtl: varyantıyla çözülüyor — örneğin "ileri" ok ikonları doğru yönü göstersin diye rtl:rotate-180 alıyor.
Sonuç
Yeni bir şehir ya da rota eklemek artık bir admin işi: nereden ve nereyi seç, her dil için slug, başlık, metin ve meta alanlarını doldur, kaydet. Sayfa çevrildiği her dilde kendi URL'i, doğru hreflang karşılıkları ve doğru yazı yönüyle yayına çıkıyor; ana sayfadaki listeler de onunla birlikte güncelleniyor. Döngüde ne geliştirici var ne deploy.