React Server Components i dohvaćanje podataka u Next.js App Routeru — vodič kroz renderiranje, keširanje i streaming

Praktičan vodič za React Server Components u Next.js App Routeru. Od kompozicijskih obrazaca i strategija renderiranja do keširanja, streaminga i paralelnog dohvaćanja podataka — sve što vam treba za performantne aplikacije.

Uvod: Nova era dohvaćanja podataka u Next.js-u

Ako ste ikada radili s Pages Routerom u Next.js-u, vjerojatno se sjećate onog osjećaja ograničenosti. Funkcije poput getServerSideProps i getStaticProps bile su jedini način da podatke dohvatite na serveru, a to je značilo da ste cijelu logiku morali trpati na jedno mjesto — na vrh stabla komponenti. Iskreno, bilo je to prilično frustrirajuće.

S dolaskom App Routera i React Server Components (RSC) arhitekture, ta paradigma se potpuno promijenila.

Danas svaka komponenta u vašoj Next.js aplikaciji može samostalno dohvaćati podatke izravno na serveru. Nema više prop drillinga, nema globalnog state managementa samo da biste proslijedili podatke niz stablo. Ovo nije kozmetička promjena — radi se o fundamentalnom pomaku u načinu na koji razmišljamo o arhitekturi React aplikacija. Server Components omogućuju da teške operacije (upiti prema bazi, pozivi prema API-jima, transformacije podataka) ostanu na serveru, dok klijentu šaljete samo gotov, renderirani HTML.

U ovom vodiču ćemo proći kroz sve što trebate znati: kompozicijske obrasce, strategije renderiranja (SSR, SSG, ISR i eksperimentalni PPR), sustav keširanja, streaming s React Suspenseom i napredne obrasce za paralelno dohvaćanje podataka. Ajmo krenuti.

Razumijevanje React Server Components

React Server Components su komponente koje se izvršavaju isključivo na serveru. Za razliku od tradicionalnih React komponenti koje se renderiraju u pregledniku, RSC nikada ne šalju svoj JavaScript klijentskoj strani — samo renderirani rezultat (HTML i poseban format poznat kao RSC Payload) putuje do preglednika.

Implikacije su značajne, i na performanse i na sigurnost.

Zašto su Server Components revolucionarni

Prije RSC-a, svaka React komponenta koja je trebala podatke morala je ili primiti ih kao props od nadređene komponente, ili ih dohvatiti na klijentskoj strani koristeći useEffect — što je rezultiralo vidljivim spinnerima, layout shiftovima i nepotrebnim opterećenjem preglednika. S RSC-om, možete jednostavno napisati async komponentu koja direktno komunicira s bazom podataka:

// app/products/page.tsx
// Ovo je Server Component — zadano ponašanje u App Routeru
import { db } from '@/lib/database';

export default async function ProductsPage() {
  // Direktan upit prema bazi — izvršava se na serveru
  const products = await db.product.findMany({
    where: { published: true },
    include: { category: true },
    orderBy: { createdAt: 'desc' },
  });

  return (
    <div className="grid grid-cols-3 gap-6">
      {products.map((product) => (
        <article key={product.id} className="border rounded-lg p-4">
          <h3>{product.name}</h3>
          <p>{product.description}</p>
          <span className="text-green-600 font-bold">
            {product.price} EUR
          </span>
        </article>
      ))}
    </div>
  );
}

Ovaj kod se u potpunosti izvršava na serveru. Prisma upit, transformacija podataka, čak i sam JSX — sve se odvija na serveru, a klijentu se šalje samo gotov HTML. Evo što to konkretno znači za vas:

  • Nema JavaScript bundle-a — biblioteke poput Prisma ili moment.js koje koristite u Server Components ne povećavaju veličinu JavaScript paketa koji se šalje klijentu.
  • Direktan pristup resursima — možete pristupiti bazi podataka, datotečnom sustavu ili internim API-jima bez izlaganja tih resursa javnom internetu.
  • Nema waterfalla na klijentu — podaci su dostupni odmah prilikom prvog renderiranja, bez sekundarnih fetch poziva.
  • Sigurnost — osjetljivi podaci poput API ključeva, tokena baze podataka i poslovne logike nikada ne napuštaju server.

Server vs Client Components: Kada koristiti što

U App Routeru sve komponente su prema zadanim postavkama Server Components. Da biste komponentu označili kao klijentsku, trebate dodati direktivu "use client" na vrh datoteke. Ali kada točno trebate klijentsku komponentu?

Koristite Server Components kada:

  • Dohvaćate podatke iz baze podataka ili vanjskog API-ja
  • Pristupate backendskim resursima (datotečni sustav, interni servisi)
  • Trebate osjetljive podatke (API ključevi, tokeni) koji ne smiju dospjeti do klijenta
  • Koristite teške biblioteke (ORM, markdown parseri, generatori PDF-a)
  • Renderirate statički sadržaj koji ne zahtijeva interaktivnost

Koristite Client Components kada:

  • Trebate React hookove (useState, useEffect, useRef)
  • Trebate event handlere (onClick, onChange, onSubmit)
  • Koristite browser API-je (localStorage, geolocation, Intersection Observer)
  • Imate komponentu s animacijama ili prijelazima koji zahtijevaju klijentski JavaScript

Kompozicijski obrasci: Granica između servera i klijenta

Ovo je, po mom mišljenju, jedan od najvažnijih koncepata koje trebate savladati. Granica između Server i Client Components nije samo tehnički detalj — ona fundamentalno oblikuje arhitekturu vaše aplikacije.

Pravilo uvoza: Server ne može biti dijete klijenta

Ključno pravilo: Client Component ne može uvoziti Server Component. Čim datoteka ima "use client" direktivu, sve komponente koje ona uvozi automatski postaju dio klijentskog grafa modula. Ovaj kod, na primjer, neće raditi kako očekujete:

// components/interactive-panel.tsx
"use client"

// ❌ POGREŠNO — ovo neće funkcionirati ispravno
// ServerDataComponent će biti tretiran kao Client Component
import { ServerDataComponent } from './server-data-component';

export function InteractivePanel() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        Prikaži podatke
      </button>
      {isOpen && <ServerDataComponent />}
    </div>
  );
}

Obrazac "Children as Props" — rješenje za kompoziciju

Rješenje je zapravo dosta elegantno: umjesto uvoza Server Component-a u Client Component, proslijedite ga kao children ili bilo koji drugi prop. Server Component tako zadržava svoju serversku prirodu, a Client Component ga prikazuje bez da ga "posjeduje":

// components/interactive-panel.tsx
"use client"

import { useState, type ReactNode } from 'react';

interface InteractivePanelProps {
  children: ReactNode;
  title: string;
}

export function InteractivePanel({ children, title }: InteractivePanelProps) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="border rounded-lg">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="w-full p-4 text-left font-bold"
      >
        {title} {isOpen ? '▲' : '▼'}
      </button>
      {isOpen && (
        <div className="p-4 border-t">
          {children}
        </div>
      )}
    </div>
  );
}

// app/dashboard/page.tsx — Server Component
import { InteractivePanel } from '@/components/interactive-panel';
import { db } from '@/lib/database';

export default async function DashboardPage() {
  const stats = await db.stats.findFirst();

  return (
    <InteractivePanel title="Statistika">
      {/* Ovo je Server Component sadržaj proslijeđen kao children */}
      <div>
        <p>Ukupno korisnika: {stats?.totalUsers}</p>
        <p>Aktivnih sesija: {stats?.activeSessions}</p>
        <p>Prihod ovaj mjesec: {stats?.monthlyRevenue} EUR</p>
      </div>
    </InteractivePanel>
  );
}

Ovaj obrazac je izuzetno moćan. Kombinirate interaktivnost klijentskih komponenti s pristupom podacima serverskih komponenti, bez kompromisa na bilo kojoj strani.

Organizacija providera

Postavljanje kontekst providera (za teme, autentifikaciju, i18n) zna biti nezgodno. Budući da provideri zahtijevaju createContext i useState, moraju biti Client Components. Ključno pravilo ovdje: renderirajte providere što dublje u stablu kako bi Next.js mogao optimizirati statičke dijelove.

// providers/theme-provider.tsx
"use client"

import { createContext, useContext, useState, type ReactNode } from 'react';

type Theme = 'light' | 'dark';

const ThemeContext = createContext<{
  theme: Theme;
  toggleTheme: () => void;
} | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');

  const toggleTheme = () =>
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme mora biti unutar ThemeProvider');
  return context;
};

// app/layout.tsx — Server Component
import { ThemeProvider } from '@/providers/theme-provider';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="hr">
      <body>
        {/* Provider obuhvaća samo children, ne cijeli dokument */}
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Strategije renderiranja u App Routeru

Next.js App Router podržava četiri glavne strategije renderiranja i odabir prave može napraviti ogromnu razliku za performanse vaše aplikacije. Jedna važna napomena: od Next.js 15, stranice su prema zadanom dinamičke — to je promjena u odnosu na ranije verzije koje su favorizirale statičko generiranje.

Static Site Generation (SSG)

SSG generira HTML stranice u vrijeme izgradnje (build time). Ovo je najbrža opcija jer se predgenerirane stranice posluže izravno s CDN-a bez ikakve serverske obrade. Idealna za sadržaj koji se rijetko mijenja:

// app/about/page.tsx
// Eksplicitno označavanje stranice kao statičke
export const dynamic = 'force-static';

export default function AboutPage() {
  return (
    <main>
      <h1>O nama</h1>
      <p>Sadržaj koji se rijetko mijenja...</p>
    </main>
  );
}

// Za dinamičke rute, koristite generateStaticParams
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await db.post.findMany({
    select: { slug: true },
  });

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await db.post.findUnique({
    where: { slug },
  });

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Server-Side Rendering (SSR)

SSR generira HTML za svaki zahtjev na serveru. Korisno je za stranice s podacima specifičnim za korisnika ili podacima koji se često mijenjaju. U App Routeru stranica automatski postaje dinamička kad koristi funkcije poput cookies(), headers(), ili searchParams:

// app/dashboard/page.tsx
import { cookies } from 'next/headers';

// Ova stranica je automatski dinamička zbog korištenja cookies()
export default async function DashboardPage() {
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get('session-token')?.value;

  if (!sessionToken) {
    redirect('/login');
  }

  const user = await getUserFromToken(sessionToken);
  const dashboardData = await getDashboardData(user.id);

  return (
    <main>
      <h1>Dobrodošli, {user.name}</h1>
      <DashboardStats data={dashboardData} />
    </main>
  );
}

// Možete i eksplicitno forsirati dinamičko renderiranje
export const dynamic = 'force-dynamic';

Incremental Static Regeneration (ISR)

ISR je nešto poput najboljeg od oba svijeta — stranice se generiraju statički, ali se automatski ažuriraju nakon definiranog vremenskog intervala. Savršeno za sadržaj koji se mijenja periodično, poput kataloga proizvoda ili liste blog članaka:

// app/products/page.tsx
// Revalidacija svakih 60 sekundi
export const revalidate = 60;

export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 },
  }).then((res) => res.json());

  return (
    <div>
      {products.map((product: any) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

ISR funkcionira prema principu "stale-while-revalidate" — kada istekne vrijeme revalidacije, sljedeći posjetitelj dobiva zastarjelu verziju dok se u pozadini generira nova. Tek sljedeći posjetitelj nakon toga dobiva svježu verziju. Korisnici nikad ne čekaju na generiranje stranice, što je prilično genijalno rješenje.

On-demand revalidacija

Osim vremenske revalidacije, možete i ručno pokrenuti revalidaciju putem tag-based ili path-based pristupa. Ovo je posebno korisno za CMS integracije gdje želite trenutno ažurirati stranicu kad se sadržaj promijeni:

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
  }).then((res) => res.json());

  return <ProductList products={products} />;
}

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const { tag, secret } = await request.json();

  // Provjera autorizacije
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json(
      { message: 'Neautorizirano' },
      { status: 401 }
    );
  }

  // Tag-based revalidacija
  if (tag) {
    revalidateTag(tag);
    return NextResponse.json({ revalidated: true, tag });
  }

  // Path-based revalidacija
  revalidatePath('/products');
  return NextResponse.json({ revalidated: true, path: '/products' });
}

Partial Prerendering (PPR) — eksperimentalna značajka

PPR je vjerojatno najuzbudljivija nova stvar u Next.js-u. Ideja je da kombinirate statički i dinamički sadržaj unutar iste rute. Statički dijelovi se predgeneriraju u build time-u, a dinamički se streamaju u runtime-u. Napomena: ovo je još eksperimentalna značajka i nije spremna za produkciju, ali koncept vrijedi razumjeti jer će vjerojatno postati standard.

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
};

export default nextConfig;

// app/product/[id]/page.tsx
import { Suspense } from 'react';

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  // Statički dio — predgenerira se u build time
  const product = await getProduct(id);

  return (
    <main>
      {/* Statički sadržaj */}
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>{product.price} EUR</span>

      {/* Dinamički sadržaj — streama se u runtime-u */}
      <Suspense fallback={<p>Učitavanje recenzija...</p>}>
        <ProductReviews productId={id} />
      </Suspense>

      <Suspense fallback={<p>Učitavanje preporuka...</p>}>
        <PersonalizedRecommendations productId={id} />
      </Suspense>
    </main>
  );
}

Ključna razlika u odnosu na klasični streaming: PPR predgenerira statičku ljusku u build time-u (uključujući fallback UI iz Suspense granica), dok klasični streaming renderira cijelu stranicu po zahtjevu. Rezultat? Inicijalno učitavanje stranice s PPR-om je jednako brzo kao čisti SSG, ali s mogućnošću dinamičkog sadržaja.

Sustav keširanja u Next.js-u

Hajde, budimo iskreni — keširanje je najkompliciraniji dio Next.js App Routera. Ali upravo zato je važno da ga razumijete. Next.js ima više razina keširanja koje rade zajedno, i kad shvatite kako funkcioniraju, dobit ćete puno bolje performanse.

Request Memoization

React automatski memoizira fetch pozive s istim URL-om i opcijama unutar jednog renderiranja. To znači da možete bez brige pozivati isti fetch u više komponenti — izvršit će se samo jednom:

// lib/data.ts
export async function getUser(id: string) {
  // Ovaj poziv će se automatski memoizirati
  const res = await fetch(`https://api.example.com/users/${id}`);
  return res.json();
}

// app/layout.tsx — koristi getUser
export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser('123'); // Poziv #1
  return (
    <html>
      <body>
        <nav>Pozdrav, {user.name}</nav>
        {children}
      </body>
    </html>
  );
}

// app/page.tsx — koristi isti getUser
export default async function Page() {
  const user = await getUser('123'); // Poziv #2 — memoizirano, ne šalje novi zahtjev
  return <h1>Profil: {user.name}</h1>;
}

Važna napomena: memoizacija se primjenjuje samo unutar jednog zahtjeva. Svaki novi korisnički zahtjev započinje s čistim memoizacijskim kešom.

Data Cache

Data Cache je trajni keš koji opstaje između korisničkih zahtjeva, pa čak i između deployova. Kontrolirate ga putem fetch opcija:

// Keširano zauvijek (ili do revalidacije)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
});

// Nikad ne keširati — uvijek svjež podatak
const freshData = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

// Revalidacija svakih 5 minuta
const periodicData = await fetch('https://api.example.com/data', {
  next: { revalidate: 300 },
});

// Tag-based keširanje za ciljanu revalidaciju
const taggedData = await fetch('https://api.example.com/products', {
  next: { tags: ['products', 'catalog'] },
});

React cache() za funkcije koje nisu fetch

Kad dohvaćate podatke izravno iz baze podataka (bez fetch), možete koristiti React cache() funkciju za memoizaciju. Evo kako to izgleda u praksi:

// lib/data.ts
import { cache } from 'react';
import { db } from '@/lib/database';

// Memoizirana funkcija — poziva se jednom po zahtjevu
export const getUser = cache(async (id: string) => {
  return db.user.findUnique({
    where: { id },
    include: { posts: true, profile: true },
  });
});

// Koristi se u više komponenti bez duplog upita
// app/profile/page.tsx
export default async function ProfilePage() {
  const user = await getUser('user-123');
  return <UserProfile user={user} />;
}

// components/user-sidebar.tsx — Server Component
export async function UserSidebar() {
  const user = await getUser('user-123'); // Memoizirano — nema duplog upita
  return <aside>{user?.name}</aside>;
}

Full Route Cache

Full Route Cache sprema kompletan HTML i RSC Payload za statičke rute. Bitna stvar za zapamtiti: ako invalidirate Data Cache (npr. putem revalidateTag), automatski se invalidira i Full Route Cache. Ali obrnuto ne vrijedi — invalidacija Full Route Cache-a ne utječe na Data Cache.

unstable_cache i cacheLife — napredne opcije

Za scenarije koji zahtijevaju finiju kontrolu keširanja, Next.js nudi dodatne API-je:

// Korištenje unstable_cache za keširanje rezultata funkcija
import { unstable_cache } from 'next/cache';

const getCachedProducts = unstable_cache(
  async () => {
    return db.product.findMany({
      where: { published: true },
    });
  },
  ['products-list'], // Ključ keša
  {
    revalidate: 3600, // 1 sat
    tags: ['products'],
  }
);

export default async function ProductsPage() {
  const products = await getCachedProducts();
  return <ProductGrid products={products} />;
}

Streaming i React Suspense

Streaming je jedna od onih značajki koja, kad je jednom isprobate, više se ne želite vraćati na stari način. Umjesto da čekate da se kompletna stranica renderira pa tek onda sve pošaljete klijentu, streaming šalje dijelove čim postanu dostupni. Korisnik vidi sadržaj postupno, počevši od najvažnijih dijelova.

Kako streaming funkcionira

Bez streaminga, server mora završiti renderiranje cijele stranice prije nego što pošalje bilo kakav HTML. Ako jedna komponenta treba 3 sekunde za dohvat podataka, cijela stranica kasni 3 sekunde. Sa streamingom, brzi dijelovi stižu odmah, dok se sporiji šalju naknadno.

Next.js koristi HTTP streaming i React Flight protokol za ovo. Evo kako se to koristi u praksi:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { RecentOrders } from '@/components/recent-orders';
import { SalesChart } from '@/components/sales-chart';
import { UserActivity } from '@/components/user-activity';

export default function DashboardPage() {
  return (
    <main className="grid grid-cols-2 gap-6 p-8">
      <h1 className="col-span-2 text-3xl font-bold">
        Nadzorna ploča
      </h1>

      {/* Svaka Suspense granica streama se neovisno */}
      <Suspense
        fallback={
          <div className="animate-pulse bg-gray-200 h-64 rounded-lg" />
        }
      >
        <SalesChart />
      </Suspense>

      <Suspense
        fallback={
          <div className="animate-pulse bg-gray-200 h-64 rounded-lg" />
        }
      >
        <UserActivity />
      </Suspense>

      <div className="col-span-2">
        <Suspense
          fallback={
            <div className="animate-pulse bg-gray-200 h-96 rounded-lg" />
          }
        >
          <RecentOrders />
        </Suspense>
      </div>
    </main>
  );
}

// components/sales-chart.tsx — Server Component
export async function SalesChart() {
  const salesData = await fetch('https://api.example.com/sales', {
    next: { revalidate: 300 },
  }).then((res) => res.json());

  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h2 className="text-xl font-semibold mb-4">Prodaja</h2>
      <div className="h-48">
        {salesData.map((point: any, i: number) => (
          <div
            key={i}
            className="inline-block bg-blue-500"
            style={{ height: `${point.value}%`, width: '4%', margin: '0 1%' }}
          />
        ))}
      </div>
    </div>
  );
}

Loading UI s loading.tsx

Next.js pruža konvencionalnu loading.tsx datoteku koja automatski omotava sadržaj stranice u Suspense granicu. Najjednostavniji je način za dodavanje loading stanja na razini rute:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="p-8 space-y-6">
      <div className="h-10 w-64 bg-gray-200 animate-pulse rounded" />
      <div className="grid grid-cols-2 gap-6">
        <div className="h-64 bg-gray-200 animate-pulse rounded-lg" />
        <div className="h-64 bg-gray-200 animate-pulse rounded-lg" />
      </div>
      <div className="h-96 bg-gray-200 animate-pulse rounded-lg" />
    </div>
  );
}

Razlika između loading.tsx i ručnih Suspense granica svodi se na granularnost. loading.tsx obuhvaća cijelu stranicu, dok vam Suspense granice daju preciznu kontrolu nad tim koji se dijelovi stranice streamaju neovisno.

Obrasci dohvaćanja podataka: Paralelno vs sekvencijalno

Način na koji organizirate dohvaćanje podataka ima ogroman utjecaj na performanse. Ovo je jedno od onih područja gdje mala promjena u kodu može rezultirati drastičnom razlikom u korisničkom iskustvu.

Sekvencijalni waterfall — problem

Waterfall nastaje kad jedno dohvaćanje mora završiti prije nego što sljedeće počne. Čest problem u ugniježđenim komponentama, a nažalost lako ga je previdjeti:

// ❌ LOŠE — sekvencijalni waterfall
// app/artist/[id]/page.tsx
export default async function ArtistPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  // Korak 1: Dohvati umjetnika (1 sekunda)
  const artist = await getArtist(id);

  // Korak 2: Čeka korak 1, pa dohvaća albume (1 sekunda)
  const albums = await getAlbums(artist.id);

  // Korak 3: Čeka korak 2, pa dohvaća recenzije (1 sekunda)
  const reviews = await getReviews(artist.id);

  // Ukupno: 3 sekunde 😩
  return (
    <div>
      <h1>{artist.name}</h1>
      <AlbumList albums={albums} />
      <ReviewList reviews={reviews} />
    </div>
  );
}

Paralelno dohvaćanje s Promise.all

Kad podaci nisu međuovisni, koristite Promise.all za paralelno dohvaćanje. Razlika je dramatična:

// ✅ DOBRO — paralelno dohvaćanje
export default async function ArtistPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  // Sva tri poziva počinju istovremeno
  const [artist, albums, reviews] = await Promise.all([
    getArtist(id),
    getAlbums(id),
    getReviews(id),
  ]);

  // Ukupno: ~1 sekunda (najsporiji od tri poziva) 🚀
  return (
    <div>
      <h1>{artist.name}</h1>
      <AlbumList albums={albums} />
      <ReviewList reviews={reviews} />
    </div>
  );
}

Kombinirani pristup: Paralelno + Streaming

A sad dolazimo do najmoćnijeg obrasca — kombinacije paralelnog dohvaćanja sa Suspense streamingom. Svaka komponenta dohvaća vlastite podatke neovisno, a Suspense osigurava da se brži dijelovi prikažu odmah:

// app/artist/[id]/page.tsx
import { Suspense } from 'react';

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

  // Ovaj podatak je kritičan — dohvaćamo ga odmah
  const artist = await getArtist(id);

  return (
    <main>
      <h1>{artist.name}</h1>
      <p>{artist.bio}</p>

      {/* Albumi i recenzije se streamaju neovisno */}
      <div className="grid grid-cols-2 gap-8">
        <Suspense fallback={<AlbumsSkeleton />}>
          <AlbumsSection artistId={id} />
        </Suspense>

        <Suspense fallback={<ReviewsSkeleton />}>
          <ReviewsSection artistId={id} />
        </Suspense>
      </div>
    </main>
  );
}

// Svaka sekcija dohvaća vlastite podatke
async function AlbumsSection({ artistId }: { artistId: string }) {
  const albums = await getAlbums(artistId);
  return (
    <section>
      <h2>Albumi</h2>
      {albums.map((album: any) => (
        <div key={album.id}>{album.title}</div>
      ))}
    </section>
  );
}

async function ReviewsSection({ artistId }: { artistId: string }) {
  const reviews = await getReviews(artistId);
  return (
    <section>
      <h2>Recenzije</h2>
      {reviews.map((review: any) => (
        <div key={review.id}>{review.content}</div>
      ))}
    </section>
  );
}

Ovaj obrazac je idealan jer korisnik odmah vidi ime i biografiju umjetnika, dok se albumi i recenzije učitavaju paralelno u pozadini. Čak i ako je jedan od tih poziva sporiji, drugi se prikazuje čim bude spreman — a to je upravo ono korisničko iskustvo koje želite postići.

Napredni obrasci za produkcijske aplikacije

Do sada smo pokrili temelje. Sad pogledajmo neke obrasce koji su posebno korisni kad radite na stvarnim, produkcijskim aplikacijama.

Obrazac preloada podataka

Kad znate da će podstranicu trebati određeni podaci, možete pokrenuti dohvaćanje unaprijed. Ovo smanjuje percipirano vrijeme učitavanja jer server počinje dohvaćati podatke čim zahtjev stigne, prije nego što ih komponenta eksplicitno zatraži:

// lib/data.ts
import { cache } from 'react';

export const getProduct = cache(async (id: string) => {
  const res = await fetch(`https://api.example.com/products/${id}`);
  return res.json();
});

// Preload funkcija — pokreće dohvaćanje bez čekanja na rezultat
export const preloadProduct = (id: string) => {
  void getProduct(id);
};

// app/product/[id]/page.tsx
import { getProduct, preloadProduct } from '@/lib/data';
import { ProductDetails } from '@/components/product-details';

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

  // Pokreni dohvaćanje odmah
  preloadProduct(id);

  // ... drugi kod ili provjere ...

  // Kad zatreba rezultat, već je (vjerojatno) dostupan
  const product = await getProduct(id);

  return <ProductDetails product={product} />;
}

Upravljanje greškama s error.tsx

Next.js pruža konvencionalnu error.tsx datoteku za elegantno upravljanje greškama na razini rute. U kombinaciji sa streamingom, ovo osigurava da greška u jednom dijelu stranice ne sruši sve ostalo:

// app/dashboard/error.tsx
"use client"

interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function DashboardError({ error, reset }: ErrorProps) {
  return (
    <div className="p-8 text-center">
      <h2 className="text-2xl font-bold text-red-600 mb-4">
        Nešto je pošlo po krivu
      </h2>
      <p className="text-gray-600 mb-6">
        Došlo je do greške pri učitavanju nadzorne ploče.
      </p>
      <button
        onClick={() => reset()}
        className="px-6 py-2 bg-blue-600 text-white rounded-lg
                   hover:bg-blue-700 transition-colors"
      >
        Pokušaj ponovno
      </button>
    </div>
  );
}

Dohvaćanje podataka s autentifikacijom

U produkcijskim aplikacijama gotovo uvijek ćete morati kombinirati dohvaćanje podataka s provjerom autentifikacije. Evo jednog čistog obrasca za to:

// lib/auth.ts
import { cookies } from 'next/headers';
import { cache } from 'react';
import { redirect } from 'next/navigation';

export const getCurrentUser = cache(async () => {
  const cookieStore = await cookies();
  const token = cookieStore.get('session-token')?.value;

  if (!token) return null;

  try {
    const user = await verifyToken(token);
    return user;
  } catch {
    return null;
  }
});

export async function requireAuth() {
  const user = await getCurrentUser();
  if (!user) redirect('/login');
  return user;
}

// app/settings/page.tsx
import { requireAuth } from '@/lib/auth';

export default async function SettingsPage() {
  const user = await requireAuth();

  const [profile, preferences] = await Promise.all([
    getProfile(user.id),
    getPreferences(user.id),
  ]);

  return (
    <main>
      <h1>Postavke za {user.name}</h1>
      <ProfileForm profile={profile} />
      <PreferencesForm preferences={preferences} />
    </main>
  );
}

Praktični savjeti i česte pogreške

Nakon rada na više produkcijskih Next.js aplikacija, evo grešaka koje sam najčešće vidio (i sam napravio, da budem iskren).

1. Izbjegavajte nepotrebne Client Components

Jedna od najčešćih pogrešaka je staviti "use client" na cijelu stranicu samo zato što jedan mali dio zahtijeva interaktivnost. Umjesto toga, izdvojite interaktivni dio u zasebnu Client Component:

// ❌ LOŠE — cijela stranica je klijentska
"use client"

export default function ProductPage() {
  const [quantity, setQuantity] = useState(1);
  // Sada ne možete koristiti async/await za dohvat podataka
  // ...
}

// ✅ DOBRO — samo interaktivni dio je klijentski
// components/quantity-selector.tsx
"use client"
export function QuantitySelector() {
  const [quantity, setQuantity] = useState(1);
  return (
    <div>
      <button onClick={() => setQuantity(q => Math.max(1, q - 1))}>-</button>
      <span>{quantity}</span>
      <button onClick={() => setQuantity(q => q + 1)}>+</button>
    </div>
  );
}

// app/product/[id]/page.tsx — Server Component
export default async function ProductPage({ params }) {
  const { id } = await params;
  const product = await getProduct(id);
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <QuantitySelector />
    </main>
  );
}

2. Ne zaboravite na error boundaries

Svaki Suspense boundary bi trebao imati odgovarajući error boundary. Bez toga, greška u jednoj komponenti može srušiti cijelu stranicu. Next.js error.tsx pokriva error boundary na razini rute, ali za granularnije upravljanje koristite React ErrorBoundary komponentu.

3. Pazite na veličinu RSC Payload-a

Ako vaše Server Components vraćaju ogromne količine podataka, RSC Payload može postati prevelik i usporiti hidrataciju. Rješenje je jednostavno — filtrirajte podatke na serveru i šaljite samo ono što je potrebno:

// ❌ LOŠE — šalje sve podatke
export default async function UsersPage() {
  const users = await db.user.findMany(); // Sva polja
  return <UserTable users={users} />;
}

// ✅ DOBRO — šalje samo potrebna polja
export default async function UsersPage() {
  const users = await db.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
      role: true,
    },
  });
  return <UserTable users={users} />;
}

4. Koristite generateMetadata za SEO

Server Components omogućuju dinamičko generiranje meta podataka na temelju dohvaćenih podataka. Nemojte zanemariti ovo — SEO je bitan:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

Zaključak

React Server Components i App Router fundamentalno su promijenili način na koji gradimo Next.js aplikacije. Umjesto centraliziranog dohvaćanja podataka na razini stranice, sada svaka komponenta može samostalno dohvaćati podatke na serveru — i to bez opterećenja klijentskog JavaScript bundle-a.

Evo najvažnijih stvari koje vrijedi ponijeti iz ovog vodiča:

  • Server Components su zadani — koristite "use client" samo kad stvarno trebate interaktivnost, hookove ili browser API-je.
  • Kompozicijski obrasci su ključni — "children as props" obrazac je vaš prijatelj za kombiniranje serverskih i klijentskih komponenti.
  • Birajte pravu strategiju renderiranja — SSG za statički sadržaj, ISR za periodično osvježavanje, SSR za dinamičke stranice, PPR za kombinaciju statičkog i dinamičkog.
  • Koristite paralelno dohvaćanjePromise.all i Suspense granice eliminiraju waterfalle i značajno poboljšavaju performanse.
  • Razumijte sustav keširanja — Request Memoization, Data Cache i Full Route Cache rade zajedno, a poznavanje njihove interakcije je ključno za predvidivo ponašanje.
  • Streaming poboljšava korisničko iskustvo — Suspense granice omogućuju progresivno učitavanje umjesto čekanja da se sve učita odjednom.

Kombiniranjem ovih tehnika sa Server Actions za mutacije i Middleware za centraliziranu kontrolu zahtjeva, dobivate kompletnu full-stack arhitekturu. Serverski pristup je tu da ostane — i što prije usvojite ove obrasce, to ćete biti produktivniji u izgradnji modernih web aplikacija.

O Autoru Editorial Team

Our team of expert writers and editors.