URL как источник правды в Next.js App Router
- суббота, 28 марта 2026 г. в 00:00:03
Когда разработчик приходит в 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 страница по умолчанию является 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 запроса собрать. Это полезно по двум причинам. Во-первых, страница остаётся компактной. Во-вторых, логика поиска не размазывается по интерфейсу.
Теперь серверная страница читает 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] ?? ""; }
Это мелочь, но именно из таких мелочей складывается надёжность. В учебных примерах про такие вещи часто молчат, а в реальном коде они всплывают очень быстро.
Если форма просто меняет URL и после этого страница должна отрендериться заново, у вас есть два рабочих пути.
Первый путь - обычная GET-форма.
Второй путь - клиентская навигация через router.push().
Для простого поиска оба варианта жизнеспособны. Используем router.push(), когда нужно чуть лучше контролировать UX вокруг поля, кнопок, сброса и синхронизации. Но это не означает, что надо превращать каждый поиск в сложный клиентский механизм.
Важно другое. И GET-форма, и router.push() должны приводить к одному и тому же результату: меняется URL, а не какой-то скрытый локальный флаг.
Не всё состояние нужно тащить в адресную строку. Хорошие кандидаты для URL:
поисковый запрос
сортировка
фильтры каталога
номер страницы пагинации
открытая сущность, если она влияет на адрес
режим, который имеет смысл сохранить или отправить ссылкой
Плохие кандидаты для URL:
открыт ли локальный dropdown
мигает ли анимация
промежуточный текст в поле до отправки
временное состояние hover или focus
локальные UI-флаги, не имеющие смысла вне текущего экрана
Правило - если состояние должно переживать обновление страницы, поддерживать прямую ссылку и корректно работать с историей браузера, почти всегда есть смысл подумать про URL.
Это самый короткий путь к рассинхрону. В адресной строке одно, в поле другое, на экране третье.
В App Router это часто лишнее. Если данные относятся к странице, сначала проверьте, нельзя ли прочитать их прямо в Server Component через searchParams.
Поле ввода важно только до отправки. После отправки главным должно стать уже не поле, а URL.
Полезный сдвиг здесь не технический, а архитектурный. В React SPA часто начинаем от компонента: есть инпут, значит есть состояние, есть состояние, значит есть эффект, есть эффект, значит будет запрос. В App Router полезнее начинать от маршрута: есть URL, значит есть состояние страницы, значит сервер знает, какие данные нужны, значит UI можно собрать уже из результата. Такой подход резко уменьшает количество лишнего client state и делает приложение устойчивее.
Именно поэтому формула URL изменился -> данные обновились так хорошо ложится на App Router. Она не выглядит красивой теорией из документации. Она снимает сразу несколько типовых болей: дублирование состояния, странную навигацию, слабую воспроизводимость и лишний клиентский код.
Если фильтр действительно описывает состояние страницы, держите его в URL.
ссылка становится рабочим состоянием
серверный рендер остаётся естественным
данные не зависят от скрытых клиентских эффектов
история браузера начинает работать в вашу пользу
код становится проще объяснять, тестировать и расширять
Для меня это один из самых практичных входов в App Router. Вроде бы мелкая тема, всего лишь searchParams, но именно на ней у многих впервые собирается цельная картина, как вообще мыслить маршрутами, данными и границей между сервером и клиентом.
Если хотите пройти эти паттерны не по обрывкам из документации, а в последовательной сборке рабочего проекта, можно найти на Stepik курс Next.js I: JavaScript 2026.