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

Redirect'ler: 301, 302, 307, 308 ve ASP.NET Core'da Doğru Redirect Kurmak

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
Redirect'ler: 301, 302, 307, 308 ve ASP.NET Core'da Doğru Redirect Kurmak

Kalıcı taşımada 301 veya 308 kullan: Google bunları güçlü canonical sinyali, 302 ve 307'yi zayıf sinyal sayar; 308 ve 307 POST'u korur. ASP.NET Core URL Rewriting Middleware'i, varsayılanı 307 olan UseHttpsRedirection'ı, veritabanındaki slug geçmişiyle tek hop'luk 301'i, zincirleri ve Angular SSR'da 302 dönen redirectTo'yu gerçek çıktılarla inceliyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10, URL (Uniform Resource Locator) Rewriting Middleware), EF (Entity Framework) Core 10.0.14 ve Angular 22.2 ile derlenip denenmiştir; Google'ın redirect yorumları Google Search Central'ın Kasım 2026 hâlinden alınmıştır.

Önce Tahmin Et

Bookstore kitap URL'lerini iki kez değiştirdi: önce /old-books/{slug}'dan /books/{slug}'a geçildi, sonra Dune'un slug'ı dune-1965'ten dune'a indirildi. Ekip iki redirect kuralı yazdı ve ikisi de 301 dönüyor. Ayrıca HTTP'den (HyperText Transfer Protocol) HTTPS'e (HTTP Secure) geçiş için ASP.NET Core şablonundaki UseHttpsRedirection() satırı olduğu gibi duruyor.

Üç soru. Eski bir blog yazısındaki /old-books/dune-1965 link'ine tıklayan kullanıcı ve Googlebot kaç redirect'ten geçer? UseHttpsRedirection() hangi status code'u döner ve Google bunu nasıl yorumlar? Ödeme formunun POST ettiği eski bir endpoint'i 301 ile yeni adrese yönlendirirsen form verisine ne olur?

Önceki yazıda status code'ların crawl ve index'e etkisini gördük ve 3xx'i bu yazıya bıraktık. Redirect, serinin ilk yazısındaki "slug değişirse redirect borçlanırsın" cümlesinin ve beşinci yazıdaki "en güçlü canonical sinyali" satırının uygulaması.

301, 302, 307, 308: İki Soru, Dört Kod

Dört kod iki sorunun cevabını kodluyor: taşınma kalıcı mı, ve istemci method'u (POST, PUT) korumalı mı?

KodKalıcı mıMethod korunur muGoogle için
301 Moved PermanentlyEvetHayır (istemciler pratikte POST'u GET'e çevirir)Güçlü canonical sinyali
302 FoundHayırHayırZayıf sinyal; kaynak URL index'te kalabilir
307 Temporary RedirectHayırEvet302 ile eşdeğer
308 Permanent RedirectEvetEvet301 ile eşdeğer

Google'ın dokümanı bu ayrımı net yapıyor: 301 ve 308, redirect hedefinin canonical olması gerektiğine dair güçlü bir sinyal; 302, 303 ve 307 zayıf bir sinyal. Geçici redirect'te Google hedefi takip eder ama index'leme pipeline'ı bunu canonical sinyali saymaz; başka sinyaller varsa kaynak URL index'te kalmaya devam edebilir. Google, redirect'in geri alınmayacağından eminsen kalıcı redirect kullanmanı söylüyor.

Method meselesini gerçek istekle görmek kolay. Dört endpoint aynı hedefe dört farklı şekilde yönlendiriyor:

// aynı hedefe dört farklı redirect
app.MapPost("/r/301", () => Results.Redirect("/echo", permanent: true));
app.MapPost("/r/302", () => Results.Redirect("/echo"));
app.MapPost("/r/307", () => Results.Redirect("/echo", preserveMethod: true));
app.MapPost("/r/308", () => Results.Redirect("/echo", permanent: true, preserveMethod: true));
app.MapMethods("/echo", ["GET", "POST"], (HttpRequest req) => $"{req.Method} /echo");
$ for c in 301 302 307 308; do printf "POST /r/$c -> "; curl -s -L -d "x=1" -H "X-Forwarded-Proto: https" http://localhost:20108/r/$c; echo; done
POST /r/301 -> GET /echo
POST /r/302 -> GET /echo
POST /r/307 -> POST /echo
POST /r/308 -> POST /echo

curl, tarayıcıların tarihsel davranışını taklit ederek 301 ve 302'de POST'u GET'e çevirip body'yi düşürüyor; 307 ve 308'de method ve body korunuyor. Açılıştaki üçüncü sorunun cevabı: ödeme formu 301'den sonra GET olarak gelir ve form verisi kaybolur. API (Application Programming Interface) ve form endpoint'lerini taşırken 308, sayfaları taşırken 301 veya 308 kullan; Google için ikisi aynı.

Diğer Redirect Türleri: Meta Refresh ve JavaScript

Sunucu tarafı redirect yapamıyorsan Google'ın tablosunda başka seçenekler de var. Anında (content="0") bir <meta http-equiv="refresh"> Google tarafından kalıcı redirect olarak, gecikmeli bir meta refresh ise geçici redirect olarak yorumlanıyor. JavaScript ile location değiştirmek de kalıcı redirect sayılıyor, ama Google bunun için açık bir şart koyuyor: JavaScript redirect'i yalnız sunucu tarafı veya meta refresh redirect mümkün değilse kullan, çünkü render başarısız olursa Google redirect'i hiç görmeyebilir. Önceki yazılardan hatırla: render, crawl'dan sonra ayrı bir kuyrukta gerçekleşiyor ve 200 dışındaki yanıtlar render'a hiç girmeyebiliyor.

Bir de "crypto redirect" var: sayfada "taşındık, yeni adres şu" diyen bir link. Google bunu kalıcı taşınma sinyali olarak güvenilir bulmuyor; başka hiçbir seçenek yoksa son çare.

ASP.NET Core URL Rewriting Middleware

Bookstore'un altyapı redirect'leri, yani HTTPS, www ve eski path öneki, URL Rewriting Middleware ile tek yerde:

// reverse proxy arkasında: X-Forwarded-Proto orijinal scheme'i söyler
app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto });

using StreamReader iisRules = File.OpenText("IISUrlRewrite.xml");
RewriteOptions rewrite = new RewriteOptions()
    .AddRedirectToHttpsPermanent()
    .AddRedirectToWwwPermanent("bookstore.example")
    .AddIISUrlRewrite(iisRules);
rewrite.AddRedirect("^old-books/(.*)", "books/$1", StatusCodes.Status301MovedPermanently);
app.UseRewriter(rewrite);
$ curl -sI http://localhost:20108/books/dune
HTTP/1.1 301 Moved Permanently
Location: https://localhost/books/dune

$ curl -sI -H "Host: bookstore.example" -H "X-Forwarded-Proto: https" http://localhost:20108/books/dune
HTTP/1.1 308 Permanent Redirect
Location: https://www.bookstore.example/books/dune

$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/old-books/dune
HTTP/1.1 301 Moved Permanently
Location: /books/dune

Dikkat çekici ayrıntı: AddRedirectToHttpsPermanent 301, AddRedirectToWwwPermanent ise 308 döndü; ikisi de "Permanent" adını taşıyor ama aynı kodu kullanmıyor. Google için fark yok, ama loglarda veya testlerde 301 bekliyorsan şaşırma. AddRedirectToWwwPermanent'a domain listesi verdim; liste olmadan localhost dahil her host'u www. önekine yönlendirir. UseForwardedHeaders burada önemli: TLS'i (Transport Layer Security) önündeki reverse proxy sonlandırıyorsa uygulama her isteği HTTP olarak görür ve HTTPS kuralı sonsuz döngüye girer. Forwarded header'ların güvenli kurulumu Web Server'lar ve Hosting serisinde.

Açılıştaki ikinci sorunun cevabı da burada. Şablondaki UseHttpsRedirection()'ın varsayılanı 307:

$ curl -sI http://localhost:20109/
HTTP/1.1 307 Temporary Redirect
Location: https://localhost/

Geliştirme ortamı için mantıklı bir seçim (kalıcı redirect tarayıcıda cache'lenir ve localhost'ta can sıkar), ama production'da HTTP'den HTTPS'e geçiş kalıcı bir karar. Microsoft Learn'deki HTTPS dokümanı da production için RedirectStatusCode'u kalıcı bir koda ayarlamayı gösteriyor. Google HTTPS'i zaten tercih ettiği için 307 büyük bir felaket değil, ama canonical sinyallerinin tutarlı olması için 301 veya 308 doğru olan.

IIS (Internet Information Services) URL Rewrite modülünü kullanan bir ekibin mevcut kuralları da kaybolmuyor: AddIISUrlRewrite, IIS'in <rewrite> XML (Extensible Markup Language) biçimini okuyor. Aşağıdaki kural Kestrel üzerinde, IIS olmadan çalıştı:

<rewrite>
  <rules>
    <rule name="Old catalog" stopProcessing="true">
      <match url="^catalog/(.*)$" />
      <action type="Redirect" url="books/{R:1}" redirectType="Permanent" />
    </rule>
  </rules>
</rewrite>
$ curl -sI -H "X-Forwarded-Proto: https" http://localhost:20108/catalog/dune
HTTP/1.1 301 Moved Permanently
Location: /books/dune

Hangisini seçmeli? Uygulama IIS arkasındaysa IIS'te tanımlanan kural isteği uygulamaya hiç ulaştırmadan döner; bu performans için iyi ama kurallar uygulama kodundan ayrı bir yerde, deploy'la birlikte versiyonlanmayan bir configuration'da yaşar. Middleware'deki kural kodla birlikte test edilir ve her hosting'de aynı çalışır. Altyapıya ait kuralları (HTTPS, host) önde, içeriğe ait kuralları (slug geçmişi) uygulamada tutmak iyi bir bölüşüm.

Veritabanından Slug Geçmişi ile 301

Slug değişiklikleri regex ile ifade edilemez; her kitabın kendi geçmişi var. Bookstore bunu bir tabloda tutuyor. Tasarımdaki kritik karar: eski slug başka bir slug'ı değil, kitabın kendisini gösteriyor.

public sealed class Book
{
    public int Id { get; set; }
    public required string Slug { get; set; }
    public required string Title { get; set; }
}

// kitabın şimdiye kadar taşıdığı her slug; başka bir slug'ı değil kitabı gösterir
public sealed class SlugHistory
{
    public required string OldSlug { get; set; }
    public int BookId { get; set; }
}
app.MapGet("/books/{slug}", async (string slug, BookstoreDb db) =>
{
    Book? book = await db.Books.SingleOrDefaultAsync(b => b.Slug == slug);
    if (book is not null)
        return Results.Content($"<h1>{book.Title}</h1>", "text/html");

    string? current = await CurrentSlugAsync(db, slug);
    return current is null ? Results.NotFound() : Results.Redirect($"/books/{current}", permanent: true);
});

// eski slug mu? doğrudan güncel slug'a git: tek hop, asla zincir yok
static Task<string?> CurrentSlugAsync(BookstoreDb db, string oldSlug) =>
    db.SlugHistory
        .Where(h => h.OldSlug == oldSlug)
        .Join(db.Books, h => h.BookId, b => b.Id, (h, b) => b.Slug)
        .SingleOrDefaultAsync();

Dune'un slug'ı iki kez değişmiş: dune-1965 → frank-herbert-dune → dune. Geçmiş tablosu "eski slug → yeni slug" tutsaydı, ilk slug ikinciye, ikinci üçüncüye giderdi ve her değişiklik zinciri bir hop uzatırdı. Kitabı gösterdiği için ikisi de tek hop'ta güncel adrese iniyor:

$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/books/dune-1965
HTTP/1.1 301 Moved Permanently
Location: /books/dune
$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/books/frank-herbert-dune
HTTP/1.1 301 Moved Permanently
Location: /books/dune

Redirect Zincirleri

Açılıştaki ilk soruya dönelim. Regex kuralı ve slug geçmişi ayrı ayrı doğru, ama birlikte bir zincir kuruyorlar:

$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/old-books/dune-1965
HTTP/1.1 301 Moved Permanently
Location: /books/dune-1965
$ curl -s -L -H "X-Forwarded-Proto: https" -o /dev/null -w "hops=%{num_redirects} final=%{url_effective} %{http_code}\n" http://localhost:20108/old-books/dune-1965
hops=2 final=http://localhost:20108/books/dune 200

Önündeki HTTPS ve www kuralları da devreye girdiğinde http://bookstore.example/old-books/dune-1965 dört hop'a çıkar. Googlebot varsayılan olarak 10 hop'a kadar izliyor ve kalıcı redirect'ler PageRank kaybettirmiyor; yani zincir felaket değil. Ama her hop bir round-trip, kullanıcı için gecikme ve Google'ın redirect rehberi ile crawl budget dokümanı uzun zincirlerden kaçınmayı öneriyor. Çözüm, eski öneki de aynı çözücüye bağlamak; regex kuralını kaldırıp /old-books/{slug}'ı slug geçmişini de bilen bir endpoint'e çevirdim:

// eski önek ve eski slug birlikte çözülür: son URL'ye tek hop
app.MapGet("/old-books/{slug}", async (string slug, BookstoreDb db) =>
{
    string? current = await db.Books.Where(b => b.Slug == slug).Select(b => b.Slug).SingleOrDefaultAsync()
        ?? await CurrentSlugAsync(db, slug);
    return current is null ? Results.NotFound() : Results.Redirect($"/books/{current}", permanent: true);
});
$ curl -s -D - -o /dev/null -H "X-Forwarded-Proto: https" http://localhost:20108/old-books/dune-1965
HTTP/1.1 301 Moved Permanently
Location: /books/dune
$ curl -s -L -H "X-Forwarded-Proto: https" -o /dev/null -w "hops=%{num_redirects} final=%{url_effective} %{http_code}\n" http://localhost:20108/old-books/dune-1965
hops=1 final=http://localhost:20108/books/dune 200

Genel ilke: her redirect doğrudan son hedefe gitmeli. Altyapı kuralları (HTTPS, host) ile içerik kuralları (slug) birbirinden bağımsız olduğu sürece tam tek hop her zaman mümkün olmaz; ama içerik kurallarının kendi aralarında zincir kurmaması senin elinde.

Angular SSR'da Redirect: 302 Varsayılanı

Angular router'daki redirectTo, SSR (Server-Side Rendering) altında gerçek bir HTTP redirect'e dönüşüyor. Bookstore'un Angular uygulamasında { path: 'old-books/:slug', redirectTo: 'books/:slug' } route'unu ilk çalıştırdığımda dönen kod:

$ curl -sI http://localhost:20110/old-books/dune | head -3
HTTP/1.1 302 Found
X-Powered-By: Express
location: /books/dune

302, yani geçici ve zayıf bir canonical sinyali. Kalıcı bir URL taşıması için server route'ta status'u açıkça vermek gerekiyor:

{ path: 'old-books/:slug', renderMode: RenderMode.Server, status: 301 },
$ curl -sI http://localhost:20110/old-books/dune | head -3
HTTP/1.1 301 Moved Permanently
X-Powered-By: Express
location: /books/dune

angular.dev, prerender sırasında redirectTo'nun <meta http-equiv="refresh"> ile bir "soft redirect"e dönüştüğünü söylüyor. Denemede bir ayrıntı çıktı: { path: 'shop', redirectTo: '' } route'unu RenderMode.Prerender ile işaretlediğim hâlde build bu route için dosya üretmedi ve sunucu isteğe 302 döndü:

$ curl -sI http://localhost:20110/shop | head -3
HTTP/1.1 302 Found
X-Powered-By: Express
location: /

@angular/build 22.2.6'nın prerender kodu bunu açıklıyor: build'in outputMode'u static değilse redirect route'ları prerender'da atlanıyor ve sunucunun HTTP redirect'ine bırakılıyor. Meta refresh sayfası yalnız sunucusuz (outputMode: "static") build'lerde üretiliyor; orada da anında meta refresh olduğu için Google bunu kalıcı redirect sayar. Sunucusu olan projelerde redirect'lerin status code'unu server route'ta açıkça vermek bu yüzden önemli.

mertozen.com'da Redirect'ler

Bu sitenin kaynak kodundaki en büyük redirect seti, 7 Kasım 2026'daki v1.12.0 sürümüyle geldi: dil seçimi ?lang=en query parametresinden /en/ alt klasörüne taşındı ve eski ?lang= adresleri 301 ile yeni adreslere yönlendiriliyor; /en da tek bir kanonik adrese, /en/'e 301 alıyor. Bu taşımanın gerekçesi ve planı serinin onuncu ve on ikinci yazılarının konusu; burada iki redirect ayrıntısına bakalım.

Birincisi, legacy redirect'ler query string'in geri kalanını koruyor: ?lang=en&page=2, lang atılıp page=2 taşınarak yönlendiriliyor. Doğru davranış; parametreleri düşüren bir redirect, sayfalama veya filtre link'lerini sessizce bozardı.

İkincisi, sitenin Program.cs'inde UseHttpsRedirection() varsayılan ayarla duruyor; yukarıda gördüğümüz gibi bu 307 demek. Site IIS ve Plesk üzerinde çalıştığı için önde ayrı bir HTTP'den HTTPS'e 301 kuralı olabilir; o durumda istek uygulamaya hiç ulaşmaz. Bunu kaynak koddan göremiyorum. Önde böyle bir kural yoksa, AddHttpsRedirection ile RedirectStatusCode'u 308'e çekmek tek satırlık bir değişiklik. Karar site sahibinin.

Üretimde Dikkat

Redirect'leri en az bir yıl, tercihen süresiz tut. Google'ın site taşıma rehberi en az bir yıl öneriyor; dış link'ler ve yer imleri ise yıllarca yaşar. Slug geçmişi tablosu birkaç bin satırla sonsuza kadar durabilir.

Redirect testleri yaz: her eski URL için beklenen hedefi ve hop sayısını assert eden bir tablo test. Zincirler, bir kural eklendiğinde başka bir kuralla etkileşerek ortaya çıkar; elle fark edilmez. Serinin son yazısında taşıma sonrası bütün eski URL'leri tarayan bir doğrulama script'i kuracağız.

Redirect hedefinin 200 döndüğünü ve canonical'ının kendisi olduğunu kontrol et. Redirect hedefi kendi canonical'ını başka bir yere veriyorsa ya da noindex'liyse sinyaller çelişir.

Search Console'da "Sayfalar" raporundaki "Page with redirect" satırı, Google'ın bildiği redirect'li URL'leri listeler. Bu listede sitemap'te olan URL'ler görüyorsan sitemap'in güncel değil demektir: sitemap'te yalnız redirect hedefleri olmalı.

Sık Düşülen Tuzaklar

Kalıcı taşımada 302 kullanmak. Angular SSR'ın varsayılanı ve birçok framework'ün Redirect() çağrısı 302; Google için zayıf sinyal.

Form veya API endpoint'ini 301 ile taşımak. POST, GET'e döner ve body kaybolur; 308 kullan.

Eski slug'ları "eski → yeni" diye zincirlemek. Her değişiklik zinciri uzatır; geçmişi entity'ye bağla.

Silinen her sayfayı ana sayfaya yönlendirmek. Alakasız hedefe toplu redirect, Google'ın rehberinde kaçınılması gerekenler arasında; ilgili bir hedef yoksa 404 veya 410 dön.

Dil veya ülke tahminine göre otomatik redirect. Googlebot genellikle ABD'den ve Accept-Language header'ı göndermeden crawl ediyor; IP'ye (Internet Protocol) veya tarayıcı diline göre yönlendirme, Googlebot'un bazı dil sürümlerini hiç görememesine yol açabilir. Google bunun yerine bir dil seçici öneriyor; ayrıntısı serinin onuncu yazısında.

Küçük Bir Deneme

★☆☆ Kendi sitende http://, www'suz ve büyük harfli bir URL'yi curl -s -L -o /dev/null -w "%{num_redirects} %{url_effective}\n" ile iste. Kaç hop var ve hangi kodlarla?

★★☆ RewriteOptions'a özel bir IRule yaz: HTTPS, www ve küçük harf normalizasyonunu tek kuralda birleştirip son URL'yi hesaplasın ve tek bir 301 dönsün. Önceki hâliyle hop sayısını karşılaştır.

★★★ Slug geçmişi tablosu için bir admin akışı tasarla: editör slug'ı değiştirdiğinde eski slug otomatik olarak SlugHistory'ye yazılsın, yeni slug başka bir kitabın eski slug'ıyla çakışıyorsa kayıt reddedilsin ve bir kitap silindiğinde geçmişteki slug'ları 410 dönsün. Üç senaryoyu integration test ile doğrula.

Buraya kadar her şey sunucunun gönderdiği yanıtla ilgiliydi. Ama içeriğini tarayıcıda JavaScript ile üreten bir uygulamada Googlebot ilk yanıtta neredeyse boş bir HTML (HyperText Markup Language) görür. Bir sonraki yazıda CSR (Client-Side Rendering), SSR ve SSG (Static Site Generation) farkını, Googlebot'un render kuyruğunu ve aynı Angular sayfasının iki render modunda crawler'a nasıl göründüğünü ölçeceğiz.