URL jako jediný zdroj stavu v Next.js App Router
V Next.js App Router se URL stává klíčovým faktorem pro stav stránky. Místo synchronizace lokálního useState s adresním řádkem čte serverová stránka searchParams a okamžitě generuje obsah. Tím se odstraní nesrovnalost mezi vstupním polem, daty a navigací.
Při hledání produktů funguje schéma takto: URL obsahuje ?q=phone → server extrahuje parametr → provede požadavek na API → vyrenderuje připravený seznam. Zpět/před a přímé odkazy se chovají předvídatelně.
Typické problémy klientského přístupu
Klasický React kód s useEffect vytváří několik zdrojů pravdy:
"use client";
import { useEffect, useState } from "react";
export default function GoodsPage() {
const [q, setQ] = useState("");
const [items, setItems] = useState([]);
useEffect(() => {
async function load() {
const res = await fetch(`/api/goods?q=${encodeURIComponent(q)}`);
const data = await res.json();
setItems(data.products);
}
load();
}, [q]);
return (
<>
<input value={q} onChange={e => setQ(e.target.value)} />
<div>{items.map(item => <div key={item.id}>{item.title}</div>)}</div>
</>
);
}
Následky:
- Přímý odkaz neobnoví stav
- Aktualizace stránky resetuje data
- Zpět/dopředu porušuje UX
- Ruční synchronizace vstupu a URL
Serverová model: data před renderováním
Serverový komponent čte searchParams na serveru a načítá data před hydratací:
- URL uchovává parametry filtrování
- Server analyzuje
searchParams - Provede fetch s cache
- UI se vykreslí z připravených propů
Univerzální datový layer pro produkty:
// src/app/_data/dummyjson.js
const API_BASE = "https://dummyjson.com";
async function fetchJson(url, fetchOptions = {}) {
const res = await fetch(url, fetchOptions);
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(`DummyJSON error: ${res.status} ${res.statusText}. ${text}`);
err.status = res.status;
throw err;
}
return res.json();
}
export async function getProducts({ q = "", limit = 12, skip = 0 } = {}) {
const safeQ = String(q).trim();
const qs = new URLSearchParams({
limit: String(limit),
skip: String(skip),
});
const url = safeQ
? `${API_BASE}/products/search?${qs.toString()}&q=${encodeURIComponent(safeQ)}`
: `${API_BASE}/products?${qs.toString()}`;
return fetchJson(url, {
next: { revalidate: 60 },
});
}
Logika hledání je izolovaná, stránka zůstává čistá.
Implementace serverové stránky
// src/app/(app)/goods/page.js
import Link from "next/link";
import { getProducts } from "@/app/_data/goodsApi";
import GoodsSearchBar from "@/app/_ui/GoodsSearchBar";
import GoodsGridMotionClient from "@/app/_ui/GoodsGridMotionClient";
export default async function GoodsPage({ searchParams }) {
const sp = await searchParams;
const q = typeof sp?.q === "string" ? sp.q : "";
const data = await getProducts({ q, limit: 12, skip: 0 });
const isEmpty = !data?.products?.length;
const hasFilter = !!String(q || "").trim();
return (
<div>
<h1>Produkty</h1>
<GoodsSearchBar initialQuery={q} />
{isEmpty ? (
<div>
<h2>Nic nenalezeno</h2>
{hasFilter ? (
<p>Pro dotaz {q} nejsou žádné produkty</p>
) : (
<p>Seznam je prázdný</p>
)}
<Link href="/goods">Otevřít všechny produkty</Link>
</div>
) : (
<GoodsGridMotionClient products={data.products} />
)}
</div>
);
}
URL plně určuje obsah stránky.
Klientské hledání s synchronizací
Vstupní pole zůstává klientské, ale po potvrzení mění pouze URL:
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
function getFirstQ(sp) {
const all = sp.getAll("q");
return all[0] ?? "";
}
export default function GoodsSearchBar({ basePath = "/goods" }) {
const router = useRouter();
const sp = useSearchParams();
const qFromUrl = getFirstQ(sp);
const [value, setValue] = useState(qFromUrl);
useEffect(() => {
setValue(qFromUrl);
}, [qFromUrl]);
function buildHref(nextQ) {
const q = nextQ.trim();
return q ? `${basePath}?q=${encodeURIComponent(q)}` : basePath;
}
function onSubmit(e) {
e.preventDefault();
router.push(buildHref(value));
}
function onReset() {
router.push(basePath);
}
const canSearch = useMemo(() => value.trim().length > 0, [value]);
return (
<form onSubmit={onSubmit}>
<input
value={value}
onChange={e => setValue(e.target.value)}
placeholder="např. phone"
/>
<button type="submit" disabled={!canSearch}>
Najít
</button>
<button type="button" onClick={onReset}>
Vymazat
</button>
</form>
);
}
Klíčové body:
useSearchParams()synchronizuje pole s historiírouter.push()mění URL bez klientského fetchugetFirstQ()zpracovává duplikáty parametrů
Výhody architektury
- Jediný zdroj pravdy: URL definuje stav
- Přímé odkazy:
/goods?q=phonevždy dá stejný výsledek - Navigace: Zpět/dopředu obnoví přesný stav
- Serverové vykreslování: Data se načítají před hydratací
- Ladění: Stav je viditelný v adresním řádku
Kdy URL nepotřebujeme
Parametry vhodné pro URL:
- Hledací dotazy
- Řazení a filtry
- Stránkování
- Režimy zobrazení
Místní stav:
- Dropdowny/modalní okna
- Animace
- Dočasný vstup
- Hover/fokus
Tři kritické chyby
- Dvojí stav bez synchronizace
useState+ URL - Klientský fetch ve serverové stránce přes
useEffect - Vstup jako hlavní zdroj místo URL po odeslání
Co je důležité:
- URL by mělo definovat data stránky v App Router
- Serverové komponenty čtou
searchParamspřed vykreslením - Klientské vstupní pole se synchronizuje s
useSearchParams() - Mnohonásobné parametry zpracovávejte prostřednictvím
getAll()
— Editorial Team
Zatím žádné komentáře.