İçindekiler
App Router, Next.js'te veri çekme konusundaki düşünce biçimimizi değiştirdi. i18n konusundaki yaklaşımımızı da değiştirdi. Mevcut kılavuzların büyük çoğunluğu çeviriyi hâlâ istemci taraflı bir mesele olarak ele alıyor—çalışma zamanında yüklenen bir JSON dosyası demeti. Bu yaklaşım Pages Router döneminde mantıklıydı. 2026'da, React Server Components varsayılan hale geldiğinde, daha iyisini yapabiliyoruz.
Bu yazıda, RSC-native i18n için benimsediğimiz kalıpları adım adım ele alıyoruz: middleware'de locale nasıl algılanır, çeviriler istemci yükü olmadan server component'larda nasıl çekilir, client island'lar nasıl temiz bir şekilde hydrate edilir ve CDN-first dağıtım denklemi nasıl tamamen değiştirir.
RSC i18n'in Temellerini Neden Değiştiriyor
Pages Router'da, getServerSideProps veya getStaticProps çevirileri sunucu taraflı olarak çekip sayfa paketine seri hale getiriyordu. İstemci, kullanıcı başına hiç değişmeyen çeviri sözlükleri de dahil olmak üzere her şeyi yeniden hydrate ediyordu.
Server Component'lar bu kalıbı ortadan kaldırır. RSC çıktısı için hydration adımı yoktur—HTML nihaidir. Bu, server component'ta çevirileri çekebileceğimiz, çevrilmiş stringleri doğrudan HTML'e render edebileceğimiz ve istemciye sıfır çeviri ile ilgili JavaScript gönderebileceğimiz anlamına gelir. Sözlük paketi yok, çalışma zamanı araması yok, hydration uyuşmazlığı yok.
Sorun şu: çeviriye ihtiyaç duyan client component'lar (etkileşimli formlar, dinamik arayüzler) hâlâ belirli bir istemci taraflı erişim gerektirir. İki endişeyi açıkça ayırdığınızda kalıp netleşir.
Middleware: İstek Uygulamanıza Ulaşmadan Locale Algılama
Locale yönlendirmesi middleware'de gerçekleşir; çünkü herhangi bir component render edilmeden önce edge'de çalışması gerekir.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es'] as const
type Locale = typeof SUPPORTED_LOCALES[number]
const DEFAULT_LOCALE: Locale = 'en'
function detectLocale(request: NextRequest): Locale {
// 1. Check URL path prefix
const pathname = request.nextUrl.pathname
const pathLocale = pathname.split('/')[1] as Locale
if (SUPPORTED_LOCALES.includes(pathLocale)) {
return pathLocale
}
// 2. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].trim().slice(0, 2) as Locale)
.find(lang => SUPPORTED_LOCALES.includes(lang))
if (preferred) return preferred
}
return DEFAULT_LOCALE
}
export function middleware(request: NextRequest) {
const locale = detectLocale(request)
const pathname = request.nextUrl.pathname
// Redirect root to locale-prefixed path
if (!SUPPORTED_LOCALES.some(l => pathname.startsWith(`/${l}`))) {
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
)
}
// Inject locale into headers for server components
const response = NextResponse.next()
response.headers.set('x-locale', locale)
return response
}
export const config = {
matcher: ['/((?!api|_next|_static|favicon.ico).*)'],
}
x-locale başlığı, middleware ile server component'lar arasındaki köprüdür. Her istekte ayarlanır; böylece herhangi bir server component, prop drilling olmadan okuyabilir.
App Router Yapısı: Layout Segment Olarak Locale
Geleneksel App Router yaklaşımı, locale'e özgü rotaları bir [locale] segmenti altında sarar:
app/
[locale]/
layout.tsx ← locale bağlamını ayarlar, temel çevirileri çeker
page.tsx ← ana sayfa
blog/
[slug]/
page.tsx
dashboard/
page.tsx
api/
...
[locale]/layout.tsx, tüm alt ağaç için çevirileri başlattığımız yerdir:
// app/[locale]/layout.tsx
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { TranslationProvider } from '@/components/translation-provider'
import { fetchTranslations } from '@/lib/translations'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es']
interface LayoutProps {
children: React.ReactNode
params: { locale: string }
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params
if (!SUPPORTED_LOCALES.includes(locale)) {
notFound()
}
// Fetch base translations for layout-level strings
const baseTranslations = await fetchTranslations(locale, 'common')
return (
<html lang={locale}>
<body>
<TranslationProvider locale={locale} messages={baseTranslations}>
{children}
</TranslationProvider>
</body>
</html>
)
}
Server Component'lar: Edge'de Çeviri Çekme
İşte temel değişim: locale dosyalarını uygulamanızla paketlemek yerine, istek zamanında bir CDN'den çeviriler çekiyoruz. Fonksiyon, diğer herhangi bir async veri çekme işlemi gibi görünür:
// lib/translations.ts
import { cache } from 'react'
type Messages = Record<string, string>
// React cache() tek bir render geçişinde çekme işlemlerini tekilleştirir
export const fetchTranslations = cache(async (
locale: string,
namespace: string
): Promise<Messages> => {
const url = `https://cdn.better-i18n.com/v1/${locale}/${namespace}.json`
const res = await fetch(url, {
next: {
revalidate: 3600, // CDN tarafından teslim edilir, saatlik yenile
tags: [`translations-${locale}-${namespace}`],
},
})
if (!res.ok) {
// Sayfayı bozmak yerine İngilizce'ye geri dön
if (locale !== 'en') {
return fetchTranslations('en', namespace)
}
throw new Error(`Failed to fetch translations: ${res.status}`)
}
return res.json()
})
React'ın cache() sarmalayıcısı, aynı sayfadaki birden fazla server component'ın aynı namespace'i talep etmesi durumunda render geçişi başına yalnızca bir ağ isteği tetiklenmesini sağlar. Next.js'in fetch önbelleğiyle birleştiğinde CDN yanıtı genellikle edge önbelleğinden sunulur—çeviri aramaları için milisaniyenin altında gecikme.
Bu kalıbı kullanan bir server component:
// app/[locale]/blog/[slug]/page.tsx
import { fetchTranslations } from '@/lib/translations'
import { getPost } from '@/lib/posts'
interface PageProps {
params: { locale: string; slug: string }
}
export default async function BlogPost({ params }: PageProps) {
const { locale, slug } = await params
// Her iki çekme de paralel gerçekleşir
const [t, post] = await Promise.all([
fetchTranslations(locale, 'blog'),
getPost(slug),
])
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p className="meta">
{t['blog.readTime'].replace('{n}', String(post.readTimeMinutes))}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<footer>
<a href={`/${locale}/blog`}>{t['blog.backToList']}</a>
</footer>
</article>
)
}
İstemci JavaScript'i yok. Hydration yok. Çeviri anahtarı araması sunucuda gerçekleşir ve sonuç seri hale getirilmiş HTML olarak döner.
Tip-Güvenli Çeviri Anahtarları
String tabanlı anahtar aramaları (t['blog.readTime']), anahtarlar değiştiğinde sessizce bozulur. Temel locale'in mesaj dosyasından bir tip üretiriz:
// types/translations.ts (generated, do not edit manually)
// Generated from: en/blog.json
export type BlogNamespaceKey =
| 'blog.readTime'
| 'blog.backToList'
| 'blog.publishedOn'
| 'blog.updatedOn'
| 'blog.authorBy'
| 'blog.sharePost'
| 'blog.relatedPosts'
// Typed accessor
export function t(messages: Record<string, string>, key: BlogNamespaceKey): string {
const value = messages[key]
if (value === undefined) {
// In development, surface the missing key
if (process.env.NODE_ENV === 'development') {
console.warn(`Missing translation key: ${key}`)
}
return key
}
return value
}
Üretim adımı, çeviri dosyaları değiştiğinde CI pipeline'ının bir parçası olarak çalışır. TypeScript daha sonra eksik veya yeniden adlandırılmış anahtarları üretimde çalışma zamanında değil, derleme zamanında yakalar.
Bu, Better i18n gibi bir platformun gerçek fayda sağladığı alanlardan biridir—tip üretimi otomatikleştirilir ve yeni anahtarları dashboard aracılığıyla gönderir göndermez tip çıktısı editörde güncellenir.
Client Component Hydration: Çeviri Island'ları
Her şey server component olamaz. Formlar, modallar ve etkileşimli arayüzler, dinamik stringler için—hata mesajları, doğrulama geri bildirimi, async durum etiketleri—istemci taraflı çeviri erişimine ihtiyaç duyar.
Kalıp, ilgili mesaj alt kümesini seri hale getiren bir context provider'dır:
// components/translation-provider.tsx
'use client'
import { createContext, useContext } from 'react'
type Messages = Record<string, string>
const TranslationContext = createContext<{
messages: Messages
locale: string
} | null>(null)
export function TranslationProvider({
children,
messages,
locale,
}: {
children: React.ReactNode
messages: Messages
locale: string
}) {
return (
<TranslationContext.Provider value={{ messages, locale }}>
{children}
</TranslationContext.Provider>
)
}
export function useTranslations(namespace?: string) {
const ctx = useContext(TranslationContext)
if (!ctx) throw new Error('useTranslations must be used inside TranslationProvider')
return {
t: (key: string) => {
const fullKey = namespace ? `${namespace}.${key}` : key
return ctx.messages[fullKey] ?? fullKey
},
locale: ctx.locale,
}
}
Layout server component mesajları çeker, ardından provider'a iletir. Client component'lar ek bir çekme tetiklemeden useTranslations() fonksiyonunu çağırır:
// components/contact-form.tsx
'use client'
import { useTranslations } from '@/components/translation-provider'
import { useState } from 'react'
export function ContactForm() {
const { t } = useTranslations('forms')
const [error, setError] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
const result = await submitContact(formData)
if (!result.ok) {
setError(t('contact.submitError'))
}
}
return (
<form action={handleSubmit}>
<label>{t('contact.nameLabel')}</label>
<input name="name" required />
<label>{t('contact.emailLabel')}</label>
<input name="email" type="email" required />
<button type="submit">{t('contact.submitButton')}</button>
{error && <p role="alert">{error}</p>}
</form>
)
}
Temel kısıt: yalnızca client component'ın gerçekten ihtiyaç duyduğu namespace'leri iletin. Tüm çeviri sözlüğünü istemci paketine seri hale getirmek amacı boşa çıkarır.
Statik Rotalar: generateStaticParams
Statik olarak oluşturulan sayfalar (pazarlama sayfaları, blog yazıları, dokümantasyon) için, derleme zamanında her locale'i önceden render etmek amacıyla generateStaticParams kullanırız:
// app/[locale]/page.tsx
export async function generateStaticParams() {
const locales = ['en', 'de', 'fr', 'es']
return locales.map(locale => ({ locale }))
}
export async function generateMetadata({ params }: { params: { locale: string } }) {
const { locale } = await params
const t = await fetchTranslations(locale, 'home')
return {
title: t['home.meta.title'],
description: t['home.meta.description'],
alternates: {
languages: {
'en': '/en',
'de': '/de',
'fr': '/fr',
'es': '/es',
},
},
}
}
CDN tarafından teslim edilen çevirilerle derleme, derleme zamanında CDN'den mesaj dosyalarını çeker ve bunları statik HTML'e işler. Çalışma zamanında CDN statik dosyayı sunar—çeviri arama süresi pratikte sıfırdır.
next-intl ve next-i18next ile Karşılaştırma
next-intl, App Router uyumlu en olgun kütüphanedir. Yukarıda açıklanan middleware ve context boilerplate'inin büyük bölümünü yönetir. Dezavantajı, locale dosyalarının repository'nizde (/messages/en.json, /messages/de.json) bulunduğunu varsaymasıdır. Her yeni çeviri bir kod dağıtımı gerektirir.
next-i18next, Pages Router için geliştirilmiştir. App Router projelerinde en iyi ihtimalle bir uyumluluk shim'idir. Yeni projeler için kullanmaktan kaçınırız.
CDN-first yaklaşım (yukarıda açıkladığımız) çeviri içeriğini kod dağıtımlarından ayırır. Fransızca bir çeviriyi güncelleyin ve saniyeler içinde CDN'de yayında olsun—yeniden dağıtım yok, PR yok, CI bekleme yok. Çeviri ekibi, mühendislik ekibinden bağımsız çalışır.
Dosya tabanlı yaklaşımlar için operasyonel yük argümanı ("daha basit") 20 locale'niz ve özel bir yerelleştirme ekibiniz olduğunda hızla çöker. Çeviri PR'larını merge etmek değer değil, gürültüdür.
2026'da Farklı Yapacaklarımız
- Streaming çeviriler: CDN çekme işlemi çözüme kavuşurken yoğun çeviri gerektiren component'ların etrafında
<Suspense>—soğuk CDN önbelleklerinin gecikmeye yol açtığı uç durumlar için kullanışlı. - Component başına namespace granülaritesi: Layout düzeyinde çekme yerine, her component'ın gerçekten ihtiyaç duyduğu namespace'leri çekin. Dar çeviri gereksinimi olan sayfalarda yük boyutunu azaltır.
- Çoğul ve biçimlendirme kuralları: Kütüphane soyutlamalarına güvenmek yerine doğrudan
Intl.PluralRulesveIntl.NumberFormatkullanın. Tarayıcı API'leri artık desteklenen tüm ortamlarda güvenilirdir.
Başlarken
Yukarıdaki kalıplar, CDN tarafından teslim edilen herhangi bir çeviri kaynağıyla çalışır. Altyapı kurulumunu atlayıp tip-güvenli anahtarlar, otomatik CDN teslimi ve hazır çeviri ekibine uygun bir editör edinmek istiyorsanız, Better i18n'in Next.js entegrasyonu bu stack'i kapsar—middleware yardımcıları, tip anahtar üretimi ve çeviri ekibinizin koda dokunmadan kullanabileceği bir dashboard.
Tam API referansı ve SDK entegrasyon kılavuzları için geliştirici belgelerine bakın.