Johdanto
Oletko koskaan miettinyt, miten Instagram tai Twitter näyttää kuvan tai postauksen modaalissa ilman, että taustalla oleva syöte katoaa? Tai miten monimutkainen dashboard voi ladata useita itsenäisiä osioita samanaikaisesti — jokainen omalla lataustilanteellaan? No, Next.js App Routerin kaksi edistyneintä reititysominaisuutta, Parallel Routes (rinnakkaisreitit) ja Intercepting Routes (reitin sieppaus), tekevät juuri tämän mahdolliseksi.
Rehellisesti sanottuna nämä ominaisuudet ovat monelle kehittäjälle App Routerin vaikeimmin ymmärrettävä osa. Tiedostojärjestelmäkonventiot @folder-sloteilla ja (..)-sieppausmerkinnöillä tuntuvat aluksi todella kryptisiltä. Mutta kun jossain vaiheessa tajuat, miten ne oikeasti toimivat, avautuu kokonaan uusi tapa rakentaa käyttöliittymiä — modaalit, joilla on omat URL-osoitteet, dashboardit, joissa jokainen paneeli latautuu itsenäisesti, ja ehdollinen renderöinti käyttäjän tilan perusteella.
Tässä oppaassa rakennamme yhdessä käytännön esimerkkejä alusta loppuun. Aloitamme perusteista ja etenemme kohti monimutkaisempia kuvioita, kuten URL-jaettavia modaaleja ja rooliperusteisia dashboard-näkymiä. Joten, lähdetään liikkeelle.
Mitä ovat Parallel Routes?
Parallel Routes eli rinnakkaisreitit mahdollistavat yhden tai useamman sivun renderöinnin samanaikaisesti samassa layoutissa. Jokainen rinnakkaisreitti toimii itsenäisesti — sillä on oma lataus- ja virhetilansa, ja se voi päivittyä ilman, että muut osat sivua latautuvat uudelleen.
Käytännössä rinnakkaisreitit määritellään slotien avulla. Slot on yksinkertaisesti kansio, jonka nimi alkaa @-merkillä. Next.js välittää slotin sisällön automaattisesti propsina ylätason layoutiin.
Perusesimerkki: Dashboard kahdella paneelilla
Kuvitellaan dashboard, jossa vasemmalla näkyy analytiikka ja oikealla tiimin tiedot. Tiedostorakenne näyttää tältä:
app/
├── dashboard/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── @analytics/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── default.tsx
│ └── @team/
│ ├── page.tsx
│ ├── loading.tsx
│ └── default.tsx
Layout vastaanottaa slotit propseina ja renderöi ne rinnakkain:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 p-6">
<div className="lg:col-span-3">{children}</div>
<div className="lg:col-span-2">{analytics}</div>
<div className="lg:col-span-1">{team}</div>
</div>
);
}
Huomaa, että children on implisiittinen slot — se vastaa dashboard/page.tsx-tiedostoa. Eli käytännössä dashboard/page.tsx on sama asia kuin dashboard/@children/page.tsx.
Itsenäiset lataus- ja virhetilat
Jokainen slot streamataan itsenäisesti. Tämä on oikeasti hieno juttu käytännössä: jos analytiikkaosio lataa dataa hitaasta API:sta, vain sen kohdalla näkyy latausindikaattori — tiimin tiedot ovat jo valmiina näytöllä.
// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
);
}
Vastaavasti virhetilat ovat eristettyjä. Jos analytiikkaosio kaatuu, tiimin tiedot jatkavat toimintaansa ihan normaalisti:
// app/dashboard/@analytics/error.tsx
"use client";
export default function AnalyticsError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="bg-red-50 border border-red-200 rounded p-4">
<p className="text-red-800">Analytiikan lataus epäonnistui.</p>
<button
onClick={reset}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded"
>
Yritä uudelleen
</button>
</div>
);
}
default.tsx — miksi se on pakollinen?
Tämä on se tiedosto, jonka unohtaminen aiheuttaa eniten päänvaivaa. Olen nähnyt tämän virheen niin monta kertaa, että se ansaitsee oman osionsa.
Kun Next.js ei pysty päättelemään slotin aktiivista tilaa URL:n perusteella — esimerkiksi sivun uudelleenlatauksessa tai suorassa navigaatiossa — se etsii default.tsx-tiedostoa.
Jos default.tsx puuttuu, Next.js renderöi 404-sivun.
Ihan oikeasti, tämä on konkreettisesti yleisin syy siihen, miksi rinnakkaisreitit eivät toimi. Yksinkertaisin korjaus on palauttaa null:
// app/dashboard/@analytics/default.tsx
export default function AnalyticsDefault() {
return null;
}
Modaalislotissa tämä on erityisen tärkeää, sillä null-palautusarvo varmistaa, ettei modaalia renderöidä, kun sitä ei ole aktiivisena.
Ehdollinen renderöinti rinnakkaisreiteillä
Yksi ominaisuus, josta en kuule puhuttavan tarpeeksi: rinnakkaisreitit mahdollistavat ehdollisen renderöinnin suoraan layoutissa. Tämä on erittäin hyödyllistä esimerkiksi silloin, kun haluat näyttää eri sisältöä admin-käyttäjille ja tavallisille käyttäjille:
// app/dashboard/layout.tsx
import { auth } from "@/lib/auth";
export default async function DashboardLayout({
children,
admin,
user,
}: {
children: React.ReactNode;
admin: React.ReactNode;
user: React.ReactNode;
}) {
const session = await auth();
const isAdmin = session?.user?.role === "admin";
return (
<main>
{children}
{isAdmin ? admin : user}
</main>
);
}
Tiedostorakenne on suoraviivainen:
app/
├── dashboard/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── @admin/
│ │ ├── page.tsx
│ │ └── default.tsx
│ └── @user/
│ ├── page.tsx
│ └── default.tsx
Nyt admin-käyttäjät näkevät hallintatyökalut, kun taas tavallisille käyttäjille näytetään eri näkymä — kaikki samassa URL-osoitteessa. Aika näppärää.
Mitä ovat Intercepting Routes?
Intercepting Routes eli reitin sieppaus mahdollistaa reitin lataamisen nykyisen layoutin sisällä ilman, että käyttäjä navigoi pois nykyiseltä sivulta. Käytännössä tämä tarkoittaa sitä, että kun käyttäjä klikkaa linkkiä, sisältö näytetään modaalissa nykyisen sivun päällä — mutta jos joku navigoi suoraan samaan URL-osoitteeseen (vaikka jaetun linkin kautta), näytetään koko sivu normaalisti.
Tämä on juuri se malli, jota esimerkiksi Instagram käyttää kuvien katselussa.
Sieppauskonventiot
Next.js käyttää tiedostojärjestelmässä erityismerkintöjä reittien sieppaamiseen. Nämä muistuttavat suhteellisia polkuja, mutta ne perustuvat reittisegmentteihin — eivät tiedostojärjestelmän kansioihin:
(.)— sieppaa saman tason reitin(..)— sieppaa yhden tason ylemmän reitin(..)(..)— sieppaa kaksi tasoa ylemmän reitin(...)— sieppaa reitinapp-juuresta
Tärkeä huomio (ja tämä aiheuttaa helposti sekaannusta): (..)-merkintä viittaa reittisegmentteihin, ei tiedostojärjestelmän kansiorakenteeseen. Reittiryhmät kuten (group) eivät muodosta reittisegmenttiä, joten niitä ei lasketa mukaan tasoa määritettäessä.
Käytännön esimerkki: Tuotegallerian URL-jaettava modaali
Rakennetaan klassinen esimerkki: tuotegalleria, jossa tuotteen klikkaaminen avaa modaalin nykyisen sivun päälle. URL päivittyy samalla, joten modaalin voi jakaa linkkinä. Mutta kun linkin avaa suoraan, näytetään tuotteen koko sivu eikä modaali tyhjän taustan päällä.
Tämä on mielestäni yksi parhaista Next.js App Routerin ominaisuuksista, joten käydään se läpi vaihe vaiheelta.
Vaihe 1: Tiedostorakenne
app/
├── layout.tsx
├── products/
│ ├── page.tsx // Tuotelistaus
│ └── [id]/
│ └── page.tsx // Tuotteen kokosivu (suora navigaatio)
└── @modal/
├── default.tsx // Palauttaa null
└── (.)products/
└── [id]/
└── page.tsx // Modaaliversio tuotteesta
Vaihe 2: Root layout vastaanottaa modal-slotin
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="fi">
<body>
{children}
{modal}
</body>
</html>
);
}
Vaihe 3: Modal-slotin default.tsx
// app/@modal/default.tsx
export default function ModalDefault() {
return null;
}
Muista — ilman tätä tiedostoa mikään ei toimi kunnolla.
Vaihe 4: Tuotelistaus linkittää tuotteisiin
// app/products/page.tsx
import Link from "next/link";
import { getProducts } from "@/lib/data";
export default async function ProductsPage() {
const products = await getProducts();
return (
<main className="p-8">
<h1 className="text-3xl font-bold mb-8">Tuotteet</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.id}`}
className="block border rounded-lg p-4 hover:shadow-lg transition"
>
<img
src={product.imageUrl}
alt={product.name}
className="w-full h-48 object-cover rounded"
/>
<h2 className="mt-2 font-semibold">{product.name}</h2>
<p className="text-gray-600">{product.price} EUR</p>
</Link>
))}
</div>
</main>
);
}
Tärkeää: Sieppaus toimii ainoastaan Next.js:n <Link>-komponentin kanssa. Tavallinen <a>-elementti ei käynnistä sieppausta, joten tämä on helppo virhe tehdä.
Vaihe 5: Modaali-komponentti
// components/modal.tsx
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect } from "react";
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const onClose = useCallback(() => {
router.back();
}, [router]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
<div className="fixed inset-0 bg-black/50" />
<div
className="relative bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 p-6"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
X
</button>
{children}
</div>
</div>
);
}
Vaihe 6: Siepattu reitti (modaaliversio)
// app/@modal/(.)products/[id]/page.tsx
import Modal from "@/components/modal";
import { getProduct } from "@/lib/data";
export default async function ProductModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return (
<Modal>
<img
src={product.imageUrl}
alt={product.name}
className="w-full h-64 object-cover rounded"
/>
<h2 className="text-2xl font-bold mt-4">{product.name}</h2>
<p className="text-gray-600 mt-2">{product.description}</p>
<p className="text-xl font-semibold mt-4">{product.price} EUR</p>
</Modal>
);
}
Vaihe 7: Kokosivu suoraa navigointia varten
// app/products/[id]/page.tsx
import { getProduct } from "@/lib/data";
import Link from "next/link";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return (
<main className="max-w-4xl mx-auto p-8">
<Link href="/products" className="text-blue-600 hover:underline">
Takaisin tuotteisiin
</Link>
<img
src={product.imageUrl}
alt={product.name}
className="w-full h-96 object-cover rounded-lg mt-6"
/>
<h1 className="text-4xl font-bold mt-6">{product.name}</h1>
<p className="text-gray-700 mt-4 text-lg">{product.description}</p>
<p className="text-2xl font-semibold mt-6">{product.price} EUR</p>
</main>
);
}
Nyt tapahtuu se taikuus: kun käyttäjä klikkaa tuotetta listalla, modaali avautuu tuotelistan päälle ja URL muuttuu muotoon /products/123. Jos käyttäjä jakaa tämän URL:n ja joku toinen avaa sen suoraan selaimessa, hän näkee tuotteen koko sivun — ei modaalia tyhjän taustan päällä. Mahtavaa, eikö?
Modaalin sulkeminen oikein
Modaalin sulkeminen kuulostaa yksinkertaiselta, mutta se voi yllättäen aiheuttaa ongelmia rinnakkaisreittien kanssa. Tässä ovat tärkeimmät säännöt:
router.back()— turvallisin tapa sulkea modaali. Se palauttaa edellisen reitin ja nollaa modal-slotin.<Link href="/" replace>— toimii sekin, mutta huomaareplace-prop. Ilman sitä modaali saattaa jäädä näkyviin.- Vältä tavallista
router.push()-kutsua — se ei välttämättä unmounttaa modal-slottia, koska rinnakkaisreitit säilyttävät tilansa client-side-navigaatiossa. Tämä on yllättänyt monet kehittäjät (myös minut).
Edistynyt esimerkki: Useita slotteja dashboardissa
Rinnakkaisreittien todellinen voima paljastuu, kun yhdistät useita slotteja samaan layoutiin. Katsotaan esimerkki SaaS-dashboardista, jossa on kolme itsenäistä paneelia:
app/
├── dashboard/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── @revenue/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ ├── error.tsx
│ │ └── default.tsx
│ ├── @orders/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ ├── error.tsx
│ │ └── default.tsx
│ └── @notifications/
│ ├── page.tsx
│ ├── loading.tsx
│ ├── error.tsx
│ └── default.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
revenue,
orders,
notifications,
}: {
children: React.ReactNode;
revenue: React.ReactNode;
orders: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow p-4">
<h1 className="text-2xl font-bold">Dashboard</h1>
</header>
<div className="p-6">
{children}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
<div className="lg:col-span-2">{revenue}</div>
<div>{notifications}</div>
</div>
<div className="mt-6">{orders}</div>
</div>
</div>
);
}
Jokainen paneeli latautuu itsenäisesti. Jos tilausdata tulee hitaasta tietokantakyselystä, vain tilausosio näyttää skeleton-loaderin — tuotto- ja ilmoitusosiot ovat heti käytettävissä.
Tämä on todella merkittävä parannus käyttökokemukseen verrattuna perinteiseen malliin, jossa koko sivu odottaa hitaimman tietolähteen valmistumista ennen kuin mitään näytetään.
Yleiset ongelmat ja niiden ratkaisut
Olen koonnut tähän yleisimmät ongelmat, joihin kehittäjät törmäävät rinnakkaisreittien ja sieppausreittien kanssa.
1. 404-virhe sivun uudelleenlatauksessa
Ongelma: Rinnakkaisreitit toimivat client-side-navigaatiossa, mutta sivun uudelleenlataus tuottaa 404-virheen.
Ratkaisu: Lisää default.tsx jokaiseen slottiin. Joo, tämä taas. Se on oikeasti niin yleinen virhe.
2. Modaali ei sulkeudu navigoidessa
Ongelma: Kun käyttäjä navigoi toiselle sivulle, modaali jää näkyviin.
Ratkaisu: Käytä router.back()-metodia modaalin sulkemiseen. Jos käytät <Link>-komponenttia, muista lisätä replace-prop.
3. Sieppaus ei toimi reittiryhmien sisällä
Ongelma: (..)-konventio ei toimi odotetulla tavalla, kun reitit ovat reittiryhmien (group) sisällä.
Ratkaisu: Muista, että (..)-merkintä perustuu reittisegmentteihin, ei tiedostojärjestelmän kansioihin. Reittiryhmät eivät luo reittisegmenttiä, joten ne eivät vaikuta tasojen laskemiseen. Aseta @modal-slot samalle tasolle kuin layout, joka vastaanottaa sen.
4. Server Action redirect rikkoo rinnakkaisreitit
Ongelma: Kun Server Action kutsuu redirect()-funktiota, muut slotit lakkaavat toimimasta.
Ratkaisu: Tämä on tunnettu rajoitus Next.js:ssä. Kiertotapana voit käyttää router.push()-kutsua client-puolella Server Actionin paluuarvon perusteella redirect()-kutsun sijaan.
5. Kaksi modaalia näkyy yhtä aikaa
Ongelma: Kun käytät useita @modal-tyyppisiä slotteja root layoutissa, molemmat renderöityvät samanaikaisesti.
Ratkaisu: Käytä yhtä yhteistä @modal-slottia ja sijoita kaikki siepatut reitit sen alle. Älä siis luo erillisiä @loginModal- ja @productModal-slotteja — käytä yhtä @modal-slottia, jonka sisälle tulee eri sieppausreitit.
Suorituskykyvinkit
Rinnakkaisreitit ja reitin sieppaus voivat parantaa suorituskykyä merkittävästi, kun niitä käyttää oikein:
- Rinnakkainen streamaus: Jokainen slot streamataan itsenäisesti, joten koko sivu ei odota hitaimman osion valmistumista. Tämä on iso etu erityisesti dashboardeissa.
- Server Components modaaleissa: Erottamalla
Modal-wrapper ("use client") sisällöstä voit käyttää Server Componenteja modaalin sisällössä. Näin client-puolen JavaScript-kuorma pienenee. - Lazy loading: Siepatut reitit ladataan vain tarvittaessa. Jos käyttäjä ei koskaan klikkaa tuotetta, modaalin koodia ei ladata lainkaan.
- Välimuisti: Rinnakkaisreittien slotit toimivat hyvin yhdessä
use cache-direktiivin kanssa. Voit välimuistittaa yksittäisiä slotteja eri strategioilla tarpeen mukaan.
Milloin käyttää — ja milloin ei
Rinnakkaisreitit ja reitin sieppaus eivät ole ratkaisu kaikkeen. Tässä on selkeä ohje, jotta tiedät milloin nämä ominaisuudet kannattaa ottaa käyttöön.
Käytä, kun:
- Tarvitset URL-jaettavia modaaleja (tuotegalleriat, kuvasarjat, kirjautumislomakkeet)
- Rakennat dashboardia, jossa eri osiot latautuvat itsenäisesti
- Haluat ehdollista renderöintiä käyttäjäroolin perusteella samassa URL:ssä
- Tarvitset itsenäisiä virhe- ja lataustiloja eri sivun osille
Älä käytä, kun:
- Yksinkertainen modaali ilman URL-vaatimusta — silloin riittää tavallinen tilanhallinta (
useState) - Sivun osat eivät tarvitse itsenäistä latausta — käytä tavallisia komponentteja
- Rakenne on jo monimutkainen — rinnakkaisreitit lisäävät tiedostojen määrää merkittävästi, ja jossain vaiheessa se alkaa haitata ylläpidettävyyttä
Usein kysytyt kysymykset
Vaikuttavatko rinnakkaisreitit URL-rakenteeseen?
Eivät. Slotit (@analytics, @team jne.) eivät näy URL:ssä lainkaan. Ne ovat puhtaasti tiedostojärjestelmän organisointiväline, jolla Next.js päättelee, mitä sisältöä renderöidään layoutin sisällä. URL-osoite pysyy samana riippumatta siitä, kuinka monta slottia on aktiivisena.
Miksi modaalini näyttää tyhjää sivua uudelleenlatauksessa?
Melkein varmasti default.tsx puuttuu modal-slotista. Kun sivu ladataan uudelleen, Next.js ei pysty päättelemään slotin aktiivista tilaa ja etsii default.tsx-tiedostoa. Jos sitä ei löydy, renderöidään 404. Lisää default.tsx, joka palauttaa null, jokaiseen slottiin — näin ei voi mennä vikaan.
Voiko rinnakkaisreiteissä käyttää Server Componenteja?
Kyllä, ehdottomasti. Jokainen slotin page.tsx voi olla Server Component, ja se voi hakea dataa suoraan palvelimelta. Ainoastaan Modal-wrapper-komponentti tarvitsee "use client" -merkinnän interaktiivisuutta varten. Tämä on itse asiassa suositeltava malli, koska se minimoi client-puolen JavaScriptin määrän.
Miten testaan rinnakkaisreittejä ja sieppausreittejä?
Testaa aina molemmat navigointitavat: 1) Client-side-navigaatio <Link>-komponentilla, jolloin sieppaus aktivoituu ja modaali avautuu. 2) Suora URL-navigaatio tai sivun uudelleenlataus, jolloin kokosivu renderöidään. Playwright tai Cypress sopivat hyvin molempien skenaarioiden testaamiseen.
Toimivatko rinnakkaisreitit Vercelin ulkopuolella?
Kyllä, rinnakkaisreitit ja reitin sieppaus ovat Next.js:n ydinominaisuuksia, jotka toimivat kaikilla alustoilla. Joitakin ongelmia on raportoitu standalone-tilassa Cloud Runissa ja vastaavissa ympäristöissä, mutta nämä ovat tyypillisesti konfiguraatio-ongelmia — eivät ominaisuusrajoituksia. Varmista, että käytät Next.js 15:tä tai uudempaa, sillä aiemmissa versioissa oli tunnettuja bugeja sieppausreiteissä.