Mert Özen Her Satırda Daha İleri
SEO ve GEO Teknik SEO — #01

SEO Dostu URL Tasarımı: Slug, Lowercase, Trailing Slash ve Query Parametreleri

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
SEO Dostu URL Tasarımı: Slug, Lowercase, Trailing Slash ve Query Parametreleri

SEO dostu URL tek ve kalıcıdır: küçük harf, tireyle ayrılmış okunur kelimeler, tutarlı trailing slash ve içeriği değiştirmeyen parametrelerden arınmış query. Türkçe başlıktan ASCII slug üreten C# fonksiyonunu, ASP.NET Core'da 301 ile normalizasyonu, Angular router'da fragment yerine History API'yi ve mertozen.com slug'larını gerçek çıktılarla inceliyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10) ve Angular 22.2 ile derlenip denenmiştir; Google'ın URL (Uniform Resource Locator) yapısı önerileri Kasım 2026 hâliyle esas alınmıştır.

Önce Tahmin Et

Bookstore ekibi yeni kitap sayfalarını yayına aldı. Pazarlama ekibi kampanya e-postasına /Books/Dune yazmış, bir blog yazarı /books/dune/ diye link vermiş, sitenin kendi menüsü ise /books/dune üretiyor. Üç adres de tarayıcıda aynı sayfayı açıyor. Arama motoru kaç sayfa görür: bir mi, üç mü?

İkinci soru: geliştiricilerden biri kitap başlığından URL üreten küçük bir fonksiyon yazmış; "Şeker Portakalı" için eker-portakal, LINQ (Language Integrated Query) üzerine bir kitap olan "LINQ İPUÇLARI" için l-nq-ipu-lar üretiyor. Sayfa açılıyor, test geçiyor. Bu URL'ler yayına girerse sonradan düzeltmek ne kadar pahalıya patlar?

Bu yazı Teknik SEO (Search Engine Optimization, arama motoru optimizasyonu) serisinin ilk yazısı. Seri, sitenin arama motorunca doğru crawl, render ve index edilmesini sağlayan altyapıyı geliştirici gözüyle, kodla anlatıyor. Crawl, index ve ranking kavramlarının kendisi SEO Temelleri serisinde; burada o kavramların koda dökülmüş hâli var. İlk durak, her şeyin anahtarı olan URL.

Google URL'den Ne Bekliyor?

Google Search Central'daki URL yapısı sayfası kısa ama net. Teknik zemin şu: Google, IETF'in (Internet Engineering Task Force) STD (Internet Standard) 66 ile, yani RFC (Request for Comments) 3986 ile tanımlanan URL'leri destekliyor; reserved karakterler percent-encode edilmeli. Bunun üstüne okunabilirlik önerileri geliyor:

  • Uzun ID'ler yerine anlamlı kelimeler: /books/dune, /p?id=83721'den iyidir.
  • Kelimeler tire ile ayrılır, alt çizgi ile değil; kelimeleri bitiştirmek (greendress) de önerilmiyor.
  • Kitlenin dilindeki kelimeler, gerekiyorsa transliteration ile kullanılabilir; ASCII (American Standard Code for Information Interchange) dışı karakterler href içinde percent-encode edilir.
  • URL'ler büyük/küçük harfe duyarlıdır: Google /APPLE ile /apple'ı farklı URL sayar.
  • Sayfa içeriğini değiştirmek için fragment (#) kullanılmaz; JavaScript ile içerik yükleniyorsa History API (Application Programming Interface) önerilir.
  • Query parametreleri key=value ve & ile yazılır; içeriği değiştirmeyen parametreler atılır.

Tire ve alt çizgi meselesi 23 Ekim 2026'da dokümanda netleştirildi. Gerekçe tarihsel: programlama dillerinde alt çizgi terimleri birleştirmek için kullanılıyor (book_title tek bir identifier), tire ise kelime sınırı gösteriyor. Yani dune_messiah yazarsan Google'ın bunu iki kelime olarak okuyacağının garantisi yok. Mevcut alt çizgili URL'lerini sırf bunun için değiştirmen gerekmiyor; değişiklik her zaman redirect ve geçici dalgalanma demek. Yeni yapı kuruyorsan tireyle başla.

Dikkat: URL'deki kelimelerin sıralamaya etkisi hakkında Google ağırlık veya oran açıklamıyor. URL tasarımının asıl getirisi başka yerde: tek ve kalıcı adres, okunabilir bir arama sonucu, paylaşıldığında anlamı belli bir link ve crawler'ın sonsuz varyasyonda kaybolmaması.

Türkçe Başlıktan ASCII Slug'a

Bookstore'da kitap ve blog sayfalarının URL'si başlıktan üretilen bir slug ile kuruluyor. Önce o "çalışan" naif versiyona bakalım:

// naif versiyon: kültüre duyarlı ToLower + ASCII olmayan her şeyi at
app.MapGet("/naive", (string title) =>
{
    string lower = title.ToLower(new System.Globalization.CultureInfo("tr-TR"));
    string ascii = System.Text.RegularExpressions.Regex.Replace(lower, "[^a-z0-9]+", "-");
    return ascii.Trim('-');
});
Şeker Portakalı -> eker-portakal
LINQ İPUÇLARI -> l-nq-ipu-lar
Ağaçlar Ölürken -> a-a-lar-l-rken

İki ayrı hata üst üste binmiş. tr-TR kültüründe büyük I noktasız ı'ya dönüşüyor (meşhur Türkçe I sorunu), ardından regex ASCII olmayan her harfi tireye çeviriyor. Sonuç okunmaz, kelime sınırları yanlış ve bir kez yayına girdi mi kalıcı. Doğrusu, harfleri atmak yerine karşılığına çevirmek:

using System.Globalization;
using System.Text;

namespace Bookstore.Urls;

public static class Slugifier
{
    // Unicode normalization ile ayrışmayan karakterler
    private static readonly Dictionary<char, string> Special = new()
    {
        ['ı'] = "i", ['İ'] = "i", ['ß'] = "ss", ['æ'] = "ae", ['ø'] = "o", ['đ'] = "d", ['ł'] = "l",
    };

    public static string ToSlug(string title, int maxLength = 80)
    {
        string text = title
            .Replace("C#", "csharp", StringComparison.OrdinalIgnoreCase)
            .Replace(".NET", "net", StringComparison.OrdinalIgnoreCase);

        StringBuilder sb = new(text.Length);
        foreach (char c in text.Normalize(NormalizationForm.FormD))
        {
            if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
                continue; // birleşik işaretleri at: c + çengel -> c
            if (Special.TryGetValue(c, out string? mapped))
                sb.Append(mapped);
            else if (char.IsAsciiLetterOrDigit(c))
                sb.Append(char.ToLowerInvariant(c));
            else if (c is '\'' or '’')
                continue; // "C#'ta" -> "csharpta"
            else
                sb.Append('-');
        }

        string slug = string.Join('-', sb.ToString().Split('-', StringSplitOptions.RemoveEmptyEntries));
        if (slug.Length <= maxLength)
            return slug;

        int cut = slug.LastIndexOf('-', maxLength);
        return cut > 0 ? slug[..cut] : slug[..maxLength];
    }
}

Kilit nokta NormalizationForm.FormD. Bu form "ç"yi "c" ile birleşik bir çengel işaretine, "ö"yü "o" ile iki noktaya ayırıyor; NonSpacingMark kategorisindeki işaretleri atınca temel harf kalıyor. Ama "ı" ve "İ" (ayrıca "ß", "ø" gibi birkaç harf) ayrışmıyor, onları tabloyla çeviriyoruz. Küçük harfe çevirmek için ToLowerInvariant kullanılıyor; kültüre bağlı ToLower sunucunun bölge ayarına göre farklı sonuç verebilir. Kesme işareti tamamen atılıyor ki "C#'ta" csharp-ta değil csharpta olsun. Gerçek çıktılar:

C#'ta Async/Await: Task, ValueTask ve Deadlock -> csharpta-async-await-task-valuetask-ve-deadlock
.NET 10'a Geçiş Rehberi -> net-10a-gecis-rehberi
Şeker Portakalı -> seker-portakali
İSTANBUL'da Öğle Arası -> istanbulda-ogle-arasi
Ağaçlar Ölürken: Çığ Gibi Büyüyen Sorunlar -> agaclar-olurken-cig-gibi-buyuyen-sorunlar

Uzunluk sınırı kelime ortasında kesmesin diye son tireden kesiliyor. Bu sınırın bir SEO kuralı olmadığını belirteyim: Google URL uzunluğu için bir sayı vermiyor. Sınır, CMS (Content Management System, içerik yönetim sistemi) alanının ve okunabilirliğin sınırı.

Transliteration'ın alternatifi, Türkçe karakterleri URL'de bırakmak. Google bunu destekliyor; /kitaplar/şeker-portakalı geçerli bir URL ve href içinde %C5%9Feker-portakal%C4%B1 diye percent-encode edilir. Pratikte sorun Google'da değil, URL'nin geçtiği diğer yerlerde: kopyalanınca yapışan percent-encoded hâli, e-posta istemcileri, log analizi, bazı eski sistemlerin farklı Unicode normalization formlarıyla ürettiği iki ayrı byte dizisi. ASCII slug bu sınıftaki sorunların hepsini baştan kapatıyor; Türkçe okur için "seker-portakali" de gayet okunur.

Büyük/Küçük Harf ve Trailing Slash: Tek Bir URL Kuralı

Açılıştaki ilk sorunun cevabı: üç. Google /Books/Dune ile /books/dune'u farklı URL sayıyor; root dışındaki path'lerde /books/dune/ ile /books/dune de iki ayrı URL. Sunucun üçüne de aynı içeriği 200 ile dönerse, Google bunları duplicate olarak gruplayıp birini canonical seçmek zorunda kalır. Seçimi ona bırakmak yerine kuralı sen koy: tek biçim üret, diğerlerini 301 ile ona yolla.

using Bookstore.Urls;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<RouteOptions>(o =>
{
    o.LowercaseUrls = true;        // üretilen link'ler: /books/dune
    o.AppendTrailingSlash = false; // "/books/dune/" yok
});

WebApplication app = builder.Build();

// sayfa başına tek URL: büyük harf veya sondaki slash -> canonical biçime 301
app.Use(async (context, next) =>
{
    string path = context.Request.Path.Value ?? "/";
    string normalized = path.Length > 1 ? path.TrimEnd('/') : path;
    normalized = normalized.ToLowerInvariant();

    if (normalized != path)
    {
        context.Response.Redirect(normalized + context.Request.QueryString, permanent: true);
        return;
    }
    await next();
});

app.MapGet("/books/{slug}", (string slug) => $"Book page: {slug}")
   .WithName("BookDetail");

app.MapGet("/links", (LinkGenerator links) =>
    links.GetPathByName("BookDetail", new { slug = "Dune-Messiah", page = 2 }));

app.MapGet("/slug", (string title) => Slugifier.ToSlug(title));

app.Run();

Burada iki ayrı iş var. RouteOptions yalnız uygulamanın kendi ürettiği link'leri etkiliyor: LinkGenerator, Razor'daki tag helper'lar ve Url.Action hep küçük harfli, sonu slash'sız path üretiyor. Gelen istekleri ise etkilemiyor; ASP.NET Core routing zaten case-insensitive eşleştirdiği için /Books/Dune hiçbir şey yapmasan da 200 döner. Duplicate'i asıl o üretir. Middleware bu yüzden gerekli:

$ curl -s http://localhost:20101/links
/books/dune-messiah?page=2

$ curl -si http://localhost:20101/Books/Dune/
HTTP/1.1 301 Moved Permanently
Location: /books/dune

$ curl -si "http://localhost:20101/BOOKS/Dune?ref=Home"
HTTP/1.1 301 Moved Permanently
Location: /books/dune?ref=Home

İki ayrıntıya dikkat. LowercaseUrls route değerini de küçültüyor (Dune-Messiah → dune-messiah) ama query string'e dokunmuyor; onun için ayrı bir LowercaseQueryStrings seçeneği var, açmadan önce query değerlerinin büyük/küçük harfe duyarlı olup olmadığını düşün (bir token veya base64 değeri bozulur). Middleware de query string'i olduğu gibi taşıyor. Kendi uygulamanda bu kuralı yazarken route parametrelerinin gerçekten case-insensitive olduğundan emin ol; büyük/küçük harfe duyarlı bir kimlik (ör. kısa link kodu) taşıyan path'ler bu kuralın dışında kalmalı.

Trailing slash'ın hangisi "doğru" sorusunun cevabı yok; Google ikisini de kabul ediyor. Doğru olan tutarlılık: seç, link'lerini, sitemap'ini ve canonical'larını hep aynı biçimle üret, diğerini redirect et. Uygulamanın önündeki IIS (Internet Information Services) veya Nginx'te aynı kuralı rewrite ile de kurabilirsin; o konfigürasyon Web Server'lar ve Hosting serisinin konusu. Redirect'in kendisini (301 mi 308 mi, zincirler) serinin yedinci yazısında açacağız.

Query Parametreleri ve Sonsuz URL Uzayı

Bookstore'un kategori sayfasını düşün: /books?genre=scifi&sort=price&page=2. Filtreler birleştirilebiliyorsa (tür × yazar × fiyat aralığı × dil × sıralama × sayfa) URL sayısı çarpımla büyür. Google dokümanı bunu "additive filtering" diye anıyor ve net söylüyor: Google'ın her ürüne ulaşabilmesi için birkaç listeye ihtiyacı var, her kombinasyona değil. Aynı sınıfta başka URL üreticileri de var:

  • Session ID veya takip parametresi (?sid=789, ?ref=newsletter): içeriği değiştirmez; session için cookie kullan, takip parametresini canonical'dan at.
  • Sıralama parametreleri: aynı listeyi farklı sırada gösterir, yeni içerik yoktur.
  • Sonsuz takvimler: "sonraki ay" link'i sonsuza kadar yeni URL üretir; Google gelecek tarihli dinamik takvim link'lerine nofollow önerir.
  • Kırık relative link'ler: href="books/dune" gibi parent-relative bir link, var olmayan path'lere gerçek 404 dönmeyen bir sunucuda /books/books/books/... diye sonsuz path üretir. Çözüm root-relative link (/books/dune).

Birden çok değer gerekiyorsa Google'ın önerisi virgül: ?genre=scifi,fantasy. Parametre sırasını da sabitle; ?a=1&b=2 ile ?b=2&a=1 farklı URL'ler. mertozen.com'un blog listesi bunun iyi bir örneği: kategori, seri, tag, sıralama ve sayfa query parametresiyle geliyor, ama canonical üretilirken parametreler hep aynı sırayla diziliyor, varsayılan değerler (sort=publishedAt:desc, page=1) atılıyor ve site içi arama terimi (q) canonical'a hiç girmiyor. Böylece aynı liste kaç farklı yoldan açılırsa açılsın, tek bir adres beyan ediliyor. Hangi parametre kombinasyonunun crawl edileceğini robots.txt ile, hangisinin index'e gireceğini canonical ve noindex ile yönetmeyi serinin sonraki yazılarında göreceğiz.

Fragment'lar ve Angular Router

Fragment (# sonrası) tarayıcıda kalır, sunucuya gönderilmez. Bu yüzden https://bookstore.example/#/books/dune ile https://bookstore.example/#/books/foundation crawler açısından aynı URL'dir: kök sayfa. Eski AngularJS uygulamaları ve bugün hâlâ withHashLocation() ile kurulan Angular projeleri tam bu durumda. Google dokümanı açık: içerik değiştirmek için fragment kullanma, History API kullan.

Angular router varsayılan olarak zaten History API kullanıyor (PathLocationStrategy); yapman gereken route path'lerini URL tasarımına göre koymak ve link'leri gerçek <a href> olarak üretmek:

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'books/:slug', component: BookDetail },
  { path: 'old-books/:slug', redirectTo: 'books/:slug' },
  { path: 'search', component: Search },
  { path: '**', component: NotFound },
];
<!-- doğru: routerLink gerçek bir href üretir -->
<a [routerLink]="['/books', book.slug]">{{ book.title }}</a>
<!-- crawler için link değil: href yok -->
<button (click)="openFoundation()">Foundation</button>

Uygulamayı SSR (Server-Side Rendering) ile çalıştırıp sunucunun gönderdiği HTML'e (HyperText Markup Language) baktığında fark net görünüyor:

$ curl -s http://localhost:20110/ | grep -o '<a [^>]*>[^<]*</a>\|<button[^>]*>[^<]*</button>'
<a routerlink="/" href="/" jsaction="click:;">Home</a>
<a routerlink="/search" href="/search?q=dune" jsaction="click:;">Search</a>
<a href="/books/dune" jsaction="click:;">Dune</a>
<a href="/books/foundation" jsaction="click:;">Foundation</a>
<a href="/books/neuromancer" jsaction="click:;">Neuromancer</a>
<button jsaction="click:;">Foundation</button>

Kitap link'leri href="/books/dune" olarak geliyor; buton ise crawler için hiçbir yere gitmiyor. Google yalnız href'i olan <a> element'lerini link olarak takip ediyor. Route path'lerinde de aynı kurallar geçerli: küçük harf, tire, anlamlı kelime. Angular router path'leri varsayılan olarak büyük/küçük harfe duyarlı eşleştirdiği için /Books/dune isteği ** route'una düşer; SSR'da bunu 404 yapmak serinin altıncı yazısının konusu. Angular routing'in temelleri için Angular serisindeki "Angular Routing Rehberi: RouterModule, Route Parametreleri ve Child Route'lar" yazısına bakabilirsin.

mertozen.com'un Slug'ları: Kesik Slug da Kalıcıdır

Bu sitenin kendi URL'leri iyi bir saha örneği. Türkçe sayfa adları transliteration ile ASCII yazılmış: /videolar, /hakkimda, /araclar, /iletisim. Blog slug'ları başlıktan üretiliyor ve Türkçe ekler kelimeye yapışık kalıyor: csharpta-..., net-10a-.... Yukarıdaki Slugifier'ı ASP.NET Core Web API serisinin yirminci yazısının başlığıyla ("ASP.NET Core Web API'de Pagination, Filtering ve Sorting: Büyük Listeleri Güvenle Döndürmek") çalıştırdım:

$ curl -s -G --data-urlencode "title=ASP.NET Core Web API'de Pagination, Filtering ve Sorting: Büyük Listeleri Güvenle Döndürmek" http://localhost:20101/slug
aspnet-core-web-apide-pagination-filtering-ve-sorting-buyuk-listeleri-guvenle

Sonuç, yazının sitede yayımlanmış slug'ıyla birebir aynı: 80 karakter sınırı yüzünden "dondurmek" kelimesi düşmüş. Okur için küçük bir eksiklik, ama asıl ders şu: bu adres artık yazının kalıcı kimliği. Başka sitelerden gelen link'ler, Google'ın index'i, sosyal medya paylaşımları ve sitemap hep bu adresi biliyor. "Slug'ı düzelteyim" dediğin an eski adres için bir 301 kuralı borçlanmış olursun. Kesik bir slug, kırık bir link'ten her zaman daha ucuzdur.

Aynı mantıkla, bir yazının başlığını sonradan değiştirmek slug'ı otomatik değiştirmemeli. CMS'te slug'ı başlıktan yalnız ilk kayıtta üretip sonra ayrı ve kilitli bir alan olarak tutmak, bu sınıftaki hataların çoğunu baştan önler.

Üretimde Dikkat

Slug'ın benzersizliğini veritabanında garanti et: aynı başlıklı iki kitap (iki farklı baskı) aynı slug'ı üretir. Unique index koy, çakışmada yazar adı veya yıl ekle (dune-1965), sayaç (dune-2) son çare olsun; sayaç anlam taşımaz.

Slug değiştiğinde eski slug'ı silme, bir geçmiş tablosunda tut ve 301 ver. Bunun veritabanı destekli bir örneğini serinin yedinci yazısında kuruyoruz. Search Console'daki (Google'ın site sahipleri için ücretsiz panelinin) "Sayfalar" raporunda redirect'li ve 404 sayfaları izle; URL değişikliği sonrası gelen 404 artışı genelde unutulmuş bir eski link demek.

Normalizasyon middleware'ini pipeline'ın başına, static file ve routing'den önce koy. Ama sıralamayı test et: /Images/Cover.JPG gibi gerçekten büyük harfli dosya adların varsa, küçültme kuralı bu dosyaları kırar. Static dosyaları ya küçük harfle yayımla ya da kuralı yalnız sayfa path'lerine uygula. Pipeline sıralamasının mantığı ASP.NET Core Web API serisinin "ASP.NET Core Middleware Pipeline: Sıralama, Use, Run, Map ve Kendi Middleware'in" yazısında.

Ölçüm tarafında bakacağın sinyal, aynı içeriğin birden çok URL ile görünüp görünmediği. Server log'larında aynı slug'ın farklı biçimlerle (büyük harf, sondaki slash, takip parametresi) ne sıklıkla istendiğine bakmak, kuralın gerçekten işe yarayıp yaramadığını gösterir. Log analizi serinin on birinci yazısında.

Sık Düşülen Tuzaklar

Yayından sonra slug değiştirip redirect koymamak. En pahalı URL hatası budur: eski adresi bilen her şey 404'e düşer ve sayfanın biriktirdiği sinyaller yeni adrese taşınmaz.

Kültüre duyarlı ToLower(). Geliştirici makinesinde en-US ile doğru çalışan kod, Türkçe bölge ayarlı bir sunucuda LINQ'i lınq yapar. URL, dosya adı ve identifier üretirken hep ToLowerInvariant().

Her filtre kombinasyonuna crawl edilebilir link vermek. Filtre UI'ı (User Interface) <a href> ile yüzlerce kombinasyon üretiyorsa, crawler'ın vaktini o kombinasyonlar yer. Önemli kombinasyonları (ör. tür sayfası) gerçek URL yap, gerisini canonical, robots.txt veya form/JavaScript ile yönet.

Uygulamanın kendi link'lerinde tutarsızlık. Redirect kuralı varken menünün hâlâ /Books/Dune üretmesi, her tıklamada fazladan bir redirect demek. LowercaseUrls bu yüzden middleware'in yanında duruyor.

Fragment'lı route'larla SPA (Single-Page Application) yayına almak. #/books/dune crawler için yoktur; sonradan History API'ye geçmek, bütün paylaşılmış link'ler için bir taşıma projesine döner.

Küçük Bir Deneme

★☆☆ Slugifier.ToSlug'ı kendi sitendeki on başlıkla çalıştır ve mevcut slug'larla karşılaştır. Farklı çıkanlar varsa hangisinin doğru olduğunu değil, hangisinin yayında olduğunu esas al; yayındaki adres kalıcıdır.

★★☆ Normalizasyon middleware'ine bir istisna listesi ekle: /s/{code} biçimindeki kısa link'ler büyük/küçük harfe duyarlı olsun ve küçültülmesin. curl -si ile hem /S/AbC hem /s/AbC isteklerinin ne döndüğünü kontrol et.

★★★ Bookstore kategori sayfası için bir query parametresi politikası yaz: hangi parametreler (genre, page) canonical'a girer, hangileri (sort, ref, sid) atılır, parametreler hangi sırayla dizilir? Bunu gelen query'den canonical URL üreten tek bir fonksiyon olarak yaz ve beş farklı sıralanmış/eksik/fazla parametreli istekle test et.

URL'ler artık tek ve kalıcı. Ama crawler'ın bu URL'lerin hangilerine gireceğini, hangilerine hiç uğramayacağını sitenin kökündeki tek bir dosya belirliyor. Bir sonraki yazıda RFC 9309 ile standartlaşan robots.txt'in kurallarını, en spesifik eşleşmenin nasıl seçildiğini ve neyi asla engellememen gerektiğini, ASP.NET Core'da environment'a göre üretilen bir robots.txt ile göreceğiz.