javascript

URL как источник правды в Next.js App Router

  • суббота, 28 марта 2026 г. в 00:00:03
https://habr.com/ru/articles/1016068/

Когда разработчик приходит в Next.js из обычного React SPA, он часто тащит с собой старую схему мышления. Есть поле ввода, значит будет useState. Есть поиск, значит будет useEffect. Есть список данных, значит будем следить за изменением состояния и вручную запускать новый запрос.

На маленьком экране это вроде работает. Но очень быстро выясняется, что в приложении уже не одно состояние, а три. Есть значение в поле, значение в URL, данные, загруженные по одному из этих значений. Потом появляется четвёртая проблема. Кнопки Back и Forward начинают вести себя странно. Ссылкой на результат поиска неудобно делиться. А отладка превращается в угадайку, потому что не до конца понятно, что именно сейчас считается главным источником правды.

В App Router это решается проще. Если фильтр является частью состояния страницы, его логично держать в URL. Тогда схема становится прямой: URL изменился -> сервер прочитал searchParams -> выполнил fetch -> отрендерил новый список. В этот момент Next.js начинает восприниматься как понятный инженерный инструмент.

Где обычно начинается путаница

Рассмотрим пример поиска товаров в каталоге. Пользователь вводит запрос, запрос попадает в URL, сервер читает searchParams, делает fetch к API и рендерит уже готовый список.

"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>
    </>
  );
}

На уровне демо это допустимо. Но архитектурно здесь уже есть слабое место. Поле ввода стало главным, а URL вообще ни за что не отвечает. Значит:

  • результат поиска нельзя нормально открыть прямой ссылкой

  • состояние теряется при обновлении страницы

  • Back и Forward работают не так, как ожидает пользователь

  • приходится вручную синхронизировать интерфейс и адресную строку

В App Router это чаще всего просто не нужно.

Более естественная модель для App Router

В App Router страница по умолчанию является Server Component. Значит данные удобно грузить прямо внутри страницы. Не через useEffect, не после первого рендера в браузере, а до рендера, на сервере.

Если запрос поиска уже записан в URL как ?q=phone, то страница может прочитать searchParams.q и сразу получить правильный набор данных. Получается простая и устойчивая схема:

  • URL хранит смысловое состояние страницы

  • сервер читает это состояние

  • сервер получает данные

  • UI рендерится уже из готовых данных

Это особенно хорошо видно на поиске товаров. Если q есть, идём в /products/search?q=.... Если q пустой, идём в обычный /products.

Минимальный серверный слой данных

Ниже упрощённая версия загрузчика. Логика простая: есть q - используем поисковый эндпоинт, нет q - отдаём обычную витрину.

// 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 },
  });
}

Здесь важно не то, что используется именно DummyJSON. Важно, что слой данных принимает параметры уже в нормальной форме и сам решает, какой URL запроса собрать. Это полезно по двум причинам. Во-первых, страница остаётся компактной. Во-вторых, логика поиска не размазывается по интерфейсу.

Страница, где URL реально управляет данными

Теперь серверная страница читает searchParams и делает один понятный вызов:

// 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>Товары</h1>
      <GoodsSearchBar initialQuery={q} />

      {isEmpty ? (
        <div>
          <h2>Ничего не найдено</h2>

          {hasFilter ? (
            <p>По запросу {q} нет товаров</p>
          ) : (
            <p>Список пуст</p>
          )}

          <Link href="/goods">Открыть все товары</Link>
        </div>
      ) : (
        <GoodsGridMotionClient products={data.products} />
      )}
    </div>
  );
}

Ключевой момент здесь - страница не хранит локальное состояние поиска. Она читает значение из URL и строит рендер на его основе. То есть теперь именно URL отвечает на вопрос, какой список должен быть на экране.

Что даёт такой подход на практике

Самая сильная сторона этой схемы не в том, что кода стало меньше, хотя и это приятно. Главное в другом.

Появляется один источник правды. Не нужно гадать, чему верить: инпуту, локальному состоянию или адресу страницы. Если на странице открыт /goods?q=phone, значит текущий фильтр именно phone.

Ссылки становятся осмысленными. Можно отправить ссылку коллеге, открыть её в новом окне, сохранить в закладки. Страница восстановится в нужном состоянии.

Back и Forward начинают вести себя нормально. Пользователь поменял фильтр, ушёл дальше, вернулся назад и увидел ровно то состояние, которое было связано с этим URL.

Серверный рендер остаётся естественным. Не нужно запускать загрузку после рендера через useEffect. Данные приходят до того, как страница будет собрана.

Отладка упрощается. Открыли URL, посмотрели q, воспроизвели состояние. Никаких скрытых клиентских переменных, которые живут отдельно от адресной строки.

Но ведь поле ввода всё равно клиентское

Да, и это нормальная граница. URL может быть источником правды для данных, а поле ввода может жить как локальное клиентское состояние до отправки. Важное разделение.

  • введённый, но ещё не подтверждённый текст может жить в useState

  • подтверждённый фильтр должен жить в URL

  • данные должны грузиться уже по URL

То есть локальное состояние здесь не отменяется. Оно перестаёт притворяться главным. Компонент поиска клиентский, потому что он обрабатывает ввод, кнопки и навигацию. Но после отправки он не загружает данные сам. Он просто меняет 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="например: phone"
      />

      <button type="submit" disabled={!canSearch}>
        Найти
      </button>

      <button type="button" onClick={onReset}>
        Сбросить
      </button>
    </form>
  );
}

Здесь важны две детали.

Первая: после router.push() приложение не начинает вручную грузить данные на клиенте. Оно просто переводит страницу в новый URL, а дальше App Router делает свою работу.

Вторая: поле синхронизируется с useSearchParams(). Это нужно для случаев, когда пользователь ходит по истории браузера через Back и Forward или открывает ссылку напрямую.

Неприятный баг, о котором часто забывают

Одна из мелочей, которая потом неожиданно вылезает в проде, это повторяющийся параметр. Например, пользователь или внешний сервис могут открыть URL такого вида:

/goods?q=phone&q=tv

Если код бездумно берёт параметр, можно получить неожиданное поведение. В клиентском компоненте лучше явно решить, как вы хотите это трактовать. В моём варианте берётся первое значение:

function getFirstQ(sp) {
  const all = sp.getAll("q");
  return all[0] ?? "";
}

Это мелочь, но именно из таких мелочей складывается надёжность. В учебных примерах про такие вещи часто молчат, а в реальном коде они всплывают очень быстро.

Когда router.push, а когда обычный GET

Если форма просто меняет URL и после этого страница должна отрендериться заново, у вас есть два рабочих пути.

Первый путь - обычная GET-форма.
Второй путь - клиентская навигация через router.push().

Для простого поиска оба варианта жизнеспособны. Используем router.push(), когда нужно чуть лучше контролировать UX вокруг поля, кнопок, сброса и синхронизации. Но это не означает, что надо превращать каждый поиск в сложный клиентский механизм.

Важно другое. И GET-форма, и router.push() должны приводить к одному и тому же результату: меняется URL, а не какой-то скрытый локальный флаг.

Когда URL не должен быть источником правды

Не всё состояние нужно тащить в адресную строку. Хорошие кандидаты для URL:

  • поисковый запрос

  • сортировка

  • фильтры каталога

  • номер страницы пагинации

  • открытая сущность, если она влияет на адрес

  • режим, который имеет смысл сохранить или отправить ссылкой

Плохие кандидаты для URL:

  • открыт ли локальный dropdown

  • мигает ли анимация

  • промежуточный текст в поле до отправки

  • временное состояние hover или focus

  • локальные UI-флаги, не имеющие смысла вне текущего экрана

Правило - если состояние должно переживать обновление страницы, поддерживать прямую ссылку и корректно работать с историей браузера, почти всегда есть смысл подумать про URL.

Три частые ошибки

1. Держать одно и то же состояние и в useState и в URL без синхронизации

Это самый короткий путь к рассинхрону. В адресной строке одно, в поле другое, на экране третье.

2. Делать fetch в useEffect, хотя страница и так серверная

В App Router это часто лишнее. Если данные относятся к странице, сначала проверьте, нельзя ли прочитать их прямо в Server Component через searchParams.

3. Считать поле ввода главным состоянием страницы

Поле ввода важно только до отправки. После отправки главным должно стать уже не поле, а URL.

Что в итоге меняется в голове

Полезный сдвиг здесь не технический, а архитектурный. В React SPA часто начинаем от компонента: есть инпут, значит есть состояние, есть состояние, значит есть эффект, есть эффект, значит будет запрос. В App Router полезнее начинать от маршрута: есть URL, значит есть состояние страницы, значит сервер знает, какие данные нужны, значит UI можно собрать уже из результата. Такой подход резко уменьшает количество лишнего client state и делает приложение устойчивее.

Именно поэтому формула URL изменился -> данные обновились так хорошо ложится на App Router. Она не выглядит красивой теорией из документации. Она снимает сразу несколько типовых болей: дублирование состояния, странную навигацию, слабую воспроизводимость и лишний клиентский код.

Небольшой итог

Если фильтр действительно описывает состояние страницы, держите его в URL.

  • ссылка становится рабочим состоянием

  • серверный рендер остаётся естественным

  • данные не зависят от скрытых клиентских эффектов

  • история браузера начинает работать в вашу пользу

  • код становится проще объяснять, тестировать и расширять

Для меня это один из самых практичных входов в App Router. Вроде бы мелкая тема, всего лишь searchParams, но именно на ней у многих впервые собирается цельная картина, как вообще мыслить маршрутами, данными и границей между сервером и клиентом.

Если хотите пройти эти паттерны не по обрывкам из документации, а в последовательной сборке рабочего проекта, можно найти на Stepik курс Next.js I: JavaScript 2026.