# Zašto je ovo važno za produkcijski UX#
App Router olakšava isporuku brzih stranica, ali jednako lako možete isporučiti i krhak UX: jedan neuspjeli fetch može srušiti cijelu rutu, loading stanja mogu uzrokovati pomake layouta, a streaming se može pogrešno koristiti pa korisnici vide treperenje umjesto napretka.
Ovaj vodič pokazuje kako strukturirati route segmente i koristiti error.tsx, loading.tsx, not-found.tsx i Suspense streaming kako bi sučelje ostalo responzivno čak i kad su podaci spori ili neispravni. Naučit ćete i praktične obrasce za djelomično renderiranje te za održavanje stabilnog layouta.
Ako migrirate s Pages Routera, krenite s našim popisom: Checklist za migraciju na Next.js App Router. Za odluke o cacheu koje izravno utječu na percipirani UX, pogledajte: Next.js strategije cacheiranja: SSR, ISR, SWR.
# Mentalni model: route segmenti su UX granice#
U App Routeru svaka mapa u app/ je route segment. Svaki segment može imati:
layout.tsxza zajednički “chrome”page.tsxza leaf sadržajloading.tsxza fallback na razini segmentaerror.tsxza error boundary na razini segmentanot-found.tsxza 404 na razini segmenta
Ključni UX uvid je sljedeći: granice segmenata definiraju što može neovisno pasti, učitavati se i streamati. Ako vam je segment preširok, korisnici dobivaju “sve-ili-ništa” UI. Ako je dobro strukturiran, dobivate otporno djelomično renderiranje.
Praktična struktura segmenata za otporne stranice#
Čest anti-obrazac je staviti cijelu stranicu u jedan route segment i zatim se oslanjati na jedan loading.tsx i jedan error.tsx. Umjesto toga, podijelite po “sekcijama koje su korisniku vidljive i mogu biti neovisne”.
Primjer za e-commerce stranicu proizvoda:
| Sekcija | Utjecaj kvara | Preporučena granica |
|---|---|---|
| Globalna navigacija, ikona košarice | Mora ostati stabilno | Root layout, izvan loadinga |
| Header proizvoda, cijena | Kritično | Segment s vlastitim error i loading |
| Recenzije | Nekritično, sporo | Ugniježđeni segment ili Suspense granica |
| Preporuke | Nekritično | Suspense granica, opcionalno |
Raspored mapa koji to podržava:
| Putanja | Svrha | UX ishod |
|---|---|---|
app/(shop)/layout.tsx | stabilni shop chrome | bez treperenja pri navigaciji |
app/(shop)/product/[id]/layout.tsx | shell proizvoda | zajedničke dimenzije skeletona |
app/(shop)/product/[id]/loading.tsx | skeleton na razini proizvoda | stabilno rezerviran prostor |
app/(shop)/product/[id]/error.tsx | oporavljive greške proizvoda | retry bez gubitka layouta |
app/(shop)/product/[id]/not-found.tsx | proizvod nije pronađen | ispravan 404 UX |
app/(shop)/product/[id]/page.tsx | streama sekcije | progresivno renderiranje |
🎯 Ključna poruka: Tretirajte route segmente kao kontrolu “radijusa štete”. Manji, smisleni segmenti sprječavaju da jedan spor ili neuspjeli poziv isprazni cijelu stranicu.
# error.tsx: dizajniranje padova iz kojih se korisnik može oporaviti#
U App Routeru, error.tsx je error boundary na razini segmenta. Mora biti Client Component i hvata greške bačene u podstablu tog segmenta tijekom renderiranja, dohvaćanja podataka i server actions.
Predložak error.tsx spreman za produkciju#
Neka vaš error UI radi tri stvari:
- 1Objasni što je pošlo po zlu jezikom razumljivim korisniku
- 2Ponudi retry
- 3Zabilježi dijagnostiku za vaš observability stack
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Send to your logging tool (Sentry, Datadog, OpenTelemetry collector, etc.)
console.error('Route error', { message: error.message, digest: error.digest });
}, [error]);
return (
<div style={{ padding: 24 }}>
<h2>Something went wrong</h2>
<p>Try again. If the problem persists, contact support.</p>
<button onClick={() => reset()}>Retry</button>
</div>
);
}Funkcija reset() pokreće ponovno renderiranje segmenta. Za prolazne probleme (mrežni “hiccup”, upstream 502, privremena zagušenja baze), to je često dovoljno.
Za dublje obrasce instrumentacije i što mjeriti, koristite naš vodič za observability: Observability web aplikacija: logovi, metrike, tracing.
Obrazac: odvojite “not found” od “error”#
Čest UX bug je prikaz error ekrana kada sadržaj zapravo ne postoji. Za očekivani izostanak koristite notFound(), a za neočekivane kvarove bacite grešku:
- Proizvod s
idne postoji u bazi: pozovitenotFound() - Upit prema bazi je pao: bacite grešku
- Dozvole: često je
notFound()bolji od403radi sigurnosti kroz dvosmislenost, ovisno o vašoj politici
import { notFound } from 'next/navigation';
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, { cache: 'no-store' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to load product, status ${res.status}`);
return res.json();
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
if (!product) notFound();
return <div>{product.name}</div>;
}Obrazac: izbjegnite “globalne” error boundaryje za lokalizirane probleme#
Ako error.tsx stavite previsoko (npr. u app/error.tsx), tada bilo koji problem unutar aplikacije postaje full-screen pad. To je ponekad ispravno za situacije tipa “aplikacija je neupotrebljiva”, ali je najčešće pogrešno za jedan widget ili sekciju.
Bolji pristup su slojevite granice:
- Root error boundary za stvarno globalne kvarove
- Segment error boundaries za probleme na razini rute
- Fallbackovi na razini komponenti putem Suspensea i lokalnih error obrazaca za nekritične widgete
⚠️ Upozorenje: Ne oslanjajte se na jedan top-level
error.tsxda “riješi sve”. Korisnici full-page error doživljavaju kao rušenje aplikacije, čak i ako su samo recenzije pale.
# not-found.tsx: konzistentan 404 UX bez razbijanja app shella#
not-found.tsx je najbolji način da dizajn ostane konzistentan u 404 slučajevima. Možete ga ograničiti po segmentu, tako da stranice proizvoda imaju poruke prilagođene proizvodima, dok docs stranice imaju navigaciju specifičnu za dokumentaciju.
not-found.tsx ograničen na segment#
import Link from 'next/link';
export default function NotFound() {
return (
<div style={{ padding: 24 }}>
<h2>Product not found</h2>
<p>Check the link or browse our catalog.</p>
<Link href="/products">Go to products</Link>
</div>
);
}Obrazac: vratite 404 rano kako biste uštedjeli vrijeme i izbjegli “jank”#
Ako odsutnost možete utvrditi brzo (npr. laganim head requestom, indeksnim lookupom ili cacheiranim metadata endpointom), pozovite notFound() rano. Time sprječavate renderiranje nizvodnog UI-ja i smanjujete uzaludan rad.
To također poboljšava Core Web Vitals jer ne “bojate” loading UI da biste ga odmah zamijenili 404 stranicom.
# loading.tsx: loading UI na razini segmenta koji izbjegava pomake layouta#
loading.tsx se renderira dok se segment učitava tijekom navigacije i početnog učitavanja. To je najbolji alat za sprječavanje “praznih ekrana” i za održavanje stabilnog layouta.
Što dobar loading UI radi#
Dobar loading UI:
- Održava strukturu stranice konzistentnom
- Rezervira prostor da sadržaj ne “skače”
- Prikazuje napredak bez ometajućih animacija
- Izbjegava nepodudarnu tipografiju i razmake
Loš loading UI:
- Koristi generički spinner u sredini stranice
- Uklanja header i navigaciju, pa ih vraća
- Renderira skeleton dimenzije koje ne odgovaraju konačnom layoutu
- Mijenja broj redaka liste između loading i loaded stanja
Primjer loading.tsx s rezerviranim prostorom#
Ovaj skeleton rezervira prostor za naslov, sliku i primarne akcije. Namjerno je “blokast” kako bi odgovarao stvarnim dimenzijama.
export default function Loading() {
return (
<div style={{ padding: 24 }}>
<div style={{ height: 32, width: '60%', background: '#eee', borderRadius: 8 }} />
<div style={{ height: 16, width: '40%', background: '#eee', borderRadius: 8, marginTop: 12 }} />
<div style={{ height: 360, width: '100%', background: '#eee', borderRadius: 12, marginTop: 20 }} />
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
<div style={{ height: 44, width: 160, background: '#eee', borderRadius: 10 }} />
<div style={{ height: 44, width: 120, background: '#eee', borderRadius: 10 }} />
</div>
</div>
);
}Obrazac: držite stabilni chrome izvan loading granice#
Ako su header, sidebar ili breadcrumbs unutar segmenta koji se učitava, nestajat će i ponovno se pojavljivati tijekom navigacije. Premjestite stabilni chrome u parent layout.
Praktično slojevanje layouta:
app/layout.tsx: globalni header, tema, analyticsapp/(shop)/layout.tsx: shop navigacija, sidebar kategorijaapp/(shop)/product/[id]/layout.tsx: shell stranice proizvodaapp/(shop)/product/[id]/page.tsx: sadržaj koji se streama
💡 Savjet: Dizajnirajte skeletone iz stvarnih UI komponenti. Uzmite konačni layout komponente i zamijenite sadržaj blokovima iste veličine. To je najpouzdaniji način za minimiziranje CLS-a.
Kontrole layout shiftova koje stvarno rade#
CLS se uglavnom svodi na neočekivane promjene veličine nakon painta. Za App Router stranice najveći krivci su slike, async blokovi sadržaja i uvjetni toolbari.
Koristite ove kontrole:
| Izvor CLS-a | Rješenje | Praktičan primjer |
|---|---|---|
| Slike | Rezervirajte aspect ratio | Koristite konzistentnu visinu medija na karticama i izbjegavajte “auto” visine |
| Liste | Održite broj redaka stabilnim | Prikažite 8 skeleton redaka ako inače prikazujete 8 stavki iznad prevoja |
| Fontovi | Stabilno učitavanje fontova | Preload fontove, koristite font-display: swap samo ako se layout neće pomaknuti |
| Uvjetni UI | Rezervirajte prostor za toolbar | Renderirajte prazan toolbar container koji se kasnije popuni |
# Streaming sa Suspenseom: djelomično renderiranje bez treperenja#
Streaming je mjesto gdje App Router briljira: možete odmah renderirati shell, a zatim progresivno otkrivati sporije sekcije. Cilj nije “sve streama”, nego “streamaju prave stvari”.
Osnovni obrazac: brzi shell, spori widgeti#
Tipična stranica proizvoda ima:
- brzo: naziv, hero slika, buy button
- sporo: recenzije, povezani proizvodi, zalihe po skladištu, personalizacija
Neka se shell renderira prvo s kritičnim podacima, a ostalo streamajte pomoću Suspense granica.
import { Suspense } from 'react';
function ReviewsFallback() {
return <div style={{ height: 220, background: '#eee', borderRadius: 12 }} />;
}
function RecosFallback() {
return <div style={{ height: 180, background: '#eee', borderRadius: 12 }} />;
}
export default async function Page() {
return (
<div style={{ padding: 24 }}>
<h1>Product title</h1>
<Suspense fallback={<ReviewsFallback />}>
{/* Server Component that fetches slower data */}
{/* @ts-expect-error Async Server Component */}
<Reviews />
</Suspense>
<Suspense fallback={<RecosFallback />}>
{/* @ts-expect-error Async Server Component */}
<Recommendations />
</Suspense>
</div>
);
}Time stranica postaje interaktivna ranije i ne blokira se na nekritičnim pozivima.
Obrazac: učinite “sporo” eksplicitnim uz mali umjetni delay tijekom razvoja#
Timovi često misle da streamaju, ali u lokalnom devu je sve prebrzo pa se problemi ne primijete. Dodajte delay samo u developmentu u sporim komponentama kako biste vidjeli stvarno ponašanje.
export async function sleep(ms: number) {
if (process.env.NODE_ENV !== 'development') return;
await new Promise((r) => setTimeout(r, ms));
}Koristite ga u server component fetch putanji kako biste provjerili podudaraju li se dimenzije skeletona i da nema skokova layouta.
Obrazac: rukovanje djelomičnim padom unutar streamane sekcije#
Velika prednost streaminga je što možete izolirati kvar. Ako recenzije padnu, ostatak stranice proizvoda i dalje treba raditi.
To možete postići ugniježđenim route segmentom s vlastitim error.tsx, ili lokalnim error UI-jem, ovisno o strukturi. Za izolaciju na razini rute, ugniježđeni segment je jednostavan.
Primjer strukture:
| Putanja | Što izolira |
|---|---|
app/(shop)/product/[id]/page.tsx | shell proizvoda |
app/(shop)/product/[id]/reviews/page.tsx | sekcija recenzija |
app/(shop)/product/[id]/reviews/error.tsx | error UI samo za recenzije |
app/(shop)/product/[id]/reviews/loading.tsx | skeleton recenzija |
Zatim renderirajte recenzije putem parallel slota ili linkanjem na nested route obrazac koji odgovara vašoj aplikaciji. Ključ je izolacija: podstablo recenzija posjeduje vlastita loading i error stanja.
ℹ️ Napomena: Ako ne želite promjene URL-a ili kompleksnost ugniježđenog routanja, i dalje možete izolirati kvarove tako da recenzije držite u zasebnoj Suspense granici i dopustite da grešku uhvati najbliži segment
error.tsx. Kompromis je opseg error boundaryja.
# Obrasci neuspjeha dohvaćanja podataka koji ne ruše UX#
Većina produkcijskih problema nisu “iznimke u kodu”. To su upstream timeouti, rate limitovi, prazna stanja i djelomični podaci.
Obrazac: klasificirajte rezultate fetcha i renderirajte odgovarajući UI#
Umjesto da svaki non-200 tretirate kao “throw error”, klasificirajte:
- 404: pozovite
notFound()za rutu resursa - 401 i 403: prikažite auth/access UI ili redirect
- 429: prikažite poruku “pokušajte kasnije”
- 500 i mrežni kvarovi: bacite prema
error.tsxako je sekcija kritična, inače renderirajte degradirani UI
Držite logiku centralizirano.
export type FetchResult<T> =
| { ok: true; data: T }
| { ok: false; status: number; message: string };
export async function safeJson<T>(url: string): Promise<FetchResult<T>> {
try {
const res = await fetch(url, { next: { revalidate: 60 } });
if (!res.ok) return { ok: false, status: res.status, message: res.statusText };
return { ok: true, data: (await res.json()) as T };
} catch (e) {
return { ok: false, status: 0, message: e instanceof Error ? e.message : 'Unknown error' };
}
}Sada komponenta odlučuje koliko “stroga” treba biti fallback logika.
Obrazac: degradirajte nekritične sekcije umjesto bacanja greške#
Za preporuke je obično bolje ne prikazati ništa nego prikazati zastrašujuću grešku. Time korisnik ostaje fokusiran na primarnu akciju.
import { safeJson } from '@/lib/safeJson';
type Reco = { id: string; name: string };
export async function Recommendations() {
const result = await safeJson<Reco[]>('https://api.example.com/recommendations');
if (!result.ok) {
return (
<section>
<h3>Recommended for you</h3>
<p style={{ opacity: 0.7 }}>Recommendations are unavailable right now.</p>
</section>
);
}
return (
<section>
<h3>Recommended for you</h3>
<ul>
{result.data.slice(0, 6).map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</section>
);
}Za kritične detalje proizvoda obično želite baciti grešku kako bi error.tsx dao konzistentan put “pokušaj ponovno”.
Obrazac: uskladite strategiju cacheiranja s loading UX-om#
Cacheiranje mijenja koliko često korisnici vide loading.tsx — često ili rijetko.
Brza tablica odluke:
| Cilj | Preporučeni pristup | UX učinak |
|---|---|---|
| Uvijek svježi podaci | cache: 'no-store' | više loading stanja, potrebno više streaminga |
| Brzi ponovni posjeti | next.revalidate s razumnim TTL-om | manje spinnera, konzistentniji UX |
| Personalizirane sekcije | per-user fetch, često bez cachea | izolirajte u Suspense da ne blokira |
Ako trebate dublji okvir za odluke, koristite: Next.js strategije cacheiranja: SSR, ISR, SWR.
# Recepti route segmenata koje možete kopirati#
Ovo su “defaultno dobri” obrasci koje implementiramo klijentima kada je pouzdanost bitna.
Recept 1: dashboard s neovisnim widgetima#
Cilj: shell se učitava odmah, widgeti se streamaju, a kvar jednog widgeta ne isprazni cijeli dashboard.
Predložena struktura:
| Datoteka | Svrha |
|---|---|
app/(app)/dashboard/layout.tsx | dashboard chrome, navigacija |
app/(app)/dashboard/loading.tsx | minimalni scaffold |
app/(app)/dashboard/error.tsx | samo za greške koje ruše shell |
app/(app)/dashboard/page.tsx | streama widgete sa Suspenseom |
app/(app)/dashboard/widgets/* | opcionalno: ugniježđeni segmenti po widgetu |
U page.tsx, držite stabilan grid layout čak i tijekom učitavanja. Skeleton blokovi trebaju odgovarati veličinama widgeta.
Recept 2: content site sa snažnim 404 ponašanjem#
Cilj: 404 stranice su brendirane, specifične po segmentu i izbjegavaju konfuziju.
Predložena struktura:
| Segment | sadržaj u not-found.tsx |
|---|---|
app/(docs) | pretraga dokumenata i linkovi iz sidebara |
app/(blog) | najnoviji postovi i kategorije |
app/(marketing) | CTA prema glavnom proizvodu |
To sprječava “generički 404” koji korisnike ostavlja u slijepoj ulici.
Recept 3: detalji proizvoda sa streamingom i izoliranim greškama#
Cilj: detalji proizvoda moraju biti pouzdani; recenzije i preporuke ne smiju utjecati na konverziju.
Predložene granice:
- Detalji proizvoda: granica segmenta s
error.tsxiloading.tsx - Recenzije: zasebna Suspense granica, opcionalno vlastiti ugniježđeni segment
- Preporuke: degradirajte graciozno, izbjegavajte throw
# Testiranje i observability: provjerite UX u stvarnim modovima kvara#
Nemate stvarno otporan UX dok ne testirate:
- sporu mrežu
- upstream 500
- timeoute
- djelomični 404
- ponovljene navigacije
Jednostavan checklist za “injectanje” kvarova#
Koristite ovo kao gate prije releasea:
| Scenarij | Očekivani UX | Gdje je implementirano |
|---|---|---|
| Product API vraća 404 | prikaži product not-found.tsx | notFound() + not-found.tsx |
| Product API vraća 500 | prikaži segment error s retry | error.tsx |
| Reviews API timeouta | prikaži fallback za recenzije ili degradirani UI | Suspense ili ugniježđeni segment |
| Navigacija između proizvoda | stabilan header, bez skokova | parent layout + rezervirani skeleton |
Instrumentirajte retry i učestalost fallbackova#
Ako se retry u error.tsx često koristi, imate problem pouzdanosti upstreama ili preagresivno invalidiranje cachea. Pratite:
- broj renderiranja error boundaryja po ruti
- stopu klikova na retry
- time to first byte i time to first contentful paint na sporim rutama
- postotak sesija koje vide
loading.tsxu ključnim user journeyjima
Za obrasce implementacije i koje metrike su bitne, koristite: Observability web aplikacija: logovi, metrike, tracing.
# Ključne poruke#
- Strukturirajte route segmente tako da svaka korisniku vidljiva sekcija ima kontroliran radijus štete, umjesto jednog ogromnog segmenta koji pada u cijelosti.
- Koristite
notFound()inot-found.tsxza očekivani izostanak, a bacajte greške za neočekivane kvarove kako bi se aktiviraoerror.tsxs mogućnošću retryja. - Koristite
loading.tsxkako biste spriječili prazne ekrane i smanjili CLS rezerviranjem prostora skeletonima koji odgovaraju dimenzijama konačnog UI-ja. - Streamajte nekritične sekcije sa Suspenseom kako bi korisnici dobili brzi shell i progresivno otkrivanje sporijih widgeta.
- Graciozno degradirajte nekritične kvarove podataka, a full-screen error ostavite samo za stvarno blokirajuće probleme.
# Zaključak#
Otporan App Router UX je uglavnom stvar granica: route segmenti definiraju što se može neovisno učitavati, pasti i streamati. Kada kombinirate loading.tsx, error.tsx i not-found.tsx ograničene na segment s ciljanim Suspense granicama, dobivate brže percipirane performanse, manje pomaka layouta i manje trenutaka “aplikacija je pukla”.
Ako želite da pregledamo vašu trenutnu App Router strukturu i end-to-end implementiramo otporne loading i error obrasce, kontaktirajte Samioda. Pomoći ćemo vam dizajnirati granice segmenata, streaming strategiju i observability kako bi vaš UX ostao stabilan čak i kada se sustavi u stvarnom svijetu ne ponašaju idealno.
FAQ
Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.
Više iz kategorije Web razvoj
Sve →Izrada dizajn sustava u Next.js s Radix UI, Tailwindom i Storybookom: vodič od početka do kraja za 2026.
Praktičan, produkcijski spreman pristup izradi i održavanju Next.js dizajn sustava uz Radix UI za pristupačnost, Tailwind za stiliziranje i Storybook za dokumentaciju, testiranje i verzionirana izdanja.
Kontrolna lista za React code review koju koristimo: performanse, pristupačnost i održivost
Praktična kontrolna lista za React code review usmjerena na performanse, pristupačnost i održivost, s primjerima, savjetima za automatizaciju i copy-paste predloškom.
Next.js višeregionalne implementacije: obrasci za nižu latenciju na Vercelu i Cloudflareu
Praktičan vodič za Next.js višeregionalnu implementaciju u 2026.: edge rendering, regionalni SSR i obrasci lokalnosti podataka na Vercelu i Cloudflareu, uključujući upozorenja za baze podataka i kontrolnu listu za provjeru latencije.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
React Query u velikim aplikacijama: invalidacija cachea, paginacija i obrasci mutacija za stvarne aplikacije
Najbolje prakse invalidacije cachea u React Queryju za aplikacije iz stvarnog svijeta: skalabilan dizajn query keyjeva, strategija invalidacije, optimistična ažuriranja, infinite queryji i pozadinski refetch u Next.js App Routeru.
Objašnjene strategije cacheiranja u Next.js-u: SSR, SSG, ISR, Route Cache i SWR
Praktičan vodič kroz Next.js strategije cacheiranja u eri App Routera — kako se SSR, SSG, ISR, Route Cache, Data Cache i SWR uklapaju u cjelinu, uz tablice za odluke, primjere koda i česte zamke poput zastarjelih auth i tenant podataka.
Kontrolni popis za migraciju na Next.js App Router (s Pages Routera) + česte zamke
Praktičan, korak-po-korak plan migracije na Next.js App Router s Pages Routera, uključujući kontrolni popis za routing, dohvat podataka, SEO metadata, deployment i vodič za rješavanje čestih zamki.