URL jako jedynym źródłem stanu w Next.js App Router
W Next.js App Router adres URL staje się kluczowym elementem stanu strony. Zamiast synchronizować lokalny useState z paskiem adresu, strona serwerowa odczytuje searchParams i od razu generuje zawartość. To eliminuje rozbieżności między polem wprowadzania danych, danymi i nawigacją.
Przy wyszukiwaniu produktów schemat działa następująco: URL zawiera ?q=phone → serwer wyodrębnia parametr → wykonuje żądanie do API → renderuje gotową listę. Przyciski cofnij/dalej oraz bezpośrednie linki działają przewidywalnie.
Typowe problemy podejścia klienckiego
Klasyczny kod React z useEffect tworzy wiele źródeł prawdy:
"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>
</>
);
}
Skutki:
- bezpośredni link nie przywraca stanu
- odświeżenie strony kasuje dane
- przyciski cofnij/dalej psują UX
- ręczna synchronizacja pola i URL
Model serwerowy: dane przed renderowaniem
Komponent serwerowy odczytuje searchParams na serwerze i pobiera dane jeszcze przed hydrowaniem:
- URL przechowuje parametry filtrowania
- Serwer parsuje
searchParams - Wykonuje fetch z cache’em
- UI renderowany z gotowymi propsami
Uniwersalna warstwa danych dla produktów:
// 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 wyszukiwania jest izolowana, a strona pozostaje czysta.
Realizacja strony serwerowej
// 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>Towary</h1>
<GoodsSearchBar initialQuery={q} />
{isEmpty ? (
<div>
<h2>Nic nie znaleziono</h2>
{hasFilter ? (
<p>Brak produktów dla frazy {q}</p>
) : (
<p>Lista jest pusta</p>
)}
<Link href="/goods">Otwórz wszystkie towary</Link>
</div>
) : (
<GoodsGridMotionClient products={data.products} />
)}
</div>
);
}
URL całkowicie definiuje zawartość strony.
Wyszukiwanie klienckie z synchronizacją
Pole wprowadzania pozostaje klienckie, ale po potwierdzeniu zmienia tylko 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="np. telefon"
/>
<button type="submit" disabled={!canSearch}>
Szukaj
</button>
<button type="button" onClick={onReset}>
Resetuj
</button>
</form>
);
}
Kluczowe aspekty:
useSearchParams()synchronizuje pole z historiąrouter.push()zmienia URL bez klienckiego fetchagetFirstQ()obsługuje powtarzające się parametry
Zalety architektury
- Jedno źródło prawdy: URL definiuje stan
- Bezpośrednie linki:
/goods?q=phonezawsze daje ten sam wynik - Nawigacja: cofnij/dalej przywraca dokładny stan
- Renderowanie serwerowe: dane są pobierane przed hydrowaniem
- Debugowanie: stan widoczny w pasku adresu
Kiedy URL nie jest potrzebny
Dobre kandydaty do URL:
- Zapytania wyszukiwania
- Sortowanie i filtry
- Paginacja
- Tryby wyświetlania
Stan lokalny:
- Menu rozwijane / okienka modalne
- Animacje
- Tymczasowe dane wejściowe
- Hover/focus
Trzy krytyczne błędy
- Podwójny stan bez synchronizacji:
useState+ URL - Klientski fetch w stronie serwerowej przez
useEffect - Pole jako głównym źródłem zamiast URL po submitcie
Co ważne:
- URL powinien definiować dane strony w App Router
- Komponenty serwerowe odczytują
searchParamsprzed renderowaniem - Pole klienckie synchronizuje się z
useSearchParams() - Obsługa wielokrotnych parametrów przez `getAll()'
— Editorial Team
Brak komentarzy.