fix(seo): routing and 404 status codes #32

Merged
slaid098 merged 3 commits from fix/seo/routing-and-404-codes into main 2026-08-07 20:22:15 +03:00
Owner

Что сделано

Исправлены 5 SEO-багов из issue #29:

  1. Matcher граница locale-сегмента (src/middleware.ts) — negative lookahead |ru|en заменён на (?!(?:ru|en)(?:/|$)). Теперь /rusds12312, /enfoo, /russia, /energy — middleware РАБОТАЕТ → редирект на /ru/rusds12312 (далее hard 404 через dynamicParams=false). /ru, /ru/apps, /en/foo — исключены. Проверено regex-тестом (17 кейсов, все pass).

  2. Редиректы 308 permanent (src/middleware.ts) — все NextResponse.redirect(new URL(...))NextResponse.redirect(new URL(...), 308). Тесты обновлены: 307 → 308 (8 кейсов).

  3. dynamicParams = false (src/app/[locale]/layout.tsx) — unknown locale (/de, /fr, /xyz) → hard 404 (через static generation, без рендера home). Build подтверждает: /[locale] SSG prerendered только для /ru, /en.

  4. Удалён soft-404 route (src/app/[locale]/%5F%5F404%5F%5F/page.tsx) — /__404__ отдавал HTTP 200 (textbook soft 404). Подтверждено через context7 (Next.js 15.1.8 docs): notFound() выставляет HTTP 404 через NEXT_HTTP_ERROR_FALLBACK;404 digest → server sets statusCode=404. Route удалён; notFound() в [slug]/page.tsx:70 теперь отдаёт hard 404 с локализованным контентом через [locale]/not-found.tsx.

  5. Canonical использует реальный locale (src/app/[locale]/page.tsx) — isLocale(locale) ? locale : DEFAULT_LOCALEif (!isLocale(locale)) notFound(); const loc = locale. Defense-in-depth: fallback на DEFAULT_LOCALE убран, canonical всегда реальный сегмент. После dynamicParams=false fallback-кейс недостижим, но guard защищает от регрессии.

  6. Удалён dead code (src/middleware.ts) — LEGACY_REDIRECTS дублировал next.config.ts redirects (unreachable: next.config redirects запускаются BEFORE middleware). Связанные тесты удалены (блок «middleware legacy redirects»).

  7. Root not-found.tsx (src/app/not-found.tsx) — redirect(/${DEFAULT_LOCALE}/__404__) заменён на рендер локализованного 404 на основе Accept-Language (через headers()). Root not-found не участвует в ISR (рендерится только при 404), поэтому headers() безопасен (в отличие от cookies() — ADR-026).

Почему

  • Soft 404 (HTTP 200 на невалидных URL) — Google флагает как indexable junk, теряет доверие к сайту.
  • 307 temporary — Google не передаёт link equity, индексирует оба URL (source + target).
  • Matcher |ru|en — матчит любой путь, начинающийся с ru/en (не ровно ru/en), ломает /russia, /energy etc.
  • dynamicParams = true (default) — unknown locale рендерится динамически с home контентом + wrong canonical.
  • Wrong canonical/rusds12312 рендерил canonical /ru (чужой URL).

Watch out

  • headers() в root not-found.tsx — делает root 404 dynamic. Это безопасно: root not-found НЕ участвует в ISR/SSG страниц (рендерится только при 404 вне locale segment). ADR-026 (cookies() ломает ISR) — НЕ применяется (cookies не используются, и not-found не в ISR path).
  • .next cache — после удаления %5F%5F404%5F%5F route, tsc --noEmit падал на stale .next/types/.../%5F%5F404%5F%5F/page.ts (Cannot find module). Лечится rm -rf .next. CI чистый билд не имеет этой проблемы.
  • Build output/__404__ route исчез из build map (подтверждено). /[locale] помечен ● (SSG) с prerender только /ru, /en.
  • /RU (uppercase) — вне scope (отдельная issue). Matcher case-sensitive, /RU НЕ исключается → middleware редиректит → /ru/RU → 404 (через dynamicParams=false). Актуально для контракта, но не в спеке.

Pending

Closes #29

## Что сделано Исправлены 5 SEO-багов из issue #29: 1. **Matcher граница locale-сегмента** (`src/middleware.ts`) — negative lookahead `|ru|en` заменён на `(?!(?:ru|en)(?:/|$))`. Теперь `/rusds12312`, `/enfoo`, `/russia`, `/energy` — middleware РАБОТАЕТ → редирект на `/ru/rusds12312` (далее hard 404 через `dynamicParams=false`). `/ru`, `/ru/apps`, `/en/foo` — исключены. Проверено regex-тестом (17 кейсов, все pass). 2. **Редиректы 308 permanent** (`src/middleware.ts`) — все `NextResponse.redirect(new URL(...))` → `NextResponse.redirect(new URL(...), 308)`. Тесты обновлены: 307 → 308 (8 кейсов). 3. **`dynamicParams = false`** (`src/app/[locale]/layout.tsx`) — unknown locale (`/de`, `/fr`, `/xyz`) → hard 404 (через static generation, без рендера home). Build подтверждает: `/[locale]` SSG prerendered только для `/ru`, `/en`. 4. **Удалён soft-404 route** (`src/app/[locale]/%5F%5F404%5F%5F/page.tsx`) — `/__404__` отдавал HTTP 200 (textbook soft 404). Подтверждено через context7 (Next.js 15.1.8 docs): `notFound()` выставляет HTTP 404 через `NEXT_HTTP_ERROR_FALLBACK;404` digest → server sets `statusCode=404`. Route удалён; `notFound()` в `[slug]/page.tsx:70` теперь отдаёт hard 404 с локализованным контентом через `[locale]/not-found.tsx`. 5. **Canonical использует реальный locale** (`src/app/[locale]/page.tsx`) — `isLocale(locale) ? locale : DEFAULT_LOCALE` → `if (!isLocale(locale)) notFound(); const loc = locale`. Defense-in-depth: fallback на DEFAULT_LOCALE убран, canonical всегда реальный сегмент. После `dynamicParams=false` fallback-кейс недостижим, но guard защищает от регрессии. 6. **Удалён dead code** (`src/middleware.ts`) — `LEGACY_REDIRECTS` дублировал `next.config.ts` redirects (unreachable: next.config redirects запускаются BEFORE middleware). Связанные тесты удалены (блок «middleware legacy redirects»). 7. **Root not-found.tsx** (`src/app/not-found.tsx`) — `redirect(/${DEFAULT_LOCALE}/__404__)` заменён на рендер локализованного 404 на основе `Accept-Language` (через `headers()`). Root not-found не участвует в ISR (рендерится только при 404), поэтому `headers()` безопасен (в отличие от `cookies()` — ADR-026). ## Почему - **Soft 404** (HTTP 200 на невалидных URL) — Google флагает как indexable junk, теряет доверие к сайту. - **307 temporary** — Google не передаёт link equity, индексирует оба URL (source + target). - **Matcher `|ru|en`** — матчит любой путь, начинающийся с `ru`/`en` (не ровно `ru`/`en`), ломает `/russia`, `/energy` etc. - **`dynamicParams = true`** (default) — unknown locale рендерится динамически с home контентом + wrong canonical. - **Wrong canonical** — `/rusds12312` рендерил canonical `/ru` (чужой URL). ## Watch out - **`headers()` в root `not-found.tsx`** — делает root 404 dynamic. Это безопасно: root not-found НЕ участвует в ISR/SSG страниц (рендерится только при 404 вне locale segment). ADR-026 (cookies() ломает ISR) — НЕ применяется (cookies не используются, и not-found не в ISR path). - **`.next` cache** — после удаления `%5F%5F404%5F%5F` route, `tsc --noEmit` падал на stale `.next/types/.../%5F%5F404%5F%5F/page.ts` (Cannot find module). Лечится `rm -rf .next`. CI чистый билд не имеет этой проблемы. - **Build output** — `/__404__` route исчез из build map (подтверждено). `/[locale]` помечен `● (SSG)` с prerender только `/ru`, `/en`. - **`/RU` (uppercase)** — вне scope (отдельная issue). Matcher case-sensitive, `/RU` НЕ исключается → middleware редиректит → `/ru/RU` → 404 (через `dynamicParams=false`). Актуально для контракта, но не в спеке. ## Pending — Closes #29
Author
Owner

Code Review Summary

Качественный SEO-fix PR: исправлены 5 багов из issue #29 (matcher граница locale-сегмента, 308 permanent redirects, dynamicParams=false, удаление soft-404 route, canonical guard). CI green, тесты обновлены совместно с изменениями (paired update), dangling references на удалённый %5F%5F404%5F%5F route и LEGACY_REDIRECTS отсутствуют. notFound() в generateMetadata — валидный паттерн (подтверждено Next.js 15.1.8 docs: Metadata component ловит isHTTPAccessFallbackErrorgetNotFoundMetadata).

Positives

  • Matcher fix(?!(?:ru|en)(?:/|$)) корректно отличает /ru (locale) от /russia (non-locale). 17 regex-кейсов в тестах, все pass.
  • Dead code removalLEGACY_REDIRECTS действительно дублировал next.config.ts redirects (next.config запускается before middleware → middleware-блок был unreachable). Обоснованно удалён.
  • Defense-in-depthnotFound() в page.tsx защищает canonical от регрессии, даже если dynamicParams=false уже делает кейс недостижимым.
  • Cross-file integrity — удаление writer-файлов (%5F%5F404%5F%5F/page.tsx, LEGACY_REDIRECTS) не оставило dangling readers (rg пуст).
  • Тесты обновлены — 307→308 во всех кейсах, добавлены новые matcher-кейсы (/rusds12312, /enfoo, /russia, /energy, trailing slash).

Suggestions (info, not blocking)

  • src/app/not-found.tsx:11-16 [duplication] detectLocale дублирует логику getLocale из src/middleware.ts:5-14 (обе: startsWith("en") → "en", startsWith("ru") → "ru", fallback DEFAULT_LOCALE). Можно вынести в src/lib/i18n.ts как localeFromAcceptLanguage(accept: string | null): Locale — оба consumer'а импортируют одну функцию. Не blocking: контексты разные (edge vs RSC), но логика идентична.

  • src/app/[locale]/layout.tsx:27,65 [consistency] Layout использует isLocale(locale) ? locale : DEFAULT_LOCALE fallback, а page.tsx:14,37if (!isLocale(locale)) notFound(). С dynamicParams=false fallback в layout недостижим, но паттерны несогласованы. Info: defense-in-depth допустим, но если хочется консистентности — можно заменить fallback на notFound() в layout тоже (осторожно: notFound() в layout имеет другую семантику — рендерит root not-found.tsx, не [locale]/not-found.tsx).

  • src/app/not-found.tsx [testing] Новая detectLocale логика (Accept-Language → locale) не покрыта тестами. Существующий src/__tests__/not-found.test.tsx тестирует только NotFoundView component, не root not-found.tsx page. Warning: стоит добавить тест на detectLocale (ru-RU → "ru", en-US → "en", null → DEFAULT_LOCALE).

Verdict: APPROVE

## Code Review Summary Качественный SEO-fix PR: исправлены 5 багов из issue #29 (matcher граница locale-сегмента, 308 permanent redirects, `dynamicParams=false`, удаление soft-404 route, canonical guard). CI green, тесты обновлены совместно с изменениями (paired update), dangling references на удалённый `%5F%5F404%5F%5F` route и `LEGACY_REDIRECTS` отсутствуют. `notFound()` в `generateMetadata` — валидный паттерн (подтверждено Next.js 15.1.8 docs: Metadata component ловит `isHTTPAccessFallbackError` → `getNotFoundMetadata`). ### Positives - **Matcher fix** — `(?!(?:ru|en)(?:/|$))` корректно отличает `/ru` (locale) от `/russia` (non-locale). 17 regex-кейсов в тестах, все pass. - **Dead code removal** — `LEGACY_REDIRECTS` действительно дублировал `next.config.ts` redirects (next.config запускается before middleware → middleware-блок был unreachable). Обоснованно удалён. - **Defense-in-depth** — `notFound()` в `page.tsx` защищает canonical от регрессии, даже если `dynamicParams=false` уже делает кейс недостижимым. - **Cross-file integrity** — удаление writer-файлов (`%5F%5F404%5F%5F/page.tsx`, `LEGACY_REDIRECTS`) не оставило dangling readers (`rg` пуст). - **Тесты обновлены** — 307→308 во всех кейсах, добавлены новые matcher-кейсы (`/rusds12312`, `/enfoo`, `/russia`, `/energy`, trailing slash). ### Suggestions (info, not blocking) - **src/app/not-found.tsx:11-16** [duplication] `detectLocale` дублирует логику `getLocale` из `src/middleware.ts:5-14` (обе: `startsWith("en")` → "en", `startsWith("ru")` → "ru", fallback `DEFAULT_LOCALE`). Можно вынести в `src/lib/i18n.ts` как `localeFromAcceptLanguage(accept: string | null): Locale` — оба consumer'а импортируют одну функцию. Не blocking: контексты разные (edge vs RSC), но логика идентична. - **src/app/[locale]/layout.tsx:27,65** [consistency] Layout использует `isLocale(locale) ? locale : DEFAULT_LOCALE` fallback, а `page.tsx:14,37` — `if (!isLocale(locale)) notFound()`. С `dynamicParams=false` fallback в layout недостижим, но паттерны несогласованы. Info: defense-in-depth допустим, но если хочется консистентности — можно заменить fallback на `notFound()` в layout тоже (осторожно: `notFound()` в layout имеет другую семантику — рендерит root `not-found.tsx`, не `[locale]/not-found.tsx`). - **src/app/not-found.tsx** [testing] Новая `detectLocale` логика (Accept-Language → locale) не покрыта тестами. Существующий `src/__tests__/not-found.test.tsx` тестирует только `NotFoundView` component, не root `not-found.tsx` page. Warning: стоит добавить тест на `detectLocale` (ru-RU → "ru", en-US → "en", null → DEFAULT_LOCALE). ### Verdict: APPROVE
slaid098 deleted branch fix/seo/routing-and-404-codes 2026-08-07 20:22:15 +03:00
Sign in to join this conversation.
No reviewers
No labels
bug
performance
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slaid098/slaid098-dev!32
No description provided.