Web razvoj
Next.jsReactApp RouterUXStreamingRukovanje greškamaPerformanse

UX obrasci u Next.js App Routeru: Error Boundaries, Loading UI i streaming kako treba

AO
Adrijan Omićević
·15 min čitanja

# 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.tsx za zajednički “chrome”
  • page.tsx za leaf sadržaj
  • loading.tsx za fallback na razini segmenta
  • error.tsx za error boundary na razini segmenta
  • not-found.tsx za 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:

SekcijaUtjecaj kvaraPreporučena granica
Globalna navigacija, ikona košariceMora ostati stabilnoRoot layout, izvan loadinga
Header proizvoda, cijenaKritičnoSegment s vlastitim error i loading
RecenzijeNekritično, sporoUgniježđeni segment ili Suspense granica
PreporukeNekritičnoSuspense granica, opcionalno

Raspored mapa koji to podržava:

PutanjaSvrhaUX ishod
app/(shop)/layout.tsxstabilni shop chromebez treperenja pri navigaciji
app/(shop)/product/[id]/layout.tsxshell proizvodazajedničke dimenzije skeletona
app/(shop)/product/[id]/loading.tsxskeleton na razini proizvodastabilno rezerviran prostor
app/(shop)/product/[id]/error.tsxoporavljive greške proizvodaretry bez gubitka layouta
app/(shop)/product/[id]/not-found.tsxproizvod nije pronađenispravan 404 UX
app/(shop)/product/[id]/page.tsxstreama sekcijeprogresivno 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:

  1. 1
    Objasni što je pošlo po zlu jezikom razumljivim korisniku
  2. 2
    Ponudi retry
  3. 3
    Zabilježi dijagnostiku za vaš observability stack
TSX
'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 id ne postoji u bazi: pozovite notFound()
  • Upit prema bazi je pao: bacite grešku
  • Dozvole: često je notFound() bolji od 403 radi sigurnosti kroz dvosmislenost, ovisno o vašoj politici
TSX
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.tsx da “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#

TSX
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.

TSX
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, analytics
  • app/(shop)/layout.tsx: shop navigacija, sidebar kategorija
  • app/(shop)/product/[id]/layout.tsx: shell stranice proizvoda
  • app/(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-aRješenjePraktičan primjer
SlikeRezervirajte aspect ratioKoristite konzistentnu visinu medija na karticama i izbjegavajte “auto” visine
ListeOdržite broj redaka stabilnimPrikažite 8 skeleton redaka ako inače prikazujete 8 stavki iznad prevoja
FontoviStabilno učitavanje fontovaPreload fontove, koristite font-display: swap samo ako se layout neće pomaknuti
Uvjetni UIRezervirajte prostor za toolbarRenderirajte 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.

TSX
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.

TypeScript
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.tsxshell proizvoda
app/(shop)/product/[id]/reviews/page.tsxsekcija recenzija
app/(shop)/product/[id]/reviews/error.tsxerror UI samo za recenzije
app/(shop)/product/[id]/reviews/loading.tsxskeleton 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.tsx ako je sekcija kritična, inače renderirajte degradirani UI

Držite logiku centralizirano.

TypeScript
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.

TSX
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:

CiljPreporučeni pristupUX učinak
Uvijek svježi podacicache: 'no-store'više loading stanja, potrebno više streaminga
Brzi ponovni posjetinext.revalidate s razumnim TTL-ommanje spinnera, konzistentniji UX
Personalizirane sekcijeper-user fetch, često bez cacheaizolirajte 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:

DatotekaSvrha
app/(app)/dashboard/layout.tsxdashboard chrome, navigacija
app/(app)/dashboard/loading.tsxminimalni scaffold
app/(app)/dashboard/error.tsxsamo za greške koje ruše shell
app/(app)/dashboard/page.tsxstreama 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:

Segmentsadrž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.tsx i loading.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:

ScenarijOčekivani UXGdje je implementirano
Product API vraća 404prikaži product not-found.tsxnotFound() + not-found.tsx
Product API vraća 500prikaži segment error s retryerror.tsx
Reviews API timeoutaprikaži fallback za recenzije ili degradirani UISuspense ili ugniježđeni segment
Navigacija između proizvodastabilan header, bez skokovaparent 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.tsx u 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() i not-found.tsx za očekivani izostanak, a bacajte greške za neočekivane kvarove kako bi se aktivirao error.tsx s mogućnošću retryja.
  • Koristite loading.tsx kako 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

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.