client pagination stárne rychle
Client-side pagination vypadá v prototypu neškodně, většinou proto, že první verze má padesát řádků, jeden filtr na happy path a product manager to testuje na MacBooku Pro proti lokálnímu API. Pak se to pošle ven, někdo otevře stránku 37 v účetní knize zákazníka, browser si nechá všechny předchozí stránky, protože váš React state tree ve skutečnosti nic pořádně nezapomíná, a najednou máte UI, které vypadá vytíženě, ale reálně nedělá skoro nic užitečného.
Ten problém je nudný a předvídatelný. V browseru načtete page 1, pak page 2, pak page 3, často přes generickou datovou vrstvu, která bere každou stránku jako samostatný cache entry, a aplikace začne platit za duplicitní parsování JSONu, duplicitní object graphy, duplicitní suspense boundaries a hromadu JavaScriptu, jehož jediná role je kompenzovat to, že tenhle pohled měl od začátku skládat server. Heap v Chromu roste, mobile Safari začne být agresivní, scroll restoration se rozbije a TTFB stejně není dobré, protože první render čeká na client bundle dřív, než vůbec může pagination logika začít běžet.
S infinite scrollem je to ještě horší. Týmy tomu říkají moderní UX, uživatelé po refreshi říkají „kde jsem to sakra byl“ a ops tomu říká drahý provoz, protože API najednou obsluhuje salvu malých requestů, které obcházejí většinu rozumných cache cest. Offset-based pagination zatěžuje databázi čím dál víc, jak dataset roste, a navíc vytváří divné bugy s duplicitami a chybějícími řádky ve chvíli, kdy se mezi fetchi něco vloží nebo smaže. Tohle jste už viděli, položka 101 je na konci jedné stránky a zároveň na začátku další, nebo zmizí úplně.
Ve steezr jsme pár takových systémů převzali, typicky v interních dashboardech a zákaznických portálech, kde se kdysi předpokládalo, že dat bude málo. Nikdy nezůstane málo. Oprava nebyla žádný hrdinský rewrite. Stačilo vrátit rozhodování o stránkování na server, přejít na cursory a nechat Next.js 16 cacheovat route segmenty tam, kde to dává smysl.
cursory, nebo bolest
Offset pagination je v pohodě pro admin obrazovky s pár stovkami řádků a bez většího tlaku na concurrency. Všechno ostatní si zaslouží cursory. Nemyslím tím nějaký mlhavý base64 blob bez dokumentace, ale cursor schema, kterému budete rozumět i za půl roku, až se někdo zeptá, proč se pod loadem rozjel sort order.
Použitelný cursor pro tabulky s častými inserty stojí na dvou polích, stabilním unikátním identifikátoru a monotónní sekvenci. Ta sekvence může být created_at timestamp v mikrosekundách, pokud write path garantuje dostatečnou přesnost, ještě lepší je dedikovaný bigint generovaný při insertu. id řeší shody. Řadíte podle sequence desc, id desc a obě hodnoty zabalíte do opaque tokenu.
Takhle vypadá response shape, která funguje dobře:
1{2 "items": [3 { "id": "inv_9f3...", "sequence": 98122314, "total": 4200 },4 { "id": "inv_9f2...", "sequence": 98122313, "total": 1250 }5 ],6 "page": {7 "next": "eyJzZXEiOjk4MTIyMzEzLCJpZCI6Imludl85ZjIuLi4ifQ==",8 "prefetch": [9 "eyJzZXEiOjk4MTIyMzExLCJpZCI6Imludl85ZjAuLi4ifQ=="10 ],11 "hasMore": true12 }13}
V PostgreSQL je query přímočaré a na rozdíl od OFFSET 20000 LIMIT 50 pořád efektivně používá index:
1SELECT id, sequence, total, issued_at2FROM invoices3WHERE account_id = $14 AND (sequence, id) < ($2, $3)5ORDER BY sequence DESC, id DESC6LIMIT 50;
Pod to dejte správný index:
1CREATE INDEX CONCURRENTLY idx_invoices_account_sequence_id2ON invoices (account_id, sequence DESC, id DESC);
To, že je cursor opaque, je důležité, protože client se nemá vázat na vaše sort keys a dřív nebo později je stejně změníte. Opaque ale neznamená náhodný. Pořád potřebujete deterministické kódování a signing. Jakmile půjde cursor podvrhnout, někdo vám pošle garbage a vy strávíte páteční večer debugováním invalid input syntax for type bigint z veřejného endpointu.
Prefetch window držte malé, obvykle jednu stránku dopředu, maximálně dvě na hodně rychlých sítích u feedů s těžším obsahem. Cokoliv většího už se chová jako spekulativní overfetch, což je jen slušnější název pro plýtvání.
next.js 16 mění pravidla hry
Next.js 16 tenhle pattern konečně dělá dost příjemný na to, že už moc nezbývá důvod, proč celý problém dál tlačit do browseru. React Server Components nám správný primitiv dal už dřív, fetch dat na serveru, streamování HTML a payload chunků, tenký client. Co se změnilo, je to, že route-segment caching a cache controls jsou konečně dost dobré na to, abyste kolem server-rendered segmentů stavěli stránkované pohledy, aniž by se z aplikace stala noční můra na invalidaci cache.
Základní myšlenka je jednoduchá. Berte úvodní stránku seznamu jako server concern. První slice vyrenderujte v server componentě, segment na krátkou dobu cacheujte, pokud to data snesou, a clientu předejte cursor pro další slice. Navigace na další segment zůstane rychlá, protože route jde prefetchnout a server už ví, jak ji sestavit. Přestanete shipovat orchestrace pagination do každé browser session a dostanete konzistentní HTML pro boty, pomalá zařízení i uživatele, kteří dají refresh.
Page component může zůstat skoro nudná:
1// app/customers/[customerId]/invoices/page.tsx2import { headers } from 'next/headers'34export default async function InvoicesPage({ params, searchParams }) {5 const { customerId } = await params6 const { after } = await searchParams78 const h = await headers()9 const auth = h.get('authorization')1011 const res = await fetch(12 `${process.env.API_URL}/v1/customers/${customerId}/invoices?limit=50${after ? `&after=${encodeURIComponent(after)}` : ''}`,13 {14 headers: {15 authorization: auth ?? ''16 },17 next: {18 revalidate: 30,19 tags: [`customer:${customerId}:invoices`]20 }21 }22 )2324 if (!res.ok) {25 throw new Error(`Invoices fetch failed: ${res.status} ${await res.text()}`)26 }2728 const data = await res.json()29 return <InvoicesList initialPage={data} customerId={customerId} />30}
To revalidate: 30 stačí pro spoustu dashboardů. U živějších obrazovek to otagujete a při zápisu invalidujete přes revalidateTag('customer:123:invoices') ze server action nebo mutation endpointu. Krátce žijící cache segmentů vám dá rychlé opakované navigace, aniž by si hrála na to, že zastaralá finanční data mají viset deset minut. Přesně v tom je rozdíl mezi užitečnou cache a cargo cult cache.
cache headery, které fakt pomáhají
Většina týmů buď cacheuje málo, nebo lže přes cache headery. Obojí je špatně. Jestli API běží za CDN nebo gatewayí, která respektuje HTTP caching, řekněte to v response jasně. Stránkované resources s cursory jsou výborný kandidát na krátkou shared freshness plus stale-while-revalidate, hlavně u anonymních nebo org-scoped feedů, kde hodně uživatelů sahá na stejné první stránky.
Rozumná sada response headerů vypadá takhle:
1Cache-Control: public, s-maxage=30, stale-while-revalidate=1202Vary: Accept-Encoding, Authorization, X-Org-Id3ETag: "invoices:org_42:after_root:limit_50:v1739029911"4Content-Type: application/json; charset=utf-8
Jestli je endpoint user-specific, neoznačujte ho jako public, pokud cache key stoprocentně zahrnuje auth hranici. Tohle se plete pořád. Jeden chybějící Vary: Authorization a máte hotový data leak s výbornou latencí.
Conditional requests za tu práci stojí. Pokud backend umí levně spočítat ETag navázaný na page cursor a poslední relevantní verzi mutace, klienti a mezilehlé vrstvy mohou posílat If-None-Match, API vrátí 304 Not Modified a vy nebudete každých pár sekund pálit CPU na serializaci stejných řádků. V Go službách to obvykle zapojujeme blízko handleru, v Djangu to držíme explicitně, protože „magický“ middleware má tendenci schovat přesně ty detaily, které pak potřebujete debugovat.
Třeba Next.js route handler v roli BFF může tuhle semantiku předávat dál a zachovat ji:
1export async function GET(req: Request) {2 const upstream = await fetch(`${process.env.API_URL}/v1/feed?${new URL(req.url).searchParams}`, {3 headers: {4 'if-none-match': req.headers.get('if-none-match') ?? ''5 }6 })78 return new Response(upstream.body, {9 status: upstream.status,10 headers: {11 'cache-control': upstream.headers.get('cache-control') ?? 'private, no-store',12 'etag': upstream.headers.get('etag') ?? '',13 'content-type': upstream.headers.get('content-type') ?? 'application/json; charset=utf-8'14 }15 })16}
Důležité je, aby to bylo sladěné. Cache fetchů v server components, route-segment caching, chování CDN a headery z upstream API se musí shodnout na tom, co je fresh. Když jedna vrstva říká 30 sekund a druhá no-store, dostanete matoucí cache misses a hromadu Slack zpráv o náhodně rozbitém výkonu.
prefetch bez přejídání
Prefetch je místo, kde týmy nejčastěji zabijí jinak dobrý server-first návrh. Slyší „prefetchněte další stránku“ a hned při mountu naplánují tři background requesty, jeden navíc při scrollu a další proto, že router si stejně udělal vlastní prefetch. Pak se diví, proč paměť i bandwidth pořád vypadají bídně.
Malé prefetch window stačí. Jedna stránka dopředu pokryje většinu lidského chování, protože lidé čtou sekvenčně. Dvě stránky dopředu můžou dávat smysl u lehkých datasetů na desktopu s rychlým připojením. Cokoliv za tím patří do koše, pokud nemáte tvrdá telemetry data, že uživatelé seznamy konzumují rychleji, než stíhá síť odpovídat. Většinou nestíhají.
V Next.js 16 už route-aware prefetching funguje přes router a Link, takže čisté řešení je prefetchovat další route segment nesoucí cursor, ne si vymýšlet ad hoc browser cache. Pokud URL vypadá jako /invoices?after=opaque_cursor, server může vyrenderovat odkaz na další stránku a framework si ho prefetchnout, jakmile se přiblíží do viewportu.
1import Link from 'next/link'23export function NextPageLink({ nextCursor }: { nextCursor?: string }) {4 if (!nextCursor) return null56 return (7 <Link8 prefetch9 href={`/invoices?after=${encodeURIComponent(nextCursor)}`}10 className="text-sm underline"11 >12 Dalších 5013 </Link>14 )15}
To má dva praktické efekty. Navigace zůstane adresovatelná, což se hodí pro refresh, sdílení i support tickety, a framework si sám řídí životní cyklus prefetchů místo toho, abyste navždy drželi náhodné JSON blob y v client state. Infinite scroll nad tím pořád může existovat, pokud na tom produkt trvá, ale u obrazovek, kde uživatelům záleží na pozici, mám radši viditelný prvek pro pokračování. Scroll-triggered auto-load je v pohodě pro sociální feed. Pro audit logy a faktury je to katastrofa.
Jestli infinite scroll necháte, omezte in-memory window. Starší stránky vyhazujte z renderovaného seznamu, nechte si anchor body pro obnovu pozice a historii cursorů držte zvlášť. Jinak znovu objevíte kouzelné RangeError: Invalid string length nebo tab, který mobile Safari po dvaceti minutách používání bez milosti zabije.
trade-offy a migrace
Tenhle pattern má svoje trade-offy a jsou úplně přijatelné. Cursor pagination trochu komplikuje návrat zpět, protože „předchozí stránka“ není zadarmo, pokud si neukládáte předchozí cursory nebo neumíte reverse traversal. Sort order musí být stabilní, což znamená, že produktové týmy si nemůžou donekonečna vymýšlet nové kombinace řazení přes různé sloupce, aniž by to engineering zaplatil. Cache invalidation samozřejmě pořád existuje, z toho neunikl nikdo, a u hodně volatilních feedů může být potřeba revalidate: 0 pro první segment a selektivní caching jen pro navazující stránky.
I s těmito omezeními je migrační cesta jednodušší, než si většina lidí myslí. Nemusíte přepsat každý seznam v aplikaci. Začněte jednou drahou obrazovkou, typicky route, která vám ve traces svítí tlustým client bundlem a opakovaným XHR churnem. Vedle starého API typu ?page=7 přidejte endpoint s podporou cursorů. Nechte stejný serializer položek, změňte pagination envelope, přidejte kompozitní index a dejte první stránku za server component. Pak vystavte navigaci na další stránku jako URL s cursorem. To stačí na okamžité snížení práce v bundlu, menší browser heap a lepší chování při refreshi.
Backend kontrakt po etapách může vypadat třeba takhle:
1GET /v1/invoices?limit=502GET /v1/invoices?limit=50&after=eyJzZXEiOjk4MTIyMzEzLCJpZCI6Imludl85ZjIuLi4ifQ==
Starý endpoint chvíli nechte běžet:
1GET /v1/invoices?page=7&page_size=502Deprecation: true3Sunset: Tue, 30 Jun 2026 00:00:00 GMT4Link: </v1/invoices?limit=50>; rel="successor-version"
Obě cesty můžete provozovat paralelně, měřit na reálném trafficu a client pager smazat ve chvíli, kdy error rate zůstane stabilní. Ve většině případů je to práce na dva sprinty, ne platformní iniciativa na celý kvartál.
Nejvíc z toho těží týmy, které staví provozní software, interní systémy, zákaznické portály, fronty dokumentů, zkrátka místa, kde uživatelé řeší hlavně kontinuitu a rychlost, ne efektní animace. To je velká část práce, kterou ve steezr děláme, a tenhle pattern se vyplácí pořád dokola, protože odpovídá tomu, jak web ve skutečnosti funguje: nejdřív render na serveru, opatrně cacheovat, držet browser lehký a přestat nutit JavaScript řešit problémy, které backend dávno umí vyřešit líp.
