Next.js App Router'da Paralel Rotalar ve Intercepting Routes: Modal, Dashboard ve İleri Düzey Düzen Kalıpları

Next.js App Router'ın en güçlü iki özelliği Paralel Rotalar ve Intercepting Routes ile çok panelli dashboard'lar, Instagram tarzı paylaşılabilir modaller ve rol tabanlı düzenler oluşturmayı adım adım öğrenin.

Bu yazı serimizin dördüncü ve belki de en heyecan verici bölümü. Daha önce React Server Components ile veri çekme stratejilerini inceledik, Server Actions ile güvenli form işlemlerini ve Zod validasyonunu ele aldık, ardından Middleware katmanında Edge Runtime ve kimlik doğrulama kalıplarını detaylıca anlattık. Şimdi sıra geldi Next.js App Router'ın sunduğu en güçlü ve — bence — en az anlaşılan iki özelliğe: Paralel Rotalar (Parallel Routes) ve Intercepting Routes (Rota Yakalama).

Dürüst olalım: geleneksel React uygulamalarında modal yönetimi hep bir kabus gibiydi. Çok panelli dashboard düzenleri, koşullu içerik render etme... Hepsi ayrı birer acı noktasıydı. Modal açmak için global state yönetiyorduk, URL'yi modal durumuna senkronize etmek için karmaşık mantıklar yazıyorduk ve kullanıcı sayfayı yenilediğinde? Her şey dağılıyordu.

Paralel Rotalar ve Intercepting Routes, tam olarak bu sorunları çözmek için tasarlandı. Bu iki özelliği birlikte kullandığınızda Instagram tarzı paylaşılabilir modaller, rol tabanlı dashboard panelleri ve çok adımlı form akışları gibi kalıpları şaşırtıcı derecede temiz bir şekilde uygulayabilirsiniz. Ciddi söylüyorum, ilk kez doğru çalıştığını gördüğünüzde "bu kadar kolay olamaz" diyeceksiniz.

Hazırsanız, App Router'ın derinliklerine dalalım.

Paralel Rotalar (Parallel Routes) Nedir?

Paralel Rotalar, aynı layout içinde birden fazla sayfayı eş zamanlı olarak render etmenize olanak tanıyan bir mekanizmadır. Bunu "slot" (yuva) kavramı üzerinden yapar. Her slot, klasör adının başına @ işareti eklenerek tanımlanır ve bu slotlar layout bileşenine prop olarak geçirilir.

Kritik nokta şu: slotlar URL'yi etkilemez. Yani @metrics adında bir slot oluşturduğunuzda, URL'de /metrics gibi bir segment görmezsiniz. Slot, sadece layout seviyesinde render edilen bağımsız bir içerik alanıdır. Bu da onları dashboard panelleri, yan menüler, bildirim alanları ve tabii ki modaller için ideal kılar.

En basit haliyle bir paralel rota yapısı şöyle görünür:

app/
  dashboard/
    @analytics/
      page.tsx
    @notifications/
      page.tsx
    layout.tsx
    page.tsx

Bu yapıda layout.tsx dosyası üç farklı içerik alanı alır: ana sayfa içeriği (children), analytics paneli (analytics) ve bildirimler paneli (notifications). Hepsini aynı anda, birbirinden bağımsız olarak render eder.

Slot Konvansiyonu ve Layout Entegrasyonu

Slotların layout ile nasıl entegre olduğunu anlamak gerçekten önemli. Her @klasör adı, layout bileşeninde aynı isimle bir prop haline gelir. Ayrıca unutmamanız gereken bir şey var: children aslında örtük (implicit) bir slottur. Yani page.tsx dosyanız, pratikte @children/page.tsx ile eşdeğerdir.

// app/dashboard/layout.tsx

export default function DashboardLayout({
  children,
  analytics,
  notifications,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  notifications: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="grid grid-cols-12 gap-6 p-6">
        {/* Ana içerik - children (örtük slot) */}
        <main className="col-span-8">
          {children}
        </main>

        {/* Sağ panel */}
        <aside className="col-span-4 space-y-6">
          {analytics}
          {notifications}
        </aside>
      </div>
    </div>
  );
}

Burada analytics ve notifications propları, sırasıyla @analytics/page.tsx ve @notifications/page.tsx dosyalarından gelir. Bu bileşenler tamamen bağımsızdır — kendi veri çekme mantıkları, kendi yükleme durumları ve kendi hata sınırları olabilir. RSC yazımızda ele aldığımız streaming ve Suspense kalıplarıyla da mükemmel uyum sağlar.

default.tsx: En Sık Yapılan Hata

Paralel rotalarla çalışırken en sık karşılaşılan hata, default.tsx dosyasını unutmaktır. Ve inanın bana, bu hata sizi üretim ortamında çok kötü sürprizlerle karşılaştırabilir. Neden kritik olduğunu anlamak için Next.js'in iki farklı navigasyon türünü bilmemiz gerekiyor.

Soft navigation (istemci tarafı navigasyon): Kullanıcı bir <Link> bileşenine tıkladığında gerçekleşir. Next.js, her slotun mevcut durumunu hafızada tutar. Eşleşen slot varsa güncellenir, eşleşmeyen slotlar son durumlarını korur.

Hard navigation (tam sayfa yenileme): Kullanıcı sayfayı yenilediğinde veya URL'yi doğrudan tarayıcıya yazdığında gerçekleşir. Next.js, her slot için aktif URL'ye karşılık gelen bir page.tsx arar. Bulamazsa default.tsx dosyasına bakar. O da yoksa 404 hatası döner.

Bu davranış, özellikle iç içe rotalarda büyük sorunlara yol açar. Diyelim ki dashboard'unuzda /dashboard/settings rotasına gittiğinizde @analytics slotunun settings alt rotası yoksa, hard navigation sonrası 404 alırsınız. Çözüm basit ama hayati önemde:

// app/dashboard/@analytics/default.tsx

export default function AnalyticsDefault() {
  // Eşleşen bir rota olmadığında gösterilecek varsayılan içerik
  return (
    <div className="rounded-xl border border-gray-200 bg-white p-6">
      <p className="text-gray-500">Analitik verisi yükleniyor...</p>
    </div>
  );
}
// app/dashboard/@notifications/default.tsx

export default function NotificationsDefault() {
  return (
    <div className="rounded-xl border border-gray-200 bg-white p-6">
      <p className="text-gray-500">Bildirimler yükleniyor...</p>
    </div>
  );
}

Altın kural: Her slot için mutlaka bir default.tsx dosyası oluşturun. Bunu bir alışkanlık haline getirin — hatta refleks olsun. Geliştirme ortamında her şey sorunsuz çalışıyor gibi görünebilir çünkü genellikle soft navigation kullanırsınız. Ama kullanıcılarınız URL'yi paylaştığında veya sayfayı yenilediğinde sorunlarla karşılaşırlar. Üretim ortamında hata ayıklaması en zor hatalardan biridir bu.

Bağımsız Yükleme ve Hata Durumları

Paralel rotaların en güçlü yanlarından biri, her slotun kendi loading.tsx ve error.tsx dosyalarına sahip olabilmesi. Bu sayede bir panel yüklenirken diğerleri kullanıma hazır olabilir; bir panelde hata oluştuğunda diğerleri etkilenmez. RSC yazımızda ele aldığımız streaming yaklaşımıyla birebir uyumlu.

app/
  dashboard/
    @analytics/
      page.tsx
      loading.tsx    ← Sadece analitik paneli için yükleme durumu
      error.tsx      ← Sadece analitik panelindeki hatalar için
    @notifications/
      page.tsx
      loading.tsx    ← Sadece bildirim paneli için yükleme durumu
      error.tsx      ← Sadece bildirim panelindeki hatalar için
    layout.tsx
    page.tsx
    loading.tsx      ← Ana içerik için yükleme durumu
// app/dashboard/@analytics/loading.tsx

export default function AnalyticsLoading() {
  return (
    <div className="animate-pulse rounded-xl bg-white p-6">
      <div className="mb-4 h-4 w-32 rounded bg-gray-200" />
      <div className="h-48 rounded bg-gray-100" />
    </div>
  );
}
// app/dashboard/@analytics/error.tsx
"use client";

export default function AnalyticsError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="rounded-xl border border-red-200 bg-red-50 p-6">
      <h3 className="text-sm font-semibold text-red-800">
        Analitik Verisi Yüklenemedi
      </h3>
      <p className="mt-1 text-sm text-red-600">{error.message}</p>
      <button
        onClick={reset}
        className="mt-3 rounded-md bg-red-100 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-200"
      >
        Tekrar Dene
      </button>
    </div>
  );
}

Bu yapı sayesinde analitik API'si çökse bile bildirimler paneli sorunsuz çalışmaya devam eder. Kullanıcı deneyimi açısından bu gerçekten büyük bir kazanım.

Gerçek Dünya Örneği: Çok Panelli Dashboard

Teoriyi yeterince konuştuk, şimdi ellerimizi kirletelim. Gerçek dünyadan bir örnek yapalım: üç bağımsız panele sahip bir yönetici dashboard'u. Her panel kendi verisini sunucudan çeker, kendi yükleme durumuna sahiptir ve birbirinden tamamen bağımsız çalışır.

app/
  dashboard/
    @metrics/
      page.tsx
      loading.tsx
      default.tsx
    @activity/
      page.tsx
      loading.tsx
      default.tsx
    @revenue/
      page.tsx
      loading.tsx
      default.tsx
    layout.tsx
    page.tsx
    default.tsx
// app/dashboard/layout.tsx

export default function DashboardLayout({
  children,
  metrics,
  activity,
  revenue,
}: {
  children: React.ReactNode;
  metrics: React.ReactNode;
  activity: React.ReactNode;
  revenue: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-gray-50 p-6">
      <h1 className="mb-8 text-2xl font-bold text-gray-900">
        Yönetim Paneli
      </h1>

      {/* Üst panel satırı */}
      <div className="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
        {metrics}
        {revenue}
        {activity}
      </div>

      {/* Ana içerik */}
      <div className="rounded-xl bg-white p-6 shadow-sm">
        {children}
      </div>
    </div>
  );
}
// app/dashboard/@metrics/page.tsx

import { getMetrics } from "@/lib/data";

export default async function MetricsPanel() {
  const metrics = await getMetrics();

  return (
    <div className="rounded-xl bg-white p-6 shadow-sm">
      <h2 className="mb-4 text-lg font-semibold text-gray-800">
        Temel Metrikler
      </h2>
      <div className="grid grid-cols-2 gap-4">
        <div>
          <p className="text-sm text-gray-500">Toplam Kullanıcı</p>
          <p className="text-2xl font-bold text-blue-600">
            {metrics.totalUsers.toLocaleString("tr-TR")}
          </p>
        </div>
        <div>
          <p className="text-sm text-gray-500">Aktif Oturum</p>
          <p className="text-2xl font-bold text-green-600">
            {metrics.activeSessions.toLocaleString("tr-TR")}
          </p>
        </div>
        <div>
          <p className="text-sm text-gray-500">Hata Oranı</p>
          <p className="text-2xl font-bold text-red-600">
            %{metrics.errorRate}
          </p>
        </div>
        <div>
          <p className="text-sm text-gray-500">Yanıt Süresi</p>
          <p className="text-2xl font-bold text-purple-600">
            {metrics.responseTime}ms
          </p>
        </div>
      </div>
    </div>
  );
}
// app/dashboard/@activity/page.tsx

import { getRecentActivity } from "@/lib/data";

export default async function ActivityPanel() {
  const activities = await getRecentActivity();

  return (
    <div className="rounded-xl bg-white p-6 shadow-sm">
      <h2 className="mb-4 text-lg font-semibold text-gray-800">
        Son Aktiviteler
      </h2>
      <ul className="space-y-3">
        {activities.map((item) => (
          <li key={item.id} className="flex items-start gap-3">
            <span className="mt-1 h-2 w-2 rounded-full bg-blue-500" />
            <div>
              <p className="text-sm font-medium text-gray-700">
                {item.description}
              </p>
              <p className="text-xs text-gray-400">{item.timeAgo}</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}
// app/dashboard/@revenue/page.tsx

import { getRevenue } from "@/lib/data";

export default async function RevenuePanel() {
  const revenue = await getRevenue();

  return (
    <div className="rounded-xl bg-white p-6 shadow-sm">
      <h2 className="mb-4 text-lg font-semibold text-gray-800">
        Gelir Özeti
      </h2>
      <div className="space-y-3">
        <div className="flex items-center justify-between">
          <span className="text-sm text-gray-500">Bu Ay</span>
          <span className="text-lg font-bold text-green-600">
            {revenue.currentMonth.toLocaleString("tr-TR", {
              style: "currency",
              currency: "TRY",
            })}
          </span>
        </div>
        <div className="flex items-center justify-between">
          <span className="text-sm text-gray-500">Geçen Ay</span>
          <span className="text-lg font-semibold text-gray-700">
            {revenue.lastMonth.toLocaleString("tr-TR", {
              style: "currency",
              currency: "TRY",
            })}
          </span>
        </div>
        <div className="flex items-center justify-between border-t pt-3">
          <span className="text-sm text-gray-500">Değişim</span>
          <span className={`text-lg font-bold ${
            revenue.changePercent >= 0 ? "text-green-600" : "text-red-600"
          }`}>
            {revenue.changePercent >= 0 ? "+" : ""}{revenue.changePercent}%
          </span>
        </div>
      </div>
    </div>
  );
}

Bu örnekte her panel bir Server Component olarak sunucuda render ediliyor ve kendi verisini bağımsız olarak çekiyor. Bir panelin verisi geç gelse bile diğerleri anında görüntülenir. Middleware yazımızda ele aldığımız Edge Runtime ile birleştirildiğinde inanılmaz hızlı dashboard deneyimleri oluşturabilirsiniz.

Koşullu Render ve Rol Tabanlı Erişim

Paralel rotaların bir diğer güçlü kullanım alanı da kullanıcı rolüne göre farklı içeriklerin gösterilmesi. Layout içinde oturum bilgisini kontrol edip slotları koşullu olarak render edebilirsiniz. Server Actions yazımızda ele aldığımız kimlik doğrulama mantığını burada da uygulamak mümkün.

// app/dashboard/layout.tsx

import { auth } from "@/lib/auth";

export default async function DashboardLayout({
  children,
  metrics,
  activity,
  revenue,
}: {
  children: React.ReactNode;
  metrics: React.ReactNode;
  activity: React.ReactNode;
  revenue: React.ReactNode;
}) {
  const session = await auth();
  const isAdmin = session?.user?.role === "admin";

  return (
    <div className="min-h-screen bg-gray-50 p-6">
      <div className="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
        {/* Metrikler herkese gösterilir */}
        {metrics}

        {/* Gelir paneli sadece adminlere */}
        {isAdmin && revenue}

        {/* Aktivite paneli herkese */}
        {activity}
      </div>

      <div className="rounded-xl bg-white p-6 shadow-sm">
        {children}
      </div>
    </div>
  );
}

Bu yaklaşım, Middleware yazımızda ele aldığımız rota bazlı korumayı tamamlayan güzel bir katman sunar. Middleware seviyesinde erişimi tamamen engellerken, paralel rota seviyesinde aynı sayfadaki farklı bölümleri rol bazında kontrol edebilirsiniz. İkisi birlikte çalıştığında oldukça sağlam bir yapı ortaya çıkıyor.

Intercepting Routes Nedir?

Intercepting Routes (Rota Yakalama), bir rotaya istemci tarafı navigasyonla gidildiğinde farklı bir içerik göstermenize, ancak aynı URL'ye doğrudan erişildiğinde (hard navigation) tam sayfayı göstermenize olanak tanır. En yaygın kullanımı: modal kalıpları.

Düşünün: Instagram'da bir fotoğrafa tıkladığınızda, feed'in üzerinde bir modal açılır ve URL değişir (/photo/123). Ama bu URL'yi yeni bir sekmede açarsanız, fotoğraf tam sayfa olarak gösterilir. İşte Intercepting Routes tam olarak bu davranışı sağlar. Basit gibi görünüyor ama geleneksel yöntemlerle bunu uygulamaya kalkarsanız... Söyleyeyim, eğlenceli olmuyor.

Rota yakalama, özel bir klasör adlandırma kuralı kullanır. Bu kural, dosya sistemi hiyerarşisine değil rota segmentlerine dayanır ve bu ayrım çok önemlidir:

  • (.)klasör — Aynı seviyedeki rotayı yakalar
  • (..)klasör — Bir üst seviyedeki rotayı yakalar
  • (..)(..)klasör — İki üst seviyedeki rotayı yakalar
  • (...)klasör — Kök (app) seviyesindeki rotayı yakalar

Buradaki "seviye" kelimesine dikkat edin: dosya sistemindeki klasör derinliği değil, URL'deki rota segmentidir. Özellikle route grupları (grup) kullandığınızda kafanız karışabilir çünkü route grupları URL segmenti oluşturmaz ama dosya sisteminde bir klasör katmanı ekler. Birazdan bu konuya tekrar döneceğiz.

Basit bir örnekle açıklayalım:

app/
  feed/
    page.tsx                    ← /feed sayfası
    (.)photo/[id]/
      page.tsx                  ← /feed'den /photo/[id]'ye istemci navigasyonu
                                  yakalanır ve bu dosya render edilir
  photo/[id]/
    page.tsx                    ← /photo/[id]'ye doğrudan erişimde
                                  (hard navigation) bu dosya render edilir

Kullanıcı /feed sayfasındayken bir fotoğraf linkine tıklarsa, (.)photo/[id]/page.tsx render edilir. URL /photo/123 olarak değişir ama kullanıcı hâlâ feed sayfasının layout'u içindedir. Sayfayı yenilerse veya URL'yi doğrudan ziyaret ederse, photo/[id]/page.tsx render edilir ve tam sayfa görünümünü görür.

Modal Kalıbı: Paralel Rotalar + Intercepting Routes

Ve işte asıl sihir burada başlıyor. Paralel rotalar ile intercepting routes birleştirildiğinde, modal kalıplarının dört temel sorununu aynı anda çözmüş oluyorsunuz:

  1. Paylaşılabilir URL: Modal açıkken URL değişir, bu URL paylaşılabilir.
  2. Yenileme davranışı: Sayfa yenilendiğinde modal kapanır ve tam sayfa görünümü gelir (ki bu tam da beklenen davranış).
  3. Geri butonu: Tarayıcının geri butonu modalı kapatır.
  4. İleri butonu: İleri butonu modalı yeniden açar.

Dört soruna dört çözüm, hepsi dosya sistemi konvansiyonuyla. Güzel değil mi?

Instagram tarzı bir fotoğraf galerisi örneği üzerinden adım adım ilerleyelim:

app/
  gallery/
    @modal/
      (.)photo/[id]/
        page.tsx              ← Yakalanan rota: modal olarak gösterilir
      default.tsx             ← Modal kapalıyken null döner
    photo/[id]/
      page.tsx                ← Tam sayfa fotoğraf görünümü
    layout.tsx                ← Modal slotunu render eder
    page.tsx                  ← Galeri grid'i
// app/gallery/layout.tsx

export default function GalleryLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div>
      {children}
      {modal}
    </div>
  );
}
// app/gallery/@modal/default.tsx

export default function ModalDefault() {
  // Modal kapalıyken hiçbir şey gösterme
  return null;
}
// app/gallery/page.tsx

import Link from "next/link";
import Image from "next/image";
import { getPhotos } from "@/lib/data";

export default async function GalleryPage() {
  const photos = await getPhotos();

  return (
    <div className="mx-auto max-w-6xl p-6">
      <h1 className="mb-8 text-3xl font-bold">Fotoğraf Galerisi</h1>
      <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
        {photos.map((photo) => (
          <Link
            key={photo.id}
            href={`/gallery/photo/${photo.id}`}
            className="group relative aspect-square overflow-hidden rounded-lg"
          >
            <Image
              src={photo.thumbnailUrl}
              alt={photo.title}
              fill
              className="object-cover transition-transform group-hover:scale-105"
              sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
            />
          </Link>
        ))}
      </div>
    </div>
  );
}
// app/gallery/@modal/(.)photo/[id]/page.tsx
// Bu dosya, istemci tarafı navigasyonla /gallery/photo/[id]'ye
// gidildiğinde modal olarak render edilir

import { getPhoto } from "@/lib/data";
import { ModalOverlay } from "@/components/modal-overlay";
import Image from "next/image";

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <ModalOverlay>
      <div className="relative mx-auto max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl">
        <div className="relative aspect-video">
          <Image
            src={photo.url}
            alt={photo.title}
            fill
            className="object-contain"
            priority
          />
        </div>
        <div className="p-6">
          <h2 className="text-xl font-bold text-gray-900">{photo.title}</h2>
          <p className="mt-2 text-gray-600">{photo.description}</p>
          <p className="mt-4 text-sm text-gray-400">
            {photo.author} tarafından &middot; {photo.date}
          </p>
        </div>
      </div>
    </ModalOverlay>
  );
}
// components/modal-overlay.tsx
"use client";

import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef } from "react";

export function ModalOverlay({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const overlayRef = useRef<HTMLDivElement>(null);

  const handleClose = useCallback(() => {
    router.back();
  }, [router]);

  // ESC tuşu ile kapatma
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") handleClose();
    };
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [handleClose]);

  // Overlay'e tıklama ile kapatma
  const handleOverlayClick = (e: React.MouseEvent) => {
    if (e.target === overlayRef.current) handleClose();
  };

  return (
    <div
      ref={overlayRef}
      onClick={handleOverlayClick}
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
      role="dialog"
      aria-modal="true"
    >
      <button
        onClick={handleClose}
        className="absolute right-4 top-4 rounded-full bg-white/20 p-2 text-white hover:bg-white/30"
        aria-label="Kapat"
      >
        &times;
      </button>
      {children}
    </div>
  );
}
// app/gallery/photo/[id]/page.tsx
// Bu dosya, URL'ye doğrudan erişildiğinde (hard navigation)
// tam sayfa olarak render edilir

import { getPhoto } from "@/lib/data";
import Image from "next/image";
import Link from "next/link";

export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <div className="mx-auto max-w-4xl p-6">
      <Link
        href="/gallery"
        className="mb-6 inline-flex items-center text-blue-600 hover:underline"
      >
        &larr; Galeriye Dön
      </Link>

      <div className="overflow-hidden rounded-2xl bg-white shadow-lg">
        <div className="relative aspect-video">
          <Image
            src={photo.url}
            alt={photo.title}
            fill
            className="object-contain"
            priority
          />
        </div>
        <div className="p-8">
          <h1 className="text-3xl font-bold text-gray-900">{photo.title}</h1>
          <p className="mt-4 text-lg text-gray-600">{photo.description}</p>
          <div className="mt-6 border-t pt-6">
            <p className="text-gray-500">
              Fotoğrafçı: <strong>{photo.author}</strong>
            </p>
            <p className="text-gray-500">Tarih: {photo.date}</p>
          </div>
        </div>
      </div>
    </div>
  );
}

Dikkat ettiyseniz, fotoğraf verisi hem modal versiyonunda hem de tam sayfa versiyonunda sunucuda çekiliyor. RSC yazımızda anlattığımız gibi, Server Component'ler sayesinde bu veri çekme işlemi sıfır istemci JavaScript'i ile gerçekleşir. Oldukça şık.

E-Ticaret Ürün Detay Modalı

Fotoğraf galerisi örneğini bir adım ileriye taşıyalım. Bu sefer gerçek bir e-ticaret senaryosu: ürün listesi ve ürün detay modalı. Kullanıcı ürüne tıkladığında modal açılır, URL değişir. URL'yi paylaştığında karşı taraf tam ürün sayfasını görür.

app/
  products/
    @modal/
      (.)detail/[slug]/
        page.tsx
      default.tsx
    detail/[slug]/
      page.tsx
    layout.tsx
    page.tsx
// app/products/page.tsx

import Link from "next/link";
import Image from "next/image";
import { getProducts } from "@/lib/data";

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div className="mx-auto max-w-7xl p-6">
      <h1 className="mb-8 text-3xl font-bold">Ürünler</h1>
      <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
        {products.map((product) => (
          <Link
            key={product.slug}
            href={`/products/detail/${product.slug}`}
            className="group overflow-hidden rounded-xl border bg-white shadow-sm transition-shadow hover:shadow-md"
          >
            <div className="relative aspect-square">
              <Image
                src={product.image}
                alt={product.name}
                fill
                className="object-cover"
              />
            </div>
            <div className="p-4">
              <h2 className="font-semibold text-gray-900 group-hover:text-blue-600">
                {product.name}
              </h2>
              <p className="mt-1 text-lg font-bold text-green-700">
                {product.price.toLocaleString("tr-TR", {
                  style: "currency",
                  currency: "TRY",
                })}
              </p>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}
// app/products/@modal/(.)detail/[slug]/page.tsx

import { getProduct } from "@/lib/data";
import { ModalOverlay } from "@/components/modal-overlay";
import { AddToCartButton } from "@/components/add-to-cart-button";
import Image from "next/image";

export default async function ProductModal({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const product = await getProduct(slug);

  return (
    <ModalOverlay>
      <div className="mx-auto max-w-2xl overflow-hidden rounded-2xl bg-white shadow-2xl">
        <div className="grid grid-cols-1 md:grid-cols-2">
          <div className="relative aspect-square">
            <Image
              src={product.image}
              alt={product.name}
              fill
              className="object-cover"
            />
          </div>
          <div className="flex flex-col justify-between p-6">
            <div>
              <h2 className="text-2xl font-bold text-gray-900">
                {product.name}
              </h2>
              <p className="mt-2 text-sm text-gray-600">
                {product.shortDescription}
              </p>
              <p className="mt-4 text-3xl font-bold text-green-700">
                {product.price.toLocaleString("tr-TR", {
                  style: "currency",
                  currency: "TRY",
                })}
              </p>
            </div>
            <div className="mt-6 space-y-3">
              <AddToCartButton productId={product.id} />
              <a
                href={`/products/detail/${product.slug}`}
                className="block text-center text-sm text-blue-600 hover:underline"
              >
                Tam ürün sayfasını gör &rarr;
              </a>
            </div>
          </div>
        </div>
      </div>
    </ModalOverlay>
  );
}

Bu kalıp, Server Actions yazımızda anlattığımız AddToCartButton gibi form işlemleriyle de mükemmel çalışır. Modal içindeki "Sepete Ekle" butonu bir Server Action tetikler, Zod ile validasyon yapar ve modalı kapatmadan sonucu gösterebilir. Kullanıcı deneyimi açısından düşünürseniz, akış kesintisiz devam ediyor.

Oturum Açma Modalı

Bir diğer çok yaygın kalıp da oturum açma modalı. Kullanıcı herhangi bir sayfadan "Giriş Yap" butonuna tıkladığında, sayfadan ayrılmadan bir modal açılır. URL /login olarak değişir. Eğer kullanıcı bu URL'yi doğrudan ziyaret ederse, tam sayfa giriş formunu görür.

app/
  (main)/
    @auth/
      (.)login/
        page.tsx              ← Modal giriş formu
      default.tsx
    layout.tsx
    page.tsx
  login/
    page.tsx                  ← Tam sayfa giriş formu
// app/(main)/@auth/(.)login/page.tsx

import { ModalOverlay } from "@/components/modal-overlay";
import { LoginForm } from "@/components/login-form";

export default function LoginModal() {
  return (
    <ModalOverlay>
      <div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-2xl">
        <h2 className="mb-6 text-2xl font-bold text-gray-900">
          Giriş Yap
        </h2>
        <LoginForm />
      </div>
    </ModalOverlay>
  );
}
// components/login-form.tsx
"use client";

import { useActionState } from "react";
import { loginAction } from "@/app/actions/auth";

export function LoginForm() {
  const [state, formAction, isPending] = useActionState(loginAction, null);

  return (
    <form action={formAction} className="space-y-4">
      {state?.error && (
        <div className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
          {state.error}
        </div>
      )}
      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">
          E-posta
        </label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
        />
      </div>
      <div>
        <label htmlFor="password" className="block text-sm font-medium text-gray-700">
          Şifre
        </label>
        <input
          id="password"
          name="password"
          type="password"
          required
          className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
        />
      </div>
      <button
        type="submit"
        disabled={isPending}
        className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? "Giriş yapılıyor..." : "Giriş Yap"}
      </button>
    </form>
  );
}

Burada Server Actions yazımızda detaylıca ele aldığımız useActionState hook'unu kullanıyoruz. Form validasyonu, hata yönetimi ve yükleme durumu — hepsi Server Action altyapısı üzerinden yönetiliyor. Ekstra bir state management kütüphanesine gerek yok.

Modalı Kapatma Stratejileri

Modalları kapatmak göründüğü kadar basit bir konu değil aslında. Birkaç farklı strateji var ve her birinin farklı kullanım alanları bulunuyor. Hadi tek tek bakalım.

1. router.back() ile kapatma: En yaygın ve en doğal yöntem. Tarayıcı geçmişinde bir adım geri gider, bu da modalın açılmadan önceki duruma dönülmesini sağlar. Yukarıdaki ModalOverlay bileşeninde bu yöntemi kullandık.

2. Link ile programatik yönlendirme: Bazı durumlarda modalı kapatıp belirli bir sayfaya gitmek isteyebilirsiniz. Ama burada dikkat etmeniz gereken önemli bir detay var:

// DİKKAT: Bu, geçmişe yeni bir giriş ekler
<Link href="/gallery">Kapat</Link>

// Bu, mevcut geçmiş girişini değiştirir (daha iyi)
<Link href="/gallery" replace>Kapat</Link>

replace prop'unu kullanmazsanız, kullanıcı geri butonuna bastığında modal tekrar açılır. Ve bu sonsuz bir döngüye yol açabilir — kullanıcı kapat-geri-kapat-geri yapıp durur. replace ile mevcut geçmiş girişini değiştirirsiniz, böylece geri butonu beklenen şekilde çalışır.

3. router.push() ile programatik yönlendirme: Benzer şekilde, router.push() yerine router.replace() kullanmayı tercih edin:

"use client";

import { useRouter } from "next/navigation";

export function CloseModalButton() {
  const router = useRouter();

  const handleClose = () => {
    // Geçmişte yeni giriş oluşturmadan yönlendir
    router.replace("/gallery");
  };

  return (
    <button onClick={handleClose} className="text-gray-500 hover:text-gray-700">
      Kapat
    </button>
  );
}

4. Catch-all route ile temizleme: Bazı karmaşık senaryolarda, modalı kapattıktan sonra slotun temizlenmesi gerekebilir. Catch-all default route bu durumda kurtarıcınız olur:

// app/gallery/@modal/[...catchAll]/page.tsx

export default function CatchAllModal() {
  return null;
}

Bu dosya, @modal slotuna eşleşmeyen herhangi bir rota için devreye girer ve modalın kapanmasını sağlar. Küçük bir dosya ama büyük iş görür.

İleri Düzey Kalıplar

Temel kalıpları öğrendiğimize göre, şimdi biraz daha ileri seviye senaryolara geçelim.

Çok Adımlı Form Modalları: Intercepting routes ile açılan bir modal içinde çok adımlı bir form akışı oluşturabilirsiniz. Her adım, modal içindeki farklı bir bileşen olarak render edilir. Checkout akışları için biçilmiş kaftan:

// app/checkout/@modal/(.)payment/page.tsx
"use client";

import { useState } from "react";
import { ModalOverlay } from "@/components/modal-overlay";
import { AddressStep } from "./steps/address";
import { PaymentStep } from "./steps/payment";
import { ConfirmStep } from "./steps/confirm";

const STEPS = ["address", "payment", "confirm"] as const;

export default function PaymentModal() {
  const [currentStep, setCurrentStep] = useState<typeof STEPS[number]>("address");

  return (
    <ModalOverlay>
      <div className="w-full max-w-lg rounded-2xl bg-white p-8">
        {/* Adım göstergesi */}
        <div className="mb-8 flex items-center justify-between">
          {STEPS.map((step, index) => (
            <div
              key={step}
              className={`flex items-center gap-2 ${
                STEPS.indexOf(currentStep) >= index
                  ? "text-blue-600"
                  : "text-gray-400"
              }`}
            >
              <span className="flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-semibold">
                {index + 1}
              </span>
            </div>
          ))}
        </div>

        {currentStep === "address" && (
          <AddressStep onNext={() => setCurrentStep("payment")} />
        )}
        {currentStep === "payment" && (
          <PaymentStep onNext={() => setCurrentStep("confirm")} />
        )}
        {currentStep === "confirm" && <ConfirmStep />}
      </div>
    </ModalOverlay>
  );
}

Bildirim Paneli Olarak Paralel Rota: Sayfanın sağ tarafında sürekli açık olan veya kaydırarak açılıp kapanan bir bildirim paneli, paralel rota olarak modellenebilir:

// app/dashboard/@notifications/page.tsx

import { getNotifications } from "@/lib/data";
import { NotificationList } from "@/components/notification-list";

export default async function NotificationsSlot() {
  const notifications = await getNotifications();

  return (
    <aside className="fixed right-0 top-0 h-full w-80 overflow-y-auto border-l bg-white p-4 shadow-lg">
      <h2 className="mb-4 text-lg font-semibold">Bildirimler</h2>
      <NotificationList items={notifications} />
    </aside>
  );
}

Bu slotun bağımsız bir loading.tsx dosyası olduğu için ana içerik anında yüklenirken bildirimler arka planda yüklenir. Kullanıcı beklemek zorunda kalmaz.

Sekme Bazlı Navigasyon: Paralel rotalar, sekme tabanlı arayüzler için de kullanılabilir. Her sekme bir slot olarak tanımlanır ve layout içinde hangi sekmenin aktif olduğuna göre render edilir:

// app/settings/layout.tsx
"use client";

import { usePathname } from "next/navigation";
import Link from "next/link";

export default function SettingsLayout({
  children,
  profile,
  security,
  billing,
}: {
  children: React.ReactNode;
  profile: React.ReactNode;
  security: React.ReactNode;
  billing: React.ReactNode;
}) {
  const pathname = usePathname();
  const tabs = [
    { label: "Profil", href: "/settings", slot: "profile" },
    { label: "Güvenlik", href: "/settings/security", slot: "security" },
    { label: "Faturalandırma", href: "/settings/billing", slot: "billing" },
  ];

  return (
    <div className="mx-auto max-w-4xl p-6">
      <nav className="mb-6 flex gap-1 border-b">
        {tabs.map((tab) => (
          <Link
            key={tab.href}
            href={tab.href}
            className={`px-4 py-2 text-sm font-medium transition-colors ${
              pathname === tab.href
                ? "border-b-2 border-blue-600 text-blue-600"
                : "text-gray-500 hover:text-gray-700"
            }`}
          >
            {tab.label}
          </Link>
        ))}
      </nav>
      {children}
    </div>
  );
}

Yaygın Hatalar ve Çözümleri

Paralel rotalar ve intercepting routes ile çalışırken en sık karşılaşılan hataları ve çözümlerini bir araya getirdim. Bu listeyi bir kenara kaydedin, ciddi zaman kazandırır.

1. default.tsx'i unutmak: Bunu zaten detaylıca anlattık ama tekrar vurgulayayım. Geliştirme ortamında soft navigation kullandığınız için hata görmezsiniz. Üretim ortamında kullanıcı sayfayı yenilediğinde veya URL'yi doğrudan ziyaret ettiğinde 404 alır. Her slot için mutlaka default.tsx oluşturun. Her zaman.

2. (..) konvansiyonunun dosya sistemi yerine rota segmentlerine dayanması: Bu en kafa karıştırıcı konulardan biri, açıkçası. Route grupları (groupName) dosya sisteminde bir klasör katmanı ekler ama URL'de segment oluşturmaz. Bu yüzden (..) kullanırken dosya sistemi derinliğine değil, URL segment derinliğine bakmanız gerekir.

// YANLIŞ anlama
app/
  (shop)/              ← Bu URL segmenti DEĞİL
    products/
      @modal/
        (..)detail/    ← (..) dosya sisteminde bir üst = (shop) ama
                       ← rota segmentinde bir üst = app kökü

// DOĞRU anlama - rota segmentlerine bakın:
// /products altındayız, (..) bizi / (kök) seviyesine çıkarır

3. Slot adlarında tire (-) kullanmak: Slot adları JavaScript prop adı kurallarına uymalıdır. @user-profile gibi bir slot adı, layout'ta prop olarak kullanıldığında sorun çıkarır. Bunun yerine @userProfile veya @user_profile kullanın. Ama en iyi uygulama? Basit ve kısa isimler tercih edin: @profile, @modal, @sidebar.

4. Route grupları ile intercepting routes'u birlikte kullanırken yanlış seviye hesaplama: Route grupları URL'de segment oluşturmaz ama dosya sisteminde klasör oluşturur. Intercepting routes'un (..) konvansiyonu rota segmentlerine dayandığı için route gruplarını hesaba katmamanız gerekir. Bu nokta gerçekten sık karışıklığa neden olur, dikkatli olun.

5. Hard refresh sonrası modal davranışı: Intercepting routes, sadece istemci tarafı navigasyonda çalışır. Sayfayı yenilediğinizde yakalama gerçekleşmez ve tam sayfa versiyonu gösterilir. Bu aslında beklenen ve doğru davranıştır — bir bug değil. Ancak tam sayfa versiyonunu (photo/[id]/page.tsx) oluşturmayı unutursanız kullanıcı boş bir sayfa görür.

6. Birden fazla modal için slot karışıklığı: Aynı layout'ta birden fazla modal tipi kullanmak isteyebilirsiniz. Her biri için ayrı bir slot oluşturabilirsiniz, ancak bu karmaşıklığı artırır. Daha pratik bir alternatif olarak tek bir @modal slotu kullanıp içinde farklı rotalar tanımlayabilirsiniz:

app/
  @modal/
    (.)photo/[id]/page.tsx    ← Fotoğraf modalı
    (.)login/page.tsx          ← Giriş modalı
    (.)share/[id]/page.tsx     ← Paylaşım modalı
    default.tsx

Performans ve En İyi Uygulamalar

Paralel rotalar ve intercepting routes kullanırken performans ve bakım kolaylığı açısından dikkat etmeniz gereken noktaları derleyelim.

Her slot için default.tsx oluşturun: Evet, bunu üçüncü kez söylüyorum. Çünkü bu dosya olmadan hard navigation sonrası uygulamanız kırılır. İçeriği basitçe return null; bile olabilir, ama dosya mutlaka var olmalıdır.

Ağır slotlar için streaming kullanın: RSC yazımızda ele aldığımız Suspense ve streaming kalıplarını paralel rotalarda da uygulayın. Her slotun kendi loading.tsx dosyası olsun. Bu sayede yavaş veri kaynakları diğer panelleri engellemez.

// app/dashboard/@analytics/loading.tsx

export default function AnalyticsLoading() {
  return (
    <div className="rounded-xl bg-white p-6 shadow-sm">
      <div className="animate-pulse space-y-4">
        <div className="h-4 w-1/3 rounded bg-gray-200" />
        <div className="grid grid-cols-2 gap-4">
          {Array.from({ length: 4 }).map((_, i) => (
            <div key={i} className="space-y-2">
              <div className="h-3 w-20 rounded bg-gray-200" />
              <div className="h-6 w-16 rounded bg-gray-200" />
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Modal ve tam sayfa içeriğini senkronize tutun: Intercepting route ile gösterilen modal içeriğiyle, tam sayfa versiyonu arasında tutarsızlık olmamasına dikkat edin. İdeal olarak, her iki bileşen de aynı veri çekme fonksiyonunu kullanmalıdır. Ortak bileşenleri paylaşılan bir modüle çıkarmak bu konuda işinizi kolaylaştırır:

// components/product-details.tsx
// Bu bileşen hem modal hem de tam sayfa tarafından kullanılır

export function ProductDetails({
  product,
  variant = "full",
}: {
  product: Product;
  variant?: "modal" | "full";
}) {
  return (
    <div className={variant === "modal" ? "p-4" : "p-8"}>
      <h2 className={variant === "modal" ? "text-xl" : "text-3xl"}>
        {product.name}
      </h2>
      <p className="mt-2 text-gray-600">
        {variant === "modal" ? product.shortDescription : product.fullDescription}
      </p>
      {/* ... */}
    </div>
  );
}

Hard refresh ile test edin: Geliştirme sürecinde her yeni rota eklediğinizde sayfayı yenileyin. Soft navigation'da çalışıp hard navigation'da kırılan durumları erken yakalamak çok önemli. Tarayıcınızda "Disable cache" seçeneğini açık tutun ve sık sık Cmd+Shift+R (veya Ctrl+Shift+R) yapın. Bu alışkanlık sizi çok fazla baş ağrısından kurtarır.

Erişilebilirlik (a11y) konusuna dikkat edin: Modal açıldığında klavye fokusunun modal içine taşınması, modal kapandığında eski konumuna dönmesi gerekir. role="dialog" ve aria-modal="true" niteliklerini eklemeyi unutmayın. ESC tuşu ile kapatma desteği sağlayın. Yukarıdaki ModalOverlay bileşeninde bu pratiklerin çoğunu zaten uyguladık.

Scroll kilitleme: Modal açıkken arka plandaki sayfanın kaydırılmasını engellemek için body elementine overflow: hidden ekleyin. Bunu bir useEffect ile kolayca yönetebilirsiniz:

// Modal açıldığında body scroll'u kilitle
useEffect(() => {
  const originalOverflow = document.body.style.overflow;
  document.body.style.overflow = "hidden";

  return () => {
    document.body.style.overflow = originalOverflow;
  };
}, []);

SEO için meta verileri unutmayın: Intercepting routes ile yakalanan modal sayfalarının da kendi generateMetadata fonksiyonları olmalı. Arama motorları hard navigation ile sayfaya eriştiğinde doğru meta verileri görmeli:

// app/gallery/photo/[id]/page.tsx

import type { Metadata } from "next";
import { getPhoto } from "@/lib/data";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;
  const photo = await getPhoto(id);

  return {
    title: `${photo.title} | Galeri`,
    description: photo.description,
    openGraph: {
      images: [{ url: photo.url }],
    },
  };
}

Sonuç

Bu yazıyla birlikte Next.js App Router serimizin dört temel yapı taşını tamamlamış olduk. React Server Components ile sunucu tarafında veri çekmenin temellerini attık. Server Actions ile güvenli, Zod validasyonlu form işlemlerini öğrendik. Middleware ile Edge Runtime'da kimlik doğrulama ve yönlendirme kalıplarını uyguladık. Ve şimdi Paralel Rotalar ile Intercepting Routes sayesinde, kullanıcı deneyimini bir üst seviyeye taşıyan ileri düzey düzen kalıplarını keşfettik.

Bu dört özelliği birlikte kullandığınızda neler yapabileceğinizi bir düşünün: RSC ile sunucuda veri çeken, Server Actions ile güvenli form işlemleri yapan, Middleware ile rota bazlı erişim kontrolü uygulayan ve Paralel Rotalar ile çok panelli, modal destekli, rol tabanlı bir dashboard. Tüm bunlar, sıfır istemci tarafı state yönetimi kütüphanesiyle, tamamen Next.js'in yerleşik araçlarıyla mümkün. Dışarıdan bir kütüphane bile gerekmiyor.

Paralel rotaları kullanarak dashboard panellerini bağımsız birimler olarak tasarlayın; her biri kendi yükleme, hata ve veri çekme durumlarına sahip olsun. Intercepting routes ile Instagram tarzı modaller, e-ticaret ürün detay modalları ve oturum açma formları oluşturun. Bu iki özelliğin birleşimi, daha önce karmaşık state yönetimi gerektiren kalıpları deklaratif, dosya sistemi tabanlı bir yapıya dönüştürür.

Unutmayın: default.tsx dosyalarını her slot için oluşturun, hard refresh ile düzenli test edin ve rota yakalama konvansiyonlarını dosya sistemi değil rota segmentleri üzerinden düşünün. Bu üç kural, en yaygın hataların büyük çoğunluğunu önleyecektir.

Bir sonraki yazıda bu teknikleri canlı bir proje üzerinde birleştirerek sıfırdan bir uygulama geliştirme sürecini ele almayı planlıyoruz. Şimdilik, öğrendiklerinizi kendi projelerinizde denemeye başlayın — küçük bir deneme projesi oluşturun, bir dashboard ve birkaç modal ekleyin, hard refresh ile test edin. En iyi öğrenme yöntemi her zaman olduğu gibi deneyerek öğrenmektir.

Yazar Hakkında Editorial Team

Our team of expert writers and editors.