Sejak App Router diperkenalkan, Next.js memberikan dua primitif routing yang — menurut saya pribadi — benar-benar mengubah cara kita membangun antarmuka yang kompleks: Parallel Routes dan Intercepting Routes. Di Next.js 16, keduanya semakin matang berkat integrasi dengan Cache Components, React 19, dan model streaming yang baru. Masalahnya? Dokumentasi resmi cuma menyentuh permukaan saja. Banyak developer Indonesia (saya termasuk, dulu) yang bingung kapan harus pakai @slot, bagaimana sih default.tsx itu bekerja, dan kenapa modal yang dibuat dengan intercepting routes kadang "bocor" begitu halaman di-refresh.
Jadi, artikel ini adalah panduan praktis berbasis skenario nyata: membangun dashboard multi-panel, modal galeri foto ala Instagram, sistem tab dengan URL terpisah, sampai pola lanjutan seperti conditional parallel routes dan authentication gating per slot. Semua contoh kode sudah disesuaikan dengan Next.js 16, React 19, dan konvensi terbaru per April 2026.
Apa Itu Parallel Routes?
Parallel Routes memungkinkan Anda me-render dua atau lebih halaman secara simultan di dalam satu layout yang sama. Berbeda dari children biasa yang cuma punya satu slot, parallel routes mendefinisikan banyak slot dengan konvensi folder bernama @namaSlot.
Secara teknis, setiap slot itu independen — punya loading state, error boundary, dan data fetching sendiri. Ini sangat berguna untuk UI yang harus menampilkan beberapa "area" sekaligus. Contohnya? Dashboard dengan panel analitik plus panel feed, inbox email dengan list plus preview, atau halaman sosial media dengan timeline plus notifikasi.
Struktur Folder Dasar
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│ ├── default.tsx
│ └── page.tsx
└── @team/
├── default.tsx
└── page.tsx
Dengan struktur di atas, layout akan menerima tiga props: children, analytics, dan team. Berikut contoh layout yang mengkomposisi ketiganya:
// app/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<section className="grid grid-cols-[1fr_320px] gap-6 p-6">
<div className="space-y-6">
{children}
{analytics}
</div>
<aside>{team}</aside>
</section>
)
}
Setiap slot merender halamannya sendiri secara paralel. Next.js 16 akan men-stream ketiganya bersamaan via React Suspense, jadi slot yang datanya sudah siap tidak akan terblokir oleh slot yang lambat. Inilah salah satu alasan kenapa pola ini terasa cepat di produksi.
Mengapa default.tsx Wajib Ada?
Ini pertanyaan yang paling sering muncul di Stack Overflow Indonesia — dan jujur, saya juga kena jebakan ini pertama kali. Jawabannya terkait bagaimana soft navigation bekerja: saat pengguna berpindah ke URL yang hanya mengubah satu slot, slot lain tetap menggunakan state terakhir yang aktif. Tapi begitu terjadi hard navigation (refresh halaman, akses URL langsung, atau navigasi dari luar aplikasi), Next.js kehilangan state slot sebelumnya.
Kalau tidak ada default.tsx, Next.js akan mengembalikan 404 untuk slot yang tidak memiliki match untuk URL saat ini. Aturan sederhananya begini:
- Buat
default.tsxdi setiap slot untuk menangani kasus "slot ini belum punya konten untuk URL ini". - Isi
default.tsxbisa berupanull, skeleton, atau fallback UI sederhana. - Kalau Anda ingin slot ikut ter-reset saat hard navigation, buat
default.tsxyang mengembalikan konten default.
// app/@analytics/default.tsx
export default function Default() {
return null // atau <AnalyticsSkeleton />
}
Apa Itu Intercepting Routes?
Intercepting Routes adalah fitur yang memungkinkan Anda menampilkan route berbeda di dalam konteks yang sama saat navigasi terjadi dari klien. Use case paling populer? Modal galeri foto seperti di Instagram atau Twitter — klik foto membuka modal overlay, tapi kalau URL-nya di-akses langsung, halaman penuh tetap muncul.
Konvensinya menggunakan kurung dengan titik:
(.)— intercept di level yang sama.(..)— intercept satu level di atas.(..)(..)— intercept dua level di atas.(...)— intercept dari rootapp/.
Catatan penting (dan ini sering bikin pusing): level di sini bukan level folder filesystem, melainkan level segmen route. Inilah sumber bug yang paling sering saya temui di code review.
Studi Kasus 1: Modal Galeri Foto dengan URL yang Addressable
Oke, mari kita langsung praktik. Kita bangun galeri foto di mana klik thumbnail membuka modal, tapi URL /photo/42 tetap bisa diakses langsung sebagai halaman penuh.
Struktur Folder
app/
├── layout.tsx
├── page.tsx // galeri grid
├── @modal/
│ ├── default.tsx // null
│ └── (.)photo/
│ └── [id]/
│ └── page.tsx // modal overlay
└── photo/
└── [id]/
└── page.tsx // halaman penuh
Root Layout
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html lang="id">
<body>
{children}
{modal}
<div id="modal-root" />
</body>
</html>
)
}
Komponen Modal
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/modal'
import { PhotoView } from '@/components/photo-view'
import { getPhoto } from '@/lib/photos'
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<Modal>
<PhotoView photo={photo} />
</Modal>
)
}
Komponen Modal Client
// components/modal.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
dialogRef.current?.showModal()
}, [])
function onDismiss() {
router.back()
}
return createPortal(
<dialog
ref={dialogRef}
className="backdrop:bg-black/60 rounded-xl p-0"
onClose={onDismiss}
>
{children}
<button onClick={onDismiss} className="absolute top-2 right-2">
Tutup
</button>
</dialog>,
document.getElementById('modal-root')!
)
}
Dengan struktur di atas:
- Klik thumbnail dari galeri
/→ intercept aktif → modal muncul tanpa meninggalkan grid. - Paste URL
/photo/42langsung → halaman penuh tampil. - Tombol back browser → modal tertutup dan kembali ke grid.
- Share link → penerima melihat halaman penuh (jadi SEO-friendly).
Studi Kasus 2: Dashboard dengan Tab Paralel
Skenario lain yang sering saya temui di project klien: dashboard admin di mana ada panel "Overview", "Revenue", dan "Users" yang masing-masing bisa di-refresh independen. Dengan parallel routes, setiap tab punya loading state sendiri — jadi satu query lambat tidak akan memblokir yang lain.
app/dashboard/
├── layout.tsx
├── page.tsx
├── @revenue/
│ ├── loading.tsx
│ ├── error.tsx
│ ├── default.tsx
│ └── page.tsx
└── @users/
├── loading.tsx
├── error.tsx
├── default.tsx
└── page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
revenue,
users,
}: {
children: React.ReactNode
revenue: React.ReactNode
users: React.ReactNode
}) {
return (
<div className="grid grid-cols-2 gap-4">
<section>{children}</section>
<section>{revenue}</section>
<section className="col-span-2">{users}</section>
</div>
)
}
Bayangkan kalau revenue di-fetch dari BigQuery dan butuh 3 detik, sementara users dari Postgres lokal selesai dalam 100ms — panel users akan tampil duluan. Inilah kekuatan sebenarnya parallel routes: streaming per-slot.
Conditional Parallel Routes: Auth-Gated Slots
Pola lanjutan yang lumayan elegan: menampilkan slot tertentu hanya untuk user yang login. Caranya? Conditional rendering di layout:
// app/layout.tsx
import { getCurrentUser } from '@/lib/auth'
export default async function Layout({
children,
admin,
user,
}: {
children: React.ReactNode
admin: React.ReactNode
user: React.ReactNode
}) {
const currentUser = await getCurrentUser()
const panel = currentUser?.role === 'admin' ? admin : user
return (
<>
{children}
{panel}
</>
)
}
Pola ini mengandalkan Data Access Layer (DAL) yang sudah kita bahas di artikel autentikasi sebelumnya. Ingat, ya — selalu panggil getCurrentUser di server component (bukan di slot itu sendiri) supaya cek otorisasi tidak bisa di-bypass.
Intercepting Routes dan Cache Components
Di Next.js 16, Cache Components mengubah kontrak caching. Saat Anda membuat modal dengan (.), komponen intercept sering kali adalah server component yang fetch data. Supaya tidak ada refetch berulang (yang honestly, cukup sering dilupakan), gunakan directive 'use cache':
// app/@modal/(.)photo/[id]/page.tsx
'use cache'
import { cacheLife } from 'next/cache'
export default async function PhotoModal({ params }) {
cacheLife('hours')
const { id } = await params
const photo = await getPhoto(id)
return <Modal><PhotoView photo={photo} /></Modal>
}
Dengan ini, modal dan halaman penuh bisa berbagi cache yang sama karena keduanya memanggil getPhoto(id) — asalkan fungsi itu juga ditandai 'use cache'.
Error Handling Per-Slot
Salah satu keunggulan Parallel Routes adalah error boundary yang terisolasi. Jadi kalau slot @revenue error, slot @users tetap berjalan normal. Ini sangat membantu UX, terutama di dashboard yang banyak widget.
// app/dashboard/@revenue/error.tsx
'use client'
export default function RevenueError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="rounded border border-red-300 p-4">
<p>Gagal memuat data pendapatan.</p>
<button onClick={reset} className="mt-2 underline">
Coba lagi
</button>
</div>
)
}
Loading States yang Berjenjang
Setiap slot bisa punya loading.tsx sendiri. Next.js 16 akan menampilkannya sebagai fallback Suspense otomatis — tidak perlu setup manual.
// app/dashboard/@users/loading.tsx
export default function UsersLoading() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-10 animate-pulse rounded bg-gray-200" />
))}
</div>
)
}
Pitfall Umum dan Cara Menghindarinya
1. Lupa default.tsx → 404 saat refresh
Gejala: halaman bekerja baik saat navigasi klien, tapi begitu di-refresh malah 404. Solusi: buat default.tsx di setiap slot. Ini aturan non-negotiable. Saya kena ini berkali-kali sebelum akhirnya jadi kebiasaan.
2. Intercepting Route Tidak Aktif Saat Direct URL
Banyak developer mengira (.)photo/[id] akan otomatis jadi modal untuk URL /photo/42. Tidak begitu — intercepting hanya aktif saat navigasi dari klien. Itu sebabnya Anda tetap butuh route photo/[id]/page.tsx yang "asli".
3. Salah Level (..)
Kalau Anda punya /feed dan ingin intercept /photo/[id] dari sana, jangan pakai (..) berdasarkan struktur folder. Hitung berdasarkan segmen route URL. Kadang justru (...) (dari root) yang benar. Nah, ini bagian yang paling sering bikin debugging session jadi lama.
4. State Modal Hilang Saat Back
Solusi: simpan state scroll dan filter grid di URL via searchParams. Jangan mengandalkan state React yang akan di-unmount.
5. Modal Tidak Tertutup di Mobile Safari
Gunakan <dialog> native dengan polyfill kalau perlu. router.back() adalah cara paling konsisten untuk menutup modal karena ia menghormati history browser.
SEO dan Implikasi untuk Sharing Link
Pertanyaan klasik dari tim marketing: "Apakah Google mengindex URL modal saya?" Jawabannya: ya, karena URL /photo/42 merender halaman penuh saat diakses langsung. Inilah yang membuat pola intercepting routes superior dibanding modal state-based — Anda dapat modal UX sekaligus URL yang shareable dan crawlable.
Pastikan halaman penuh photo/[id]/page.tsx memiliki generateMetadata yang proper:
// app/photo/[id]/page.tsx
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return {
title: photo.title,
description: photo.caption,
openGraph: {
images: [photo.url],
},
}
}
Parallel Routes untuk Tab URL-Driven
Pola populer lain: tab yang masing-masing punya URL sendiri tanpa unmount parent. Contoh halaman profil dengan tab "Posts", "Media", dan "Likes".
app/profile/[handle]/
├── layout.tsx
├── @tab/
│ ├── default.tsx // fallback: posts
│ ├── posts/
│ │ └── page.tsx
│ ├── media/
│ │ └── page.tsx
│ └── likes/
│ └── page.tsx
└── page.tsx // header profil
Ini mirip Twitter: URL /profile/alice/media menampilkan header profil plus tab media, tanpa re-render header-nya. Parent route stays mounted, hanya slot yang berubah. Elegan, dan hemat bandwidth.
Performance: Streaming dan Prefetching
Next.js 16 mendukung prefetching otomatis untuk <Link>. Saat user hover thumbnail galeri, modal intercept sudah di-prefetch di background. Kombinasinya dengan Partial Prerendering (PPR) memberikan ilusi instant-load yang — kalau saya jujur — cukup bikin terkesan pertama kali melihatnya live.
Beberapa tips performance yang spesifik:
- Gunakan
prefetch={true}eksplisit untuk link modal yang penting. - Set
cacheLifesesuai dinamisme data —'days'untuk foto statis,'minutes'untuk feed. - Hindari data fetching berlebih di
default.tsx— cukup skeleton saja. - Manfaatkan
loading.tsxper-slot untuk progressive reveal.
Pola Migrasi dari Modal State-Based
Kalau codebase Anda masih pakai Zustand atau Redux untuk state modal, migrasinya bisa bertahap — tidak perlu big bang rewrite:
- Identifikasi modal yang idealnya shareable (produk, foto, detail tiket).
- Buat route penuh
/item/[id]/page.tsxterlebih dahulu. - Tambah slot
@modaldi root layout. - Buat intercepting route
(..)item/[id]yang memanggil komponen yang sama. - Hapus state modal global dan handler-nya.
Manfaatnya? URL jadi bookmark-able, history browser work as expected, dan tim lain bisa langsung link ke item spesifik tanpa ribet copy-paste ID.
Kapan TIDAK Pakai Parallel Routes?
Parallel routes memang powerful — tapi bukan peluru perak. Jangan pakai kalau:
- Anda cuma butuh satu konten per halaman —
childrensudah cukup. - Komponen paralel sangat kopling (saling bergantung state) — lebih baik client component biasa.
- Tim belum terbiasa dengan konvensi — onboarding cost-nya lumayan tinggi.
- SEO crawler harus melihat semua slot sebagai satu dokumen semantik — cek hasil render dulu.
Perbandingan dengan Framework Lain
Remix punya konsep nested routes yang mirip, tapi tidak punya equivalent @slot yang bisa di-render paralel di layout yang sama. SvelteKit baru menambahkan "multiple routes" di versi 2.x, tapi belum sekaya Next.js. Astro dengan Starlight-style multi-content juga tidak punya streaming per-slot.
Di ekosistem React sendiri, Next.js 16 masih jadi yang paling mature untuk pola ini. Dan honestly, inilah salah satu alasan tim-tim besar (Vercel, Notion, Linear) pindah atau bertahan di Next.js.
FAQ
Apakah Parallel Routes menambah bundle size secara signifikan?
Tidak, kok. Setiap slot adalah server component default. Bundle klien cuma bertambah sesuai komponen interaktif di dalamnya. Streaming per-slot justru memperkecil initial JS karena loading states bisa dikirim duluan.
Bisakah saya nested parallel routes?
Bisa. Sebuah slot @foo bisa punya sub-slot @bar di dalamnya. Tapi hati-hati sama kompleksitasnya — setiap nested level butuh default.tsx sendiri-sendiri.
Bagaimana intercepting routes berinteraksi dengan middleware?
Sama seperti route biasa. Middleware (atau proxy.ts di Next.js 16) jalan sebelum intercepting resolution. Jadi auth check dan redirect tetap bekerja normal.
Apakah saya bisa pakai useSearchParams di dalam slot?
Ya, tapi slot tersebut otomatis jadi dynamic. Kalau Anda pakai PPR, pertimbangkan membungkus dengan Suspense agar shell statis tetap bisa di-prerender.
Apa beda @modal dengan @(modal)?
@modal adalah slot parallel routes. (modal) tanpa @ adalah route group untuk organisasi folder tanpa mempengaruhi URL. Keduanya bisa dikombinasi: @modal/(marketing)/promo itu valid.
Kesimpulan
Parallel Routes dan Intercepting Routes adalah dua fitur yang benar-benar membedakan Next.js 16 dari framework React lainnya. Mereka memungkinkan Anda membangun UX kelas premium — modal addressable, dashboard streaming per-panel, tab URL-driven — tanpa state management global yang rumit. Pahami konvensi @slot, disiplin dengan default.tsx, dan hitung level intercept dengan benar. Hasilnya? Aplikasi yang terasa native web, SEO-friendly, dan mudah di-maintain untuk tim.
Di artikel selanjutnya, kita akan bahas Metadata API dan generateMetadata untuk SEO dinamis di Next.js 16 — yang melengkapi pola-pola navigasi yang sudah kita pelajari di sini. Sampai jumpa di sana.