Когда проект на Next.js только начинается, прямой fetch во внешний API кажется самым коротким путём. Есть страница списка, есть поиск, есть внешний источник данных, значит можно сходить туда напрямую и сразу показать результат.
На маленьком экране это работает. Но дальше почти всегда начинаются одинаковые проблемы. Интерфейс начинает зависеть от чужой структуры ответа. Ошибки приходят в форме, которую неудобно показывать пользователю. Параметры запроса приходится валидировать в UI. Потом появляется базовый URL, который не хочется держать строкой в коде. Затем появляется ключ или другой секрет, который уже нельзя светить в браузере. В этот момент становится видно, что между UI и внешним API не хватает серверной границы.
В App Router такую роль часто закрывает Route Handlers. В официальной документации Next.js они описаны как пользовательские обработчики запросов внутри app directory на основе стандартных Web Request и Response API. Они доступны только в app, по смыслу заменяют старые API Routes из pages и поддерживают GET, POST, PUT, PATCH, DELETE, HEAD и OPTIONS. (Next.js)
Смысл Route Handlers в том, что они позволяют собрать внутренний серверный контур прямо внутри приложения. Через него можно проксировать внешний API, нормализовать ответ под нужды UI, спрятать env-переменные и секреты, а также вернуть в интерфейс уже свой контракт, а не чужой.
Где прямой fetch начинает мешать
Типовой старт выглядит так:
"use client";import { useEffect, useState } from "react";export default function GoodsPage() { const [items, setItems] = useState([]); useEffect(() => { async function load() { const res = await fetch("https://dummyjson.com/products?limit=12"); const data = await res.json(); setItems(data.products); } load(); }, []); return ( <ul> {items.map((item) => ( <li key={item.id}>{item.title}</li> ))} </ul> );}
На уровне демо здесь нет ничего страшного. Но архитектурно такая схема быстро начинает тянуть лишнее. Компонент знает внешний адрес. Компонент знает форму внешнего ответа. Компонент сам решает, что делать с ошибками. Если завтра у внешнего API поменяется поле или правило пагинации, править придётся UI. Если у запроса появится токен, клиент уже перестанет быть правильным местом для этой логики.
В какой-то момент встает другой вопрос. Почему интерфейс вообще должен знать про dummyjson.com, если ему нужен не внешний сервис как таковой, а просто список товаров в понятной внутренней форме.
Route Handler как свой серверный маршрут
Для примера, в проекте Goods Finder этот переход сделан через собственный маршрут /api/goods. Он лежит в src/app/api/goods/route.js и становится внутренней серверной точкой входа для списка товаров.
Упрощённая версия выглядит так:
// src/app/api/goods/route.jsimport { getDummyJsonBaseUrl } from "@/app/_config/env";function toListItem(p) { return { id: p.id, title: p.title, price: p.price, thumbnail: p.thumbnail, category: p.category, brand: p.brand, rating: p.rating, };}function normalizeGoodsResponse(raw, { q, limit, skip }) { const products = Array.isArray(raw?.products) ? raw.products : []; return { items: products.map(toListItem), page: { total: Number(raw?.total ?? products.length), limit, skip, }, query: { q }, };}function badRequest(details) { return Response.json( { ok: false, error: "Bad Request", details }, { status: 400 } );}function serverError(details) { return Response.json( { ok: false, error: "Server Error", details }, { status: 500 } );}function parseIntParam(name, raw, { def, min, max }) { if (raw === null) return { ok: true, value: def }; if (!/^-?\d+$/.test(raw)) { return { ok: false, details: { field: name, message: `Parameter "${name}" must be an integer` }, }; } const value = Number(raw); if (!Number.isFinite(value)) { return { ok: false, details: { field: name, message: `Parameter "${name}" is not a valid number` }, }; } if (value < min || value > max) { return { ok: false, details: { field: name, message: `Parameter "${name}" must be in range ${min}..${max}`, }, }; } return { ok: true, value };}export async function GET(request) { const API_BASE = getDummyJsonBaseUrl(); const url = new URL(request.url); const q = (url.searchParams.get("q") || "").trim(); const limitParsed = parseIntParam("limit", url.searchParams.get("limit"), { def: 12, min: 1, max: 100, }); if (!limitParsed.ok) return badRequest(limitParsed.details); const skipParsed = parseIntParam("skip", url.searchParams.get("skip"), { def: 0, min: 0, max: 10000, }); if (!skipParsed.ok) return badRequest(skipParsed.details); const limit = limitParsed.value; const skip = skipParsed.value; const upstream = q ? new URL(`${API_BASE}/products/search`) : new URL(`${API_BASE}/products`); upstream.searchParams.set("limit", String(limit)); upstream.searchParams.set("skip", String(skip)); if (q) upstream.searchParams.set("q", q); try { const res = await fetch(upstream.toString()); if (!res.ok) { const text = await res.text().catch(() => ""); return serverError({ message: "Upstream API responded with error", upstreamStatus: res.status, upstreamStatusText: res.statusText, upstreamBodyPreview: text.slice(0, 200), }); } const raw = await res.json(); const data = normalizeGoodsResponse(raw, { q, limit, skip }); return Response.json({ ok: true, ...data }, { status: 200 }); } catch (err) { return serverError({ message: "Failed to load data from upstream API", name: err?.name || "Error", error: err?.message || String(err), }); }}
Обработчик не просто проксирует запрос дальше, а в приложении появляется свой внутренний контракт. Интерфейс больше не живёт в логике внешнего API. Он живёт в логике собственного маршрута /api/goods.
Нормализация ответа полезнее простого прокси
На небольших проектах часто делают Route Handler, который просто повторяет внешний JSON как есть. Формально это уже работает, но архитектурный выигрыш получается слабым. Такой слой ничего не защищает и ничего не упрощает.
Лучше, когда обработчик нормализует ответ под нужды UI. В примере выше внешний products превращается в внутренний items, поля режутся до нужного минимума, а пагинация собирается в свою структуру page.
Это даёт сразу несколько эффектов. Интерфейс получает ровно ту форму данных, которая ему нужна. Замена внешнего API становится менее болезненной. Компоненты перестают тащить в себя лишние поля и правила чужого сервиса. А самое главное, весь договор между UI и серверной частью теперь находится внутри проекта, а не снаружи.
На этом шаге Route Handler и начинает ощущаться как мини-бэкенд, а не просто как новый адрес для fetch.
Свои статусы и ошибки вместо чужого хаоса
Ещё одна полезная вещь в Route Handlers это контроль над статусами и ошибками.
Если параметр limit пришёл в плохом формате, нет смысла отправлять интерфейс в гадание. Можно сразу вернуть предсказуемый 400 Bad Request. Если внешний сервис упал, нет смысла размазывать его странный текст по UI. Можно вернуть свой 500 Server Error с нужным техническим описанием.
В документации Next.js для Route Handlers прямо используется Response.json() как штатный способ вернуть JSON-ответ. Это и есть та точка, где удобно задавать свой HTTP-контракт. (Next.js)
UI начинает работать не с чужими статусами как получится, а со своими предсказуемыми сценариями. Есть успешный ответ. Есть плохой запрос. Есть серверная ошибка. Есть 404 для детали товара. Это уже не хаотический fetch из браузера, а нормальная серверная граница.
После этого UI переводится на свой /api/…
Как только внутри проекта появился свой маршрут /api/goods, логично перевести страницы на запросы к нему, а не к внешнему сервису напрямую.
В проекте для этого используется отдельный слой src/app/_data/goodsApi.js:
// src/app/_data/goodsApi.jsimport { headers } from "next/headers";const DEFAULT_REVALIDATE_SECONDS = 60;async function getOrigin() { const h = await headers(); const host = h.get("host"); const proto = h.get("x-forwarded-proto") || "http"; if (!host) return "http://localhost:3000"; return `${proto}://${host}`;}async function fetchFromApi(path, init = {}) { const origin = await getOrigin(); const url = `${origin}${path}`; const res = await fetch(url, init); if (!res.ok) { const text = await res.text().catch(() => ""); const err = new Error(`API 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), }); if (safeQ) qs.set("q", safeQ); const data = await fetchFromApi(`/api/goods?${qs.toString()}`, { next: { revalidate: DEFAULT_REVALIDATE_SECONDS }, }); if (Array.isArray(data.items)) { return { products: data.items, total: data.total ?? data.items.length, limit: data.limit ?? limit, skip: data.skip ?? skip, }; } return data;}export async function getProductById(id) { const safeId = encodeURIComponent(String(id)); const data = await fetchFromApi(`/api/goods/${safeId}`, { next: { revalidate: DEFAULT_REVALIDATE_SECONDS }, }); return data.item ?? data;}
Здесь особенно интересен момент с headers(). Серверная часть не хардкодит origin, а собирает его из текущего запроса. Это удобно и для локальной разработки, и для выкладки, где у приложения уже другой домен и другой протокол.
В итоге страница списка больше не знает ни про dummyjson.com, ни про структуру его ответа. Для неё существует только внутренний серверный маршрут проекта.
Env здесь не отдельная тема, а продолжение той же идеи
Как только появляется внутренний серверный контур, сразу становится естественно вынести базовый URL внешнего API в env.
В проекте это сделано через src/app/_config/env.js:
// src/app/_config/env.jsexport function requireEnv(name) { const value = process.env[name]; if (!value) { throw new Error( `Missing env "${name}". Add it to .env.local and restart the dev server.` ); } return value;}export function getDummyJsonBaseUrl() { return requireEnv("DUMMYJSON_API_BASE_URL");}
Этот кусок решает сразу несколько задач. Базовый URL перестаёт жить строкой внутри обработчика. Ошибка отсутствующей переменной становится понятной и ранней. И главное, сама конфигурация остаётся в серверной зоне, а не утекает в клиентский код.
В официальной документации Next.js это сформулировано, environment variables по умолчанию доступны только на сервере. Чтобы значение попало в браузер, его нужно специально префиксовать через NEXT_PUBLIC_. (Next.js)
Из этого следует правило. Всё, что должно быть секретом или хотя бы просто не должно жить в браузере, должно читаться на сервере. Route Handlers для этого подходят естественно.
Где нужен NEXT_PUBLIC_* и почему это не просто удобный префикс
Многие путают серверную переменную и публичную переменную как будто разницы между ними почти нет. На практике разница принципиальная.
В демонстрационном клиентском компоненте проекта это видно буквально в лоб:
"use client";export default function EnvPublicClient({ hasSecretOnServer }) { const publicValue = process.env.NEXT_PUBLIC_ENV_DEMO_PUBLIC; const secretValueInBrowser = process.env.ENV_DEMO_SECRET; return ( <main className="mx-auto max-w-2xl space-y-4 p-6"> <h1 className="text-xl font-semibold">Env demo: NEXT_PUBLIC_</h1> <div className="rounded-md border p-4"> <p className="text-sm text-slate-600">NEXT_PUBLIC_ENV_DEMO_PUBLIC:</p> <p className="font-mono">{String(publicValue)}</p> </div> <div className="rounded-md border p-4"> <p className="text-sm text-slate-600">ENV_DEMO_SECRET (на сервере):</p> <p className="font-mono">{hasSecretOnServer ? "есть (скрыто)" : "нет"}</p> </div> <div className="rounded-md border p-4"> <p className="text-sm text-slate-600">ENV_DEMO_SECRET (в браузере):</p> <p className="font-mono">{String(secretValueInBrowser)}</p> </div> </main> );}
Публичная переменная читается в браузере. Серверная переменная в клиентском коде остаётся undefined. Именно так и должно быть.
В документации Next.js отдельно сказано, что NEXT_PUBLIC_* inlined в клиентский JavaScript bundle во время next build. То есть это уже не просто удобное обозначение, а осознанное решение встроить значение в клиентскую сборку. После сборки такие значения не становятся автоматически гибкими runtime-переменными. (Next.js)
Поэтому NEXT_PUBLIC_* стоит использовать не как быстрый способ вытащить любую переменную в браузер, а как явное решение, это значение действительно должно стать публичной частью клиентского кода.
Route Handler и env вместе дают уже нормальный контур
Когда эти две темы складываются вместе, получается удобная схема.
UI не знает внешний сервис напрямую.
Route Handler работает как серверная граница.
Env-переменные остаются на сервере.
Ответ нормализуется под нужды интерфейса.
Ошибки и статусы возвращаются в своей форме.
Это и есть тот уровень, на котором Next.js начинает ощущаться как удобная среда для небольшого fullstack-контура. Не отдельный большой backend, но уже и не прямой fetch из браузера во внешний мир.
В официальной документации это хорошо читается в двух местах. Route Handlers дают собственные non-UI responses на основе Web Request/Response APIs, а environment variables по умолчанию остаются серверными, если их специально не сделать публичными через NEXT_PUBLIC_*. (Next.js)
Итог
Route Handlers полезно воспринимать как маленький внутренний backend для конкретной зоны задач. Через него удобно провести границу между UI и внешним API, задать свой контракт ответа, вернуть свои статусы ошибок и удержать секреты в серверной зоне.
А env-переменные в этой картине часть той же архитектуры. Сервер читает то, что должно остаться серверным. Клиент получает только то, что действительно нужно сделать публичным.
Поэтому связка Route Handlers плюс env так хорошо работает в учебных и небольших production-проектах. Она не перегружает приложение отдельным backend-сервисом, но при этом уже снимает половину бытовых проблем, которые возникают после прямого fetch из клиента.
Для примеров в статье использован живой проект Goods Finder.
Полная последовательная сборка этих паттернов разобрана в Stepik-курсе Next.js I: JavaScript 2026.
ссылка на оригинал статьи https://habr.com/ru/articles/1042048/