Middleware в Next.js 15: 301-редиректы, trailing slash и миграция URL без потери SEO
Лендинг выходит в прод на Vercel. Через неделю в Search Console — 47 URL с «Страница с переадресацией», 12 дублей /pricing и /pricing/, а старые ссылки с /services/web-dev ведут на 404. Canonical вы прописали в metadata, sitemap собрали, JSON-LD добавили — но краулер всё равно обходит мусорные варианты URL, тратит crawl budget и размывает сигнал на каноническую страницу.
Проблема не в контенте и не в Core Web Vitals. Проблема в том, что редиректы на уровне CDN или nginx настроены частично, а App Router по умолчанию не знает про ваши legacy-пути. next.config.js redirects работают, но не покрывают динамические кейсы: A/B-префиксы, гео-поддомены, нормализацию регистра в query string.
Middleware в Next.js 15 — edge-функция, которая выполняется до рендера страницы. Она идеальна для SEO-инфраструктуры лендинга: один источник правды для 301/308, trailing slash, www → apex, http → https (если не делает Vercel автоматически), редирект старых URL после редизайна.
В этой статье — production-паттерны middleware для SEO-лендинга на Next.js 15: архитектура, код, типичные ловушки и проверка в Search Console.
Почему редиректы — часть SEO, а не «настройка сервера»
Google индексирует URL как идентификатор документа. Два URL с одинаковым контентом — два кандидата в индекс. Canonical подсказывает предпочтительный вариант, но:
- краулер всё равно заходит на оба URL и тратит quota;
- внешние ссылки могут указывать на «неправильный» вариант;
- при смене структуры без 301 ссылочный вес не передаётся на новый адрес.
| Ситуация | Без middleware | С 301 на edge |
|---|---|---|
/about/ и /about | Два URL в индексе | Один канонический |
Старый /blog/post-slug после миграции | 404, потеря трафика | 301 → /articles/post-slug |
www.example.com | Дубль apex-домена | 301 → example.com |
/Pricing (регистр) | Редкий, но возможный дубль | 301 → /pricing |
Для коммерческого лендинга с 5–15 страницами crawl budget не критичен. Для programmatic SEO (100+ landing pages по городам или нишам) — каждый лишний URL заметен. Middleware масштабируется: одна функция на все маршруты, latency < 5 ms на Vercel Edge.
301 vs 308: для SEO постоянного переноса используйте 301 (или 308 для POST-safe редиректов). 302/307 говорят «временно» — Google может не передать весь сигнал. Next.js NextResponse.redirect(url, 301) — стандарт для миграций.
Базовая структура middleware.ts в App Router
Файл middleware.ts лежит в корне проекта (рядом с app/). Matcher ограничивает, на каких путях функция запускается — исключайте /_next, статику, API webhooks.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
const { pathname, search } = url;
const host = request.headers.get('host') ?? '';
// 1. www → apex (настройте и DNS, и canonical одинаково)
if (host.startsWith('www.')) {
url.host = host.replace(/^www\./, '');
return NextResponse.redirect(url, 301);
}
// 2. Trailing slash: единый стиль без слэша (или наоборот — главное consistency)
if (pathname !== '/' && pathname.endsWith('/')) {
url.pathname = pathname.slice(0, -1);
return NextResponse.redirect(url, 301);
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Все пути кроме:
* - api (если не нужны редиректы)
* - _next/static, _next/image
* - favicon, robots, sitemap (опционально)
*/
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)',
],
};
Ключевые моменты:
request.nextUrl.clone()— не мутируйте оригинал напрямую; клонируйте и меняйтеpathname/host.- Matcher — без него middleware бежит на каждый asset и добавляет latency. Regex выше — распространённый шаблон из документации Next.js.
- Edge Runtime — middleware всегда на edge, не на Node.js server. Нет доступа к файловой системе и некоторым Node API — карта редиректов держите в коде, Edge Config или KV.
Согласованность с metadata и sitemap
Если middleware убирает trailing slash, то:
- в
generateMetadatacanonical без слэша:https://example.com/pricing; - в
sitemap.ts— те же URL; - внутренние
<Link href="/pricing">— без слэша.
Три источника, один формат. Расхождение canonical vs фактический URL после редиректа — warning в Search Console.
Если нужна подобная разработка — напишите в Telegram.
Миграция legacy URL: карта 301 без next.config bloat
После редизайна лендинга старые URL из Google и бэклинков должны жить. redirects() в next.config.ts подходит для десятка правил; для сотен — неудобно пересобирать проект при каждом добавлении.
Статическая карта в middleware
// lib/legacy-redirects.ts
const LEGACY_REDIRECTS: Record<string, string> = {
'/services/web-development': '/services/websites',
'/services/web-dev': '/services/websites',
'/portfolio': '/cases',
'/blog': '/articles',
// slug-level — отдельный объект или regex
};
export function getLegacyRedirect(pathname: string): string | null {
return LEGACY_REDIRECTS[pathname] ?? null;
}
// middleware.ts (фрагмент)
import { getLegacyRedirect } from '@/lib/legacy-redirects';
export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
const legacy = getLegacyRedirect(url.pathname);
if (legacy) {
url.pathname = legacy;
return NextResponse.redirect(url, 301);
}
// ... остальные правила
}
Query string: при редиректе UTM-метки часто нужно сохранить. NextResponse.redirect с url.search из clone — параметры переносятся автоматически, если вы не обнуляете search.
Динамические slug: regex-правила
Старый формат /blog/2024/my-post → новый /articles/my-post:
const BLOG_LEGACY = /^\/blog\/(\d{4})\/(.+)$/;
function matchBlogLegacy(pathname: string): string | null {
const m = pathname.match(BLOG_LEGACY);
if (!m) return null;
return `/articles/${m[2]}`;
}
Порядок правил важен: сначала точные совпадения, потом regex, потом trailing slash. Иначе /blog/2024/post/ может пройти slash-нормализацию до legacy-match.
Edge Config для маркетинга без деплоя
Команда маркетинга просит редирект /promo-winter → /pricing?ref=winter без релиза. Vercel Edge Config хранит JSON-карту; middleware читает её на edge с кешем.
import { get } from '@vercel/edge-config';
export async function middleware(request: NextRequest) {
const promos = await get<Record<string, string>>('redirects');
const target = promos?.[request.nextUrl.pathname];
if (target) {
return NextResponse.redirect(new URL(target, request.url), 302); // временная акция — 302
}
// ...
}
Для постоянной миграции URL — 301 в коде с code review. Для кампаний — 302 из Edge Config.
Локализация, регистр и опасные паттерны
Locale prefix без дублей
Мультиязычный лендинг: /ru/pricing и /en/pricing. Middleware может:
- определять язык по
Accept-Languageи редиректить/pricing→/ru/pricing(осторожно с SEO — лучше явные hreflang, см. отдельную статью); - блокировать несуществующие локали:
/xx/pricing→ 301 на/en/pricing.
Не смешивайте авто-редирект по IP с индексацией: Googlebot из США может не увидеть /ru/, если редиректите всех на /en/. Для ботов — NextResponse.next() без гео-редиректа или отдельная логика по User-Agent (Google рекомендует один URL для всех).
Lowercase pathname
На case-sensitive файловых системах /About и /about — разные маршруты. Нормализация:
const lower = pathname.toLowerCase();
if (pathname !== lower) {
url.pathname = lower;
return NextResponse.redirect(url, 301);
}
Исключите API и пути с case-sensitive ID, если они есть.
Чего не делать
| Антипаттерн | Риск |
|---|---|
| Цепочка 301 → 301 → 301 | Crawl budget, потеря ~15% сигнала на каждый hop |
| 301 на страницу, которая сама 301 | Loop, URL excluded from index |
Редирект / на /home | Главная должна быть / |
| Middleware с тяжёлым fetch на каждый запрос | TTFB растёт, страдает LCP |
| 302 вместо 301 при смене slug навсегда | Google может индексировать старый URL |
Правило: максимум один hop от любого legacy URL до финального канонического.
Проверка: curl, Lighthouse, Search Console
Локальная и staging-проверка
# Статус и Location header
curl -I https://staging.example.com/services/web-dev
# Ожидаем: HTTP/2 301, location: /services/websites
Проверьте цепочку:
curl -IL https://www.example.com/Pricing/
# Должен закончиться одним 200 на https://example.com/pricing
Флаг -L следует редиректам; если больше двух 301 — упростите правила.
Search Console после деплоя
- Страницы → Исключено → «Страница с переадресацией» — нормально для legacy URL; плохо, если канонический URL сам в этом статусе.
- Проверка URL — «URL доступен Google» для нового адреса; для старого — «URL перенаправляет».
- Sitemap — только финальные URL без редиректов.
Vercel Analytics и логи
В Vercel → Logs фильтр status:301 покажет топ редиректов. Если /undefined или странные query — баг в middleware. Speed Insights не страдает от лёгкого middleware, но тяжёлый Edge Config fetch на cold start добавляет десятки ms — кешируйте.
Интеграция с next.config redirects
Дублируйте правила либо в middleware, либо в config — не в обоих. next.config redirects выполняются на другом уровне; двойной 301 возможен при ошибке. Для SEO-лендинга предпочтите middleware (гибкость, Edge Config) и держите в config только images, headers.
Нужна помощь? Telegram → или vic.kell@ya.ru
FAQ
Middleware или redirects в next.config — что выбрать?
redirects в config — декларативно, без логики, пересборка при изменении. Middleware — условная логика, regex, Edge Config, один hop на edge. Для SEO-миграций и нормализации URL выбирайте middleware; для пары постоянных правил достаточно config.
Trailing slash: со слэшем или без?
Google не penalize ни тот, ни другой вариант — важна консistency. Next.js по умолчанию без trailing slash. Выберите стиль, пропишите в middleware, canonical, sitemap и <Link>. Не смешивайте.
Редирект http → https нужен в middleware?
На Vercel HTTPS принудительный на уровне платформы. В middleware дублировать не обязательно. На self-hosted — да, первое правило в middleware или reverse proxy.
Влияет ли middleware на Core Web Vitals?
Лёгкая функция (< 1 ms CPU) на edge не влияет на LCP/INP. Тяжёлые await-fetch, JWT-verify на каждый статический asset — влияют на TTFB. Ограничивайте matcher.
Как редиректить только для Googlebot?
Не рекомендуется (cloaking). Все пользователи и боты должны видеть одинаковые 301. Исключение — технические URL (/wp-admin → /), не контентные варианты.
301 и A/B-тесты на разных URL
Если тестируете /pricing-a vs /pricing-b как отдельные URL — оба могут попасть в индекс. Для SEO используйте один URL и client-side или server-side split без индексации вариантов (noindex на альтернативах) или canonical на победителя после теста.
Заключение
SEO-лендинг на Next.js 15 — это не только metadata и Core Web Vitals. Чистая URL-архитектура через middleware на edge предотвращает дубли, сохраняет ссылочный вес при миграциях и снижает шум в Search Console. Один файл middleware.ts с matcher, картой legacy-редиректов и единым правилом trailing slash согласуется с canonical и sitemap — и работает на Vercel за миллисекунды.
Перед каждым редизайном структуры соберите таблицу старых URL из Analytics и Search Console, покройте 301, проверьте цепочки через curl -IL и обновите sitemap только финальными адресами. Так миграция не обнуляет месяцы SEO-работы — даже если hero, шрифты и JSON-LD уже настроены идеально.