Powrót do strony głównej

URL jako źródło stanu Next.js App Router

W Next.js App Router URL staje się jedynym źródłem stanu strony. Komponenty serwerowe czytają searchParams i formują treść przed renderem. Formularz po stronie klienta synchronizuje się z historią przeglądarki bez zbędnych useEffect.

URL zarządza danymi w Next.js App Router
Advertisement 728x90

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:

Google AdInline article slot
"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:

Google AdInline article slot
// 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:

Google AdInline article slot
"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 fetcha
  • getFirstQ() obsługuje powtarzające się parametry

Zalety architektury

  • Jedno źródło prawdy: URL definiuje stan
  • Bezpośrednie linki: /goods?q=phone zawsze 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ą searchParams przed renderowaniem
  • Pole klienckie synchronizuje się z useSearchParams()
  • Obsługa wielokrotnych parametrów przez `getAll()'

— Editorial Team

Advertisement 728x90

Czytaj dalej