Mert Özen Her Satırda Daha İleri
Frontend JavaScript Orta — #16

JavaScript URL, URLSearchParams ve History API: Sayfayı Yenilemeden Adres Yönetimi

Mert Özen 3 Kas 2026 9 dk 5 görüntülenme
JavaScript URL, URLSearchParams ve History API: Sayfayı Yenilemeden Adres Yönetimi

URL ve URLSearchParams ile adresi parçalamayı, hash'i, pushState ile replaceState farkını, popstate'in ne zaman geldiğini öğreniyor ve framework'süz küçük bir SPA router'ı yazıyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.

Önce Tahmin Et

Herhangi bir sayfada konsolu aç. Önce geçmişteki gezinmeleri dinleyen bir listener ekliyoruz, sonra adres çubuğundaki URL'yi koddan değiştiriyoruz:

window.addEventListener("popstate", () => console.log("got popstate"));

history.pushState({ page: 2 }, "", "?page=2");
console.log(location.search);

Konsola ne yazar, adres çubuğunda ne değişir, sayfa yenilenir mi? Okumaya devam etmeden önce bir tahmin yap. Sonra tarayıcının geri tuşuna bas ve konsolu izle. İki adımın sonucunu, History API'yi anlattıktan sonra birlikte yorumlayacağız.

URL Object'i: Adresi Parçalarına Ayırmak

Bir web adresi, zarfın üstündeki posta adresine benzer. Ülke, şehir, sokak, kapı numarası ve "Ayşe Hanım'a" notu. Bunları string işlemleriyle, split ve indexOf ile ayırmaya çalışmak hem yorucu hem hataya açık. Tarayıcı sana bu iş için hazır bir araç veriyor: URL. Bunu konsola yapıştırıp dene:

const url = new URL("https://shop.example.com:8080/products/shoes?color=red&size=42#reviews");

url.protocol;  // "https:"
url.hostname;  // "shop.example.com"
url.port;      // "8080"
url.host;      // "shop.example.com:8080"
url.origin;    // "https://shop.example.com:8080"
url.pathname;  // "/products/shoes"
url.search;    // "?color=red&size=42"
url.hash;      // "#reviews"

Bu property'ler yazılabilir de. url.pathname = "/cart" dediğinde url.href kendiliğinden güncellenir. İkinci parametre olarak bir taban adres verirsen göreli adresleri de çözer: new URL("../api", "https://example.com/docs/page/") sana https://example.com/docs/api verir. Daha önce gördüğümüz location object'i de aynı property'lere sahiptir; o, şu an açık olan sayfanın adresidir.

Geçersiz bir adres verirsen new URL bir TypeError fırlatır. Kullanıcıdan gelen bir metni kontrol ederken bunu try/catch ile sarmak yerine iki statik method kullanabilirsin: URL.canParse(text) true ya da false döner, URL.parse(text) ise geçerliyse bir URL object'i, değilse null verir. URL.parse 2024'ten beri bütün güncel tarayıcılarda var.

URLSearchParams: Query String ile Barışmak

Adresin ? işaretinden sonraki kısmı, yani query string, filtre, arama terimi ve sayfa numarası gibi bilgileri taşır. URLSearchParams bu kısmı bir sözlük gibi okuyup yazmanı sağlar:

const params = new URLSearchParams("q=coffee mug&tag=porcelain&tag=gift");

params.get("q");           // "coffee mug"
params.get("missing");     // null
params.getAll("tag");      // ["porcelain", "gift"]
params.has("q");           // true
params.size;               // 3

params.set("page", "2");   // varsa değiştirir, yoksa ekler
params.append("tag", "sale");
params.delete("q");
params.toString();         // "tag=porcelain&tag=gift&page=2&tag=sale"

for (const [key, value] of params) {
  console.log(key, value);
}

Asıl rahatlık kodlamada. new URLSearchParams({ q: "tea & café" }).toString() sana q=tea+%26+caf%C3%A9 verir; aksanlı karakterleri, boşluğu ve & işaretini senin yerine kodlar. Elle string birleştirerek URL kurduğunda en sık yapılan hata tam olarak budur: içinde & geçen bir arama terimi, sorgunun geri kalanını bozar.

Her URL object'inin canlı bir searchParams property'si vardır. Onu değiştirdiğinde URL'nin kendisi de değişir. O anki sayfanın parametrelerini okumak için de new URLSearchParams(location.search) yazman yeterli:

const link = new URL("https://example.com/products?page=1");
link.searchParams.set("page", "2");
link.searchParams.append("sort", "price");
console.log(link.href); // "https://example.com/products?page=2&sort=price"

const current = new URLSearchParams(location.search);
const page = Number(current.get("page") ?? 1);

Son satırdaki Number'a dikkat et. URL'den gelen her değer string'dir; "2" + 1 tuzağına bir kez daha düşmemek için sayıya kendin çevirmelisin.

Hash: Sayfanın İçindeki Adres

Adresin # işaretinden sonraki kısmı, yani hash ya da fragment, sunucuya hiç gönderilmez. Tarayıcı onu sayfa içinde bir yeri işaretlemek için kullanır: #reviews dersen id="reviews" olan element'e kaydırır. JavaScript'ten location.hash ile okuyup yazabilirsin ve hash değiştiğinde window üzerinde bir hashchange event'i tetiklenir.

window.addEventListener("hashchange", () => {
  console.log("New hash:", location.hash);
});

location.hash = "settings"; // sayfa yenilenmez, geçmişe yeni bir kayıt eklenir

History API yaygınlaşmadan önce tek sayfalık uygulamalar (SPA) bütün routing'i hash ile yapardı: #/products/42 gibi. Hâlâ işe yarayan bir yöntem, çünkü sunucu tarafında hiçbir ayar gerektirmez. Ama adresler çirkin durur ve hash'in asıl işi olan sayfa içi bağlantıyla çakışır. Bugün bu iş için History API kullanılıyor.

History API: pushState ve replaceState

Tarayıcı geçmişini üst üste dizilmiş bir kart destesi gibi düşün. Her kartta bir adres yazar ve geri tuşu en üstteki kartı kaldırıp bir alttakine döner. Daha önce gördüğümüz history.back() bu desteyi gezmenin yoluydu. history.pushState ise destenin üstüne yeni bir kart koyar, history.replaceState en üstteki kartın üzerindeki yazıyı değiştirir. İkisi de sunucuya istek atmaz ve sayfayı yenilemez; yalnızca adres çubuğunu ve geçmişi değiştirir.

// history.pushState(state, unused, url)
history.pushState({ view: "products" }, "", "?view=products");
history.state;        // { view: "products" }

// Filtre yazarken her tuşta yeni kart açmak yerine mevcudu güncelle
history.replaceState({ view: "products", q: "mug" }, "", "?view=products&q=mug");

Birinci parametre state object'idir: o geçmiş kaydına iliştirdiğin küçük bir not. Tarayıcı onu daha önce gördüğümüz structuredClone ile aynı algoritmayla kopyalar; bu yüzden içine function ya da DOM element'i koyamazsın. İkinci parametre tarihsel sebeplerle duruyor ve tarayıcılar onu kullanmıyor; boş string vermek gelenek. Üçüncüsü yeni adres. Bu adres mevcut sayfayla aynı origin'de olmak zorunda; başka bir siteye ait bir URL verirsen SecurityError alırsın. Yani hiçbir sayfa adres çubuğunda kendini bir başka site gibi gösteremez.

Ne zaman hangisi? Kullanıcının "yeni bir sayfaya geçtim" diye hissettiği her şey pushState: ürün listesinden ürün detayına gitmek gibi. Aynı sayfanın ince ayarı replaceState: arama kutusuna yazılan her harf, kaydırılan bir slider. Aksi hâlde kullanıcı geri tuşuna bastığında yazdığı kelimeyi harf harf silmek zorunda kalır.

popstate: Geri Tuşunu Duymak

Baştaki denemeye dönelim. pushState'ten sonra konsolda yalnızca ?page=2 gördün; adres değişti, sayfa yenilenmedi ve "got popstate" yazmadı. Bu çok önemli bir kural: pushState ve replaceState, popstate event'ini tetiklemez. Geri tuşuna bastığında ise "got popstate" yazdı.

popstate, kullanıcı ya da kod geçmişte gezindiğinde gelir: geri ve ileri tuşları, history.back(), history.forward(), history.go(n). Event geldiğinde location zaten yeni adresi gösterir ve event.state o kayda iliştirdiğin notu verir. Sayfanın ilk kaydına dönersen, ona hiç state vermediğin için event.state null gelir.

window.addEventListener("popstate", (event) => {
  console.log("Address:", location.search, "State:", event.state);
});

history.pushState({ page: 1 }, "", "?page=1");
history.pushState({ page: 2 }, "", "?page=2");
history.back(); // Address: ?page=1 State: { page: 1 }
history.back(); // Address: (ilk adres) State: null

Buradan çıkan zihinsel model şu: pushState dediğin yerde ekranı da kendin güncellersin, çünkü değişikliği zaten sen yaptın. popstate geldiğinde ise değişikliği kullanıcı yapmıştır ve ekranı yeni adrese göre yeniden çizmen gerekir. İkisi aynı render fonksiyonunu çağırırsa kod tek bir yerde kalır.

Framework'süz Küçük Bir Router

Şimdi hepsini birleştirip basit bir SPA router'ı kuralım. Linklere tıklamayı daha önce gördüğümüz event delegation ile yakalıyor, sayfa yenilemeyi engelliyor, adresi pushState ile değiştiriyor ve içeriği adrese bakarak çiziyoruz. Örnek, dosyayı doğrudan diskten açtığında da çalışsın diye yolu değil query string'i kullanıyor:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Mini router</title>
</head>
<body>
  <nav>
    <a href="?view=home">Home</a>
    <a href="?view=products">Products</a>
    <a href="?view=about">About</a>
  </nav>
  <main id="app"></main>
  <script>
    const app = document.querySelector("#app");
    const products = { 1: "Coffee mug", 2: "Teapot", 3: "Tea glass" };

    function render() {
      const params = new URLSearchParams(location.search);
      const view = params.get("view") ?? "home";
      const title = document.createElement("h2");
      const body = document.createElement("p");

      if (view === "home") {
        title.textContent = "Home";
        body.textContent = "Welcome. Pick a page from the menu.";
      } else if (view === "products") {
        title.textContent = "Products";
        for (const [id, name] of Object.entries(products)) {
          const link = document.createElement("a");
          link.href = `?view=product&id=${id}`;
          link.textContent = name;
          body.append(link, " ");
        }
      } else if (view === "product") {
        const name = products[params.get("id")];
        title.textContent = name ?? "Product not found";
        body.textContent = name ? `Product number: ${params.get("id")}` : "Go back to the list.";
      } else if (view === "about") {
        title.textContent = "About";
        body.textContent = "This page runs on the History API.";
      } else {
        title.textContent = "Page not found";
      }

      app.replaceChildren(title, body);
      document.title = title.textContent;
    }

    document.addEventListener("click", (event) => {
      const link = event.target.closest("a");
      if (!link || link.origin !== location.origin) return;
      if (event.ctrlKey || event.metaKey || event.shiftKey) return; // yeni sekmede açmaya izin ver
      event.preventDefault();
      if (link.href !== location.href) {
        history.pushState(null, "", link.href);
      }
      render();
    });

    window.addEventListener("popstate", render);
    render();
  </script>
</body>
</html>

Dosyayı aç, menüde gez, bir ürüne gir, sonra geri ve ileri tuşlarını kullan. Sayfa bir kez bile yenilenmedi ama adres her adımda doğru, geçmiş de çalışıyor. Bir ürün adresini kopyalayıp yeni sekmede açarsan doğrudan o ürün gelir, çünkü render her şeyi adresten okuyor. Ürün adını ve numarasını textContent ile yazmamız da önemli: adres kullanıcının elinde, içine ne yazacağını bilemezsin; innerHTML kullansaydık URL üzerinden XSS'e kapı açardık. Ctrl ya da Cmd ile tıklamayı serbest bırakmak da kullanıcının "yeni sekmede aç" alışkanlığını bozmamak için.

Gerçek Projelerde Yol Tabanlı Adresler

Gerçek uygulamalarda adresler genelde /products/3 gibi temiz yollar olur. Mantık aynı; params.get("view") yerine location.pathname'i parçalarsın. Ama burada tarayıcının dışında bir şart devreye girer: kullanıcı /products/3 adresinde sayfayı yenilediğinde tarayıcı bu yolu gerçekten sunucudan ister. Sunucuda öyle bir dosya yoksa 404 alırsın. Bu yüzden SPA'leri barındıran sunucular, bilmedikleri her yol için aynı HTML dosyasını döndürecek şekilde ayarlanır; sayfa açıldıktan sonra routing'i JavaScript devralır. Diskten açılan bir dosyada da farklı bir yola pushState yapmaya çalışmak hata verir; query string ve hash'i değiştirmek ise serbest. Örneğimizin query string kullanmasının sebebi bu.

İki küçük nezaket kuralı daha. Her gezinmede document.title'ı güncelle, çünkü geçmiş listesinde ve sekmede o görünür. Ekran okuyucu kullananlar için de içerik değiştiğinde odağı yeni başlığa taşımayı düşün; tam sayfa yüklemesinde tarayıcının kendiliğinden yaptığı şeyleri artık sen yapıyorsun.

Sık Düşülen Tuzaklar

pushState'ten sonra popstate beklemek. Gelmez. Ekranı pushState'in hemen ardından kendin güncelle.

Her tuş vuruşunda pushState. Geçmiş yüzlerce kayıtla dolar ve geri tuşu işe yaramaz hâle gelir. Aynı sayfadaki küçük değişiklikler için replaceState kullan.

URL'yi string birleştirerek kurmak. "?q=" + term yazdığında, içinde & ya da # olan bir arama terimi adresi bozar. URLSearchParams ya da url.searchParams bu işi senin yerine doğru yapar.

State'e her şeyi koymak. State kopyalanabilir küçük bir not olmalı. Function koyarsan DataCloneError alırsın; büyük veri koyarsan geçmişi şişirirsin. Asıl kaynak her zaman adres olsun; state yalnızca yardımcı bilgi taşısın.

Küçük Bir Deneme

★☆☆ Konsolda new URL("https://example.com/search?q=js") ile bir URL oluştur. searchParams üzerinden q'yu "tea & café" yap, page=3 ekle ve href'in son hâlini yazdır.

★★☆ Çıktıyı tahmin et, sonra dene: bir sayfada popstate listener'ı ekledikten sonra sırayla history.pushState(null, "", "?a"), history.replaceState(null, "", "?b") ve history.back() çalıştır. Listener kaç kez çalışır ve geri dönülen adres ne olur?

★★★ Router örneğindeki ürünler sayfasına bir arama kutusu ekle. Kutuya yazılanı replaceState ile ?view=products&q=... olarak adrese yaz ve listeyi filtrele. Sayfayı yenilediğinde arama kutusu ve liste aynı hâliyle geri gelsin.

Router'ımız büyüdükçe tek bir script etiketine sığmaz olur: görünümler bir dosyada, ürün verisi başka bir dosyada, yardımcı fonksiyonlar bir üçüncüsünde durmak ister. Bir sonraki yazıda tarayıcının yerleşik module sistemine bakıyoruz: import, export, dynamic import(), import maps ve JSON dosyalarını doğrudan içeri almak.