Mühendislik
İki dilli bir Next.js App Router sitesi: i18n, hreflang ve yapılandırılmış veriyi doğru kurmak
İki dilli (EN/TR) Next.js App Router sitelerini nasıl yayına aldığımızın pratik bir anlatımı — locale yönlendirmesi, hreflang alternatifleri, her locale için JSON-LD ve iki dili de birinci sınıf tutan küçük middleware incelikleri.
Bu yazıda
Çoğu iki dilli web sitesi, aslında üzerine çeviri katmanı eklenmiş tek dilli bir sitedir. İlk yayına çıkan, formları çalışan ve arama motorlarının ilk öğrendiği sürüm İngilizce olandır. Çevrilmiş sürüm ise bir çeviri belleğinden dökülmüş bir JSON dosyası ve kimsenin test etmediği bir başlık açılır menüsünden ibarettir.
Bizim kurmak istediğimiz bu değildi. neptay.com İngilizce ve Türkçe olarak başladı; bugün aynı içerik katmanından on dilde yayında — dile duyarlı yönlendirme, meta veriler, site haritaları, hreflang ve yapılandırılmış veriyle. Türkçe sürüm sonradan eklenmiş bir yama değil; aynı sitenin başka bir sesi. Bu yazı işin mühendislik tarafını adım adım anlatıyor: Next.js App Router’da nasıl kurduğumuzu, middleware’e neyi koyduğumuzu, Google’ın güvenmesi için hreflang’in nasıl görünmesi gerektiğini ve ileride karşılığını veren küçük yapısal kararları.
Küçük bir iki dilli site yayına alıyorsanız — bir stüdyo sayfası, bir restoran menüsü, bölgesel bir tanıtım sayfası — bu deseni neredeyse olduğu gibi kopyalayabilirsiniz. Elli dilli bir e-ticaret sitesi yayına alıyorsanız bu yapı temeliniz olur; yalnızca sözlüğü bir CMS akışıyla değiştirirsiniz.
URL alanının biçimi
İki dilli bir Next.js sitesinin URL’lerini düzenlemenin temelde üç yolu var:
- Her locale için bir alt alan adı (en.example.com, tr.example.com). Güçlü bir ayrım sağlar ama TLS ve analitik yönetimi pahalıdır; küçük bir sitede görsel olarak da parçalı durur.
- Her locale için bir ülke alan adı (example.com, example.com.tr). Coğrafi hedefleme için mükemmeldir ama yalnızca gerçekten Türkiye’ye özel bir iş yürütüyorsanız anlamlıdır — aksi hâlde maliyetlidir.
- Her yolda bir locale ön eki (example.com/en/, example.com/tr/). Tek alan adı, tek deploy, tek analitik mülkü. Bizim kullandığımız ve çoğu küçük iki dilli sitenin kullanması gereken yapı budur.
Next.js App Router locale ön ekini temiz biçimde yönetir, çünkü yönlendirme dosya sistemine dayanır. Sitenin tamamı app/[locale]/ altında yaşar ve her sayfa locale’i bir yol parametresi olarak alır. Özel olarak ele alınan bir ana sayfa yoktur, ‘çevrilmiş’ ve ‘çevrilmemiş’ yollar arasında gizli bir ayrım yoktur — locale yalnızca bir segment daha.
Kullandığımız klasör yapısı
app/
[locale]/
layout.tsx ← root layout: <html lang>, nav, footer, JSON-LD, hreflang
page.tsx ← home
services/page.tsx
work/
page.tsx ← work index
[slug]/page.tsx← case study
studio/page.tsx
contact/page.tsx
insights/
page.tsx
[slug]/page.tsx
[...rest]/page.tsx ← catch-all → notFound()
not-found.tsx ← localized 404
sitemap.ts
robots.tsEksik olana dikkat edin: app/layout.tsx yok. Kök layout, locale layout’unun kendisi — <html> öğesini doğrudan o sahipleniyor, böylece <html lang="…"> doğrudan yol parametresinden geliyor. Başlık hilesi yok, istemci tarafında yama yok ve en önemlisi headers() çağrısı yok; o çağrı her yolu sessizce statik üretimin dışına iterdi. Locale sisteminin dışında kalan bölümler (canlı demolarımız) kendi kök layout’larına sahip — Next.js birden fazla kök layout’u destekliyor ve locale sınırı, bunu kullanmak için tam doğru yer.
Middleware: doğru locale’i tespit edip yönlendirmek
Biri çıplak alan adına geldiğinde karar vermeniz gerekir: nereye gidecek? Elinizde üç sinyal var — URL (açıkça /tr/ yazdıysa), Accept-Language başlığı (tarayıcının istediği) ve bir çerez (en son neyi seçtiği). Kullandığımız kural, sıkıcı ama işe yarayan kural:
- URL’de zaten bir locale ön eki varsa ona uy. Seçimi hatırlamak için bir çerez yaz.
- Yoksa ve önceki bir ziyaretten kalan bir çerez varsa, o locale’e yönlendir.
- Yoksa ve Accept-Language başlığı desteklediğimiz bir locale’e karşılık geliyorsa, oraya yönlendir.
- Hiçbiri değilse varsayılan locale’e (İngilizce) düş.
Bunu layout’ta değil, middleware.ts’te yapıyoruz — daha hızlı, edge’de çalışıyor ve hydration uyumsuzluklarını önlüyor. neptay.com’da çalışan middleware’in sadeleştirilmiş bir sürümü şöyle:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale, locales } from "@/lib/i18n";
const LOCALE_COOKIE = "neptay.locale";
function pickLocaleFromAcceptLanguage(header: string | null): "en" | "tr" {
if (!header) return defaultLocale;
const lower = header.toLowerCase();
if (/(^|[,;\s])tr(-|;|,|$)/.test(lower)) return "tr";
return defaultLocale;
}
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Live demos live outside the locale system — they handle their own language.
if (pathname === "/demo" || pathname.startsWith("/demo/")) {
return NextResponse.next();
}
const matchedLocale = locales.find(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
);
if (matchedLocale) {
const res = NextResponse.next();
// Remember the locale so the next bare-domain visit lands on it.
if (req.cookies.get(LOCALE_COOKIE)?.value !== matchedLocale) {
res.cookies.set(LOCALE_COOKIE, matchedLocale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
}
return res;
}
// Honour the user's manual choice if it was already remembered.
const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
const remembered = cookieLocale && isLocale(cookieLocale) ? cookieLocale : null;
const detected = remembered ?? pickLocaleFromAcceptLanguage(req.headers.get("accept-language"));
const url = req.nextUrl.clone();
url.pathname = `/${detected}${pathname === "/" ? "" : pathname}`;
return NextResponse.redirect(url);
}
export const config = {
// Skip Next internals + static assets so they aren't redirected.
matcher: [
"/((?!_next/|api/|favicon\\.|robots\\.txt|sitemap\\.xml|.*\\.(?:png|svg|jpg|jpeg|gif|webp|ico|txt|xml)).*)",
],
};Burada iki ayrıntı emeğinin karşılığını veriyor. Accept-Language kontrolü bir startsWith değil, başlıktaki virgülle ayrılmış listenin gerçek bir ayrıştırması — “en-GB,tr;q=0.8” gönderen bir tarayıcı, bir yerde “tr” geçiyor diye Türkçe sayılmamalı. Çerez de yalnızca elle dil değiştirildiğinde değil, locale ön ekli her ziyarette yazılıyor; böylece bir sonraki çıplak alan adı ziyareti, ziyaretçinin en son okuduğu dile iniyor. Bu olmazsa dil değiştirme bozuk hissettirir: Açıkça Türkçeyi seçmiş, geri dönen bir ziyaretçi Accept-Language tahminine geri fırlatılır.
Her locale ve her yol için meta veriler
App Router’daki her sayfa generateMetadata export edebilir. İki dilli bir sitede bu fonksiyonda üç şeyin olması gerekir:
- Başlığı ve açıklamayı geçerli locale’de ayarlayın.
- Tam olarak bu yolu gösteren bir canonical URL ayarlayın (sondaki eğik çizgi sorunu yok, protokol tuhaflığı yok).
- Bu sayfanın bulunduğu her locale için hreflang alternatiflerini ve bir x-default’u ayarlayın.
Next.js, alternates.languages nesnesiyle bunu çocuk oyuncağına çeviriyor. Yol başına yapı şöyle:
export async function generateMetadata({ params }: { params: { locale: string } }): Promise<Metadata> {
if (!isLocale(params.locale)) return {};
const seo = WORK_SEO[params.locale];
return {
title: seo.title,
description: seo.description,
alternates: {
canonical: `/${params.locale}/work`,
languages: {
en: "/en/work",
tr: "/tr/work",
"x-default": "/en/work",
},
},
openGraph: { title: seo.title, description: seo.description, type: "website" },
};
}İnsanların hreflang konusunda yanlış yaptığı iki şey:
- Her alternatif karşılığını göstermeli. /en/work, /tr/work’ü alternatif olarak listeliyorsa /tr/work de /en/work’ü listelemeli. Eksik karşılıklı referanslar, Google’ın bütün hreflang grubunu yok saymasına yol açar.
- x-default bir locale değildir. Dili sizin dillerinizden hiçbirine uymayan kullanıcılar için olan URL’dir. Biz onu /en/work’e yönlendiriyoruz, çünkü İngilizce en geniş kapsamlı yedeğimiz — İngilizce daha önemli olduğu için değil.
Çok sayıda sayfanız varsa, herhangi bir yol için alternates nesnesini üreten küçük bir yardımcı fonksiyon yazın. Biz bunu satır içinde yapıyoruz, çünkü yalnızca birkaç sayfamız var ve gece 11’de Search Console’a bakarken açıkça yazılmış sürümde hata ayıklamak daha kolay.
Site haritası ve site haritasında hreflang
Meta verilerdeki yol başına hreflang işin yarısı. Google hreflang’i site haritasından da okur ve site haritası daha güçlü bir sinyaldir, çünkü bütün siteyi tek seferde kapsar. App Router’ın yerleşik bir sitemap.ts kuralı var; bizimki şöyle görünüyor:
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/lib/i18n";
const SITE = "https://neptay.com";
const PATHS = ["", "services", "work", "studio", "contact", "insights"] as const;
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
return locales.flatMap((locale) =>
PATHS.map((p) => ({
url: `${SITE}/${locale}${p ? `/${p}` : ""}`,
lastModified,
changeFrequency: "monthly" as const,
priority: p === "" ? 1 : 0.7,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${SITE}/${l}${p ? `/${p}` : ""}`])
),
},
}))
);
}Site haritasının her (locale, yol) kombinasyonu için bir kayıt ürettiğine ve her kaydın tüm locale alternatiflerini listelediğine dikkat edin. Bu tekrarlı — bilerek. Arama motorları, URL kalıplarından tahmin yürütmek yerine açıkça verilmiş çiftleri taramayı tercih eder.
Yapılandırılmış veri: tek bir temel graf, yola özel uzantılar
Schema.org JSON-LD, Google’ın çok dilli bir siteyi anlamasının ikinci ayağıdır. Biz onu iki katmana ayırıyoruz:
- Kök layout’un bir kez eklediği temel bir graf (Organization + WebSite). Sitenin kime ait olduğunu, logonun nerede durduğunu ve sitenin hangi dillerde yayınlandığını beyan eder.
- Her sayfanın eklediği yola özel şemalar (CreativeWork, BreadcrumbList, Article, FAQPage). Bu sayfanın tam olarak ne olduğunu anlatan küçük JSON-LD blokları.
İkisinde de inLanguage geçerli locale’e ayarlı. Bunu atlamayın — inLanguage olmadan Google dili sayfa metninden çıkarmak zorunda kalır; bu da kısa sayfalarda yavaş ve kusurludur.
// lib/structured-data.ts
export const organizationLd = {
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: "Neptay",
url: SITE_URL,
logo: { "@type": "ImageObject", url: `${SITE_URL}/neptay-logo.png` },
knowsLanguage: ["en", "tr"],
areaServed: "Worldwide",
};
export function articleLd(input: ArticleInput) {
return {
"@context": "https://schema.org",
"@type": "Article",
headline: input.headline,
inLanguage: input.locale, // ← critical for bilingual sites
datePublished: input.datePublished,
author: { "@id": `${SITE_URL}/#organization` },
publisher: { "@id": `${SITE_URL}/#organization` },
// …
};
}Sözlük deseni
Çeviri metinleri, locale’e göre anahtarlanmış bir const map olarak export edilen tek bir tipli sözlükte yaşar. Bu pek moda değil — günümüzün tavsiyesi, ICU mesaj biçimiyle next-intl ya da react-intl gibi bir framework kullanmak. Küçük bir site için tipli sözlük daha hızlı, daha küçük ve eksik çevirileri derleme sırasında yakalar:
export type Locale = "en" | "tr";
export const locales: Locale[] = ["en", "tr"];
export interface Dictionary {
locale: Locale;
nav: { services: string; work: string; studio: string; contact: string };
// …
}
export const dictionaries: Record<Locale, Dictionary> = {
en: { /* … */ },
tr: { /* … */ },
};Bir metnin Türkçe çevirisi eksikse TypeScript build sırasında itiraz eder. İngilizceye eklenen bir anahtar Türkçeye eklenmezse build başarısız olur. İki dil varken bunun değeri büyük: Yayına çıkmanın tek yolu, ikisini adım adım birlikte yürütmek.
Bu desen yirmi dil civarında ya da mühendis olmayanların metin düzenlemesi gerektiğinde tıkanır. O noktada sözlüğü bir CMS’e taşıyın ve çevirilerin geriden geleceğini kabullenin. İki dil ve bir stüdyo sitesi için düz TypeScript kazanır.
Statik render, edge önbelleği ve bunun SEO için önemi
Yukarıdakilerin hepsi tek bir sayfa yüklemesinde performans açısından nötr. Fark ölçekte ortaya çıkar. Her sayfayı statik olarak render ettiğinizde (çalışma anında veritabanı sorgusu yok, istek başına çeviri çağrısı yok) her sayfanın her locale’i, önbelleğe alınabilen ve edge’de önbelleklenen bir HTML belgesine dönüşür. İlk bayt süresi CDN’iniz ne diyorsa odur — dünyanın neresinde olursa olsun genellikle 30–80 ms.
Bu, SEO için iki nedenle önemli: Core Web Vitals TTFB’ye ve LCP’ye büyük ağırlık verir ve Google’ın tarama botunun bir bütçesi vardır. Statik, edge’de önbelleklenmiş bir sayfa bota tek bir ucuz gidiş-dönüşe mal olur; sunucuda render edilen bir sayfa bunun on katına mal olabilir. Beş sayfalık bir stüdyo sitesi için tarama bütçesi sorun değildir, ama iki yüz yazılık çok dilli bir blog için hızla soruna dönüşür.
Hâlâ iyileştireceğimiz şeyler
Temeller oturduktan sonra iki dilli bir App Router sitesinde hâlâ yapmaya değer olanların dürüst bir listesi:
- Locale başına OpenGraph görselleri. Şu an OG görseli iki locale’de de aynı; ideali, görseldeki başlığın da çevrilmesi.
- Otomatik çeviri kayması tespiti. EN ve TR sözlük anahtarlarını karşılaştırıp uyuşmazlıkta başarısız olan bir CI adımı. Kolay ve değerli.
- Çeviri varsa kullanıcıyı aynı sayfada tutan, yoksa (bir notla) zarifçe geri çekilen bir dil değiştirme bileşeni.
İki dilli bir site planlıyor ve mimariye ikinci bir göz istiyorsanız — ya da onu bizim kurmamızı istiyorsanız — hello@neptay.com adresinden bize ulaşabilirsiniz.