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

Closed
opened 2026-08-07 19:50:37 +03:00 by slaid098 · 0 comments
Owner

Контекст

Сайт slaid098.dev имеет 5 критичных SEO-багов в роутинге и обработке 404. Невалидные URL отдают HTTP 200 (soft 404 / indexable junk), middleware-матчер ломает все пути начинающиеся с ru/en, редиректы temporary (307) вместо permanent (308), canonical указывает на чужой URL. Live-подтверждение: curl -sI https://slaid098.dev/rusds12312 → 200 (должен 404), curl -sI https://slaid098.dev/ → 307 (должен 308), curl -sI https://slaid098.dev/ru/__404__ → 200 (должен 404).

Текущее состояние (подтверждено аудитом актуального кода):

  1. src/middleware.ts:56 — matcher "/((?!api|_next|favicon\\.ico|.*\\..*|ru|en).*)". Negative lookahead |ru|en матчит любой путь, первый сегмент которого НАЧИНАЕТСЯ с ru/en (не только ровно ru/en). Результат: /rusds12312, /enfoo, /russia, /energy — исключены из middleware → нет locale-редиректа → рендерится home страница [locale]/page.tsx с locale="rusds12312". Подтверждено regex-тестом и live: /rusds12312 → 200, /enfoo → 200, /foobar → 307 → /ru/foobar (правильно).

  2. src/middleware.ts:32,47,52NextResponse.redirect(new URL(...)) без второго аргумента → по умолчанию 307 (Temporary). Все locale-редиректы (//ru, /apps/ru/apps) temporary. SEO требует 308 (permanent) для постоянных редиректов — иначе Google не передаёт link equity и индексирует оба URL. Тесты src/__tests__/middleware.test.ts явно ожидают 307 (строки 63, 70, 77, 84, 93, 100, 107, 115) — их нужно обновить на 308.

  3. src/app/[locale]/layout.tsx — нет export const dynamicParams = false. По умолчанию dynamicParams = true → неизвестные locale (/de, /fr, /xyz) рендерятся динамически через [locale] сегмент. isLocale() возвращает false → loc = DEFAULT_LOCALE = "ru" (строка 25) → рендерится Russian home с canonical /ru (строка 35). Бесконечное число indexable junk URL.

  4. src/app/[locale]/%5F%5F404%5F%5F/page.tsx — обычный page.tsx → Next.js отдаёт HTTP 200. Это textbook soft 404. robots: noindex (строка 20) смягчает, но Google всё равно может флагать soft 404 и терять доверие к сайту. Подтверждено: curl -sI https://slaid098.dev/ru/__404__ → 200.

  5. src/app/[locale]/page.tsx:13,16const loc = isLocale(locale) ? locale : DEFAULT_LOCALEalternates: { canonical: \/${loc}` }. Для /rusds12312(locale="rusds12312") →loc="ru"→ canonical/ru, но URL /rusds12312. Wrong canonical. После фикса dynamicParams=false` (пункт 3) этот кейс исчезнет (unknown locale → 404), но canonical должен использовать реальный locale сегмент, не fallback.

Задача

  1. src/middleware.ts — переписать matcher (строка 56) так, чтобы исключать ТОЛЬКО пути, первый сегмент которых ровно ru или en (с границей сегмента), а не всё что начинается с ru/en. Рекомендуемый паттерн: "/((?!api|_next|favicon\\.ico|.*\\..*)(?!(?:ru|en)(?:/|$)).*)". Проверить regex-тестом: /rusds12312 → true (middleware РАБОТАЕТ → редирект), /ru → false (исключён), /ru/apps → false, /en/foo → false, /foobar → true, /apps → true.

  2. src/middleware.ts — все NextResponse.redirect(new URL(target, request.url)) (строки 32, 47, 52) → NextResponse.redirect(new URL(target, request.url), 308). Обновить src/__tests__/middleware.test.ts: все expect(res.status).toBe(307)toBe(308).

  3. src/app/[locale]/layout.tsx — добавить export const dynamicParams = false;. После этого unknown locale (/de, /fr) → hard 404 (автоматически через static generation). Проверить: /de → 404, /fr/foo → 404.

  4. src/app/[locale]/%5F%5F404%5F%5F/page.tsx — переделать /__404__ route так, чтобы он отдавал HTTP 404. Вариант (предпочтительный): убрать отдельный /__404__ route целиком, перенести локализованный 404-контент в src/app/[locale]/not-found.tsx (который уже рендерит NotFoundView). Тогда notFound() в [slug]/page.tsx:70 автоматически отдаст HTTP 404 с локализованным контентом. Обновить src/app/not-found.tsx — убрать redirect(\/${DEFAULT_LOCALE}/404`)(строка 11), заменить на рендер root 404 (rootnot-found.tsxрендерит локализованный 404 на основе Accept-Language). Альтернатива: оставить/404но добавитьnotFound()вызов — НОnotFound()в page.tsx НЕ меняет HTTP статус на 404 в App Router (это документировано). Поэтому правильный путь — использоватьnot-found.tsx, а не page.tsx. Уточни поведение в актуальной Next.js версии (проект на Next.js 15.5.20, см. package.json:25). Если notFound()в page.tsx действительно выставляет 404 статус (Next.js 15+ может) — оставь/404сnotFound(). Иначе — удаляй route. Покрой тестом: curl -sI https://slaid098.dev/ru/404` → 404 (после деплоя) или локальный тест статуса.

  5. src/app/[locale]/page.tsx — canonical должен использовать реальный locale сегмент (с валидацией isLocale), не fallback на DEFAULT_LOCALE. Если !isLocale(locale)notFound() (в сочетании с dynamicParams=false это уже не достижимо, но defense-in-depth). Для валидных locale canonical = /${locale} (уже так, строка 16). Главное — после фикса dynamicParams=false (пункт 3) fallback-кейс исчезает.

  6. Удалить dead code: LEGACY_REDIRECTS в src/middleware.ts (строки 5-8, 29-34, 44-49) — дублирует next.config.ts redirects, unreachable (next.config redirects跑 BEFORE middleware). Удалить связанные тесты в middleware.test.ts (блок "middleware legacy redirects", строки 90-110) если они тестируют только dead code.

Контракты

  • GET /rusds12312 → 308 → /ru/rusds12312 → 404 (hard 404, не 200)
  • GET /enfoo → 308 → /en/enfoo → 404
  • GET /foobar → 308 → /ru/foobar → 404
  • GET / → 308 → /ru (или /en по Accept-Language)
  • GET /apps → 308 → /ru/apps
  • GET /ru/nonexistent-slug → 404 (hard, через notFound() в [slug]/page.tsx)
  • GET /ru/__404__ → 404 (если route удалён) ИЛИ /ru/__404__ перестаёт существовать и 404 рендерится через [locale]/not-found.tsx
  • GET /de → 404 (unknown locale, dynamicParams=false)
  • GET /fr/foo → 404
  • Все middleware-редиректы — статус 308 (permanent)
  • Валидные URL (/ru, /en, /ru/apps, /ru/opencode-config) — без изменений, 200

Инварианты

  • Локали: только ru и en (из src/lib/i18n.ts)
  • dynamicParams = false на [locale] — unknown locale → hard 404
  • Редиректы locale/legacy — 308 (permanent), НЕ 307
  • Matcher исключает ровно /ru, /ru/..., /en, /en/... (по границе сегмента), не пути начинающиеся с ru/en
  • notFound() должен выставлять HTTP 404 статус (проверь в актуальной Next.js 15.5.20 — если нет, используй not-found.tsx route, не page.tsx)
  • Существующие валидные роуты (/ru, /en, /ru/apps, /ru/contacts, /ru/subscriptions, /ru/{slug}) — без изменений в URL

Граничные случаи

  • /RU (uppercase) — пока не в scope этого issue, но проверь что matcher не ломается
  • Trailing slash (/ru/apps/) — пока не в scope
  • Query params — сохраняются в редиректе (new URL(target, request.url) наследует query)
  • next.config.ts legacy redirects (/youtube, /take_break, /support) — БЕЗ изменений в этом issue
  • /ru/ru/foo (double locale) — должно 404 (3 сегмента, [slug] не матчит)

Влияние на связанные компоненты

  • src/middleware.ts — matcher + redirect status + удаление dead LEGACY_REDIRECTS
  • src/app/[locale]/layout.tsxdynamicParams = false
  • src/app/[locale]/%5F%5F404%5F%5F/page.tsx — удаление или переделка на real 404
  • src/app/not-found.tsx — возможно обновление (если /__404__ удаляется)
  • src/app/[locale]/not-found.tsx — возможно обновление контента
  • src/app/[locale]/page.tsx — canonical фикс
  • src/__tests__/middleware.test.ts — 307 → 308, удаление dead-code тестов
  • src/__tests__/slug-static-params.test.ts — без изменений (generateStaticParams не трогаем)
  • src/__tests__/not-found.test.tsx — возможно обновление
  • src/app/sitemap.ts — БЕЗ изменений (не листит /__404__, корректно)

Вне scope

  • In-HTML hreflang теги (отдельная issue)
  • og:url/og:image на apps/contacts/subscriptions (отдельная issue)
  • robots.txt disallow правила (отдельная issue)
  • Legacy redirect цепочки (отдельная issue)
  • trailingSlash конфиг (отдельная issue)
  • JSON-LD @id коллизии (отдельная issue)
  • Uppercase locale /RU (отдельная issue)
  • manifest.ts lang/start_url (отдельная issue)
  • og:type article для [slug] (отдельная issue)
  • Sitemap lastmod = updatedAt (отдельная issue)
  • switchLocalePath валидация (отдельная issue)

Критерии приемки

  • curl -sI https://slaid098.dev/rusds12312 → 308 (redirect на /ru/rusds12312), затем 404 (не 200)
  • curl -sI https://slaid098.dev/enfoo → 308, затем 404
  • curl -sI https://slaid098.dev/foobar → 308 → /ru/foobar → 404
  • curl -sI https://slaid098.dev/ → 308 (не 307) → /ru
  • curl -sI https://slaid098.dev/apps → 308 (не 307) → /ru/apps
  • curl -sI https://slaid098.dev/ru/__404__ → 404 (не 200) ИЛИ route удалён → 404
  • curl -sI https://slaid098.dev/de → 404 (не 200 с home контентом)
  • curl -sI https://slaid098.dev/fr/foo → 404
  • curl -sI https://slaid098.dev/ru → 200 (без изменений)
  • curl -sI https://slaid098.dev/en → 200 (без изменений)
  • curl -sI https://slaid098.dev/ru/apps → 200 (без изменений)
  • curl -sI https://slaid098.dev/ru/opencode-config → 200 (без изменений)
  • curl -sI https://slaid098.dev/ru/nonexistent-slug → 404
  • HTML canonical на /ru = https://slaid098.dev/ru (без изменений для валидных)
  • HTML canonical на /rusds12312 — отсутствует (страница 404, не рендерит canonical)
  • npm run lint (biome) проходит
  • npm run typecheck (tsc) проходит
  • npm test (vitest) проходит, middleware-тесты обновлены на 308
  • Regex-тест matcher: /rusds12312 → true (middleware работает), /ru → false, /ru/apps → false, /en/foo → false, /foobar → true
## Контекст Сайт slaid098.dev имеет 5 критичных SEO-багов в роутинге и обработке 404. Невалидные URL отдают HTTP 200 (soft 404 / indexable junk), middleware-матчер ломает все пути начинающиеся с `ru`/`en`, редиректы temporary (307) вместо permanent (308), canonical указывает на чужой URL. Live-подтверждение: `curl -sI https://slaid098.dev/rusds12312` → 200 (должен 404), `curl -sI https://slaid098.dev/` → 307 (должен 308), `curl -sI https://slaid098.dev/ru/__404__` → 200 (должен 404). Текущее состояние (подтверждено аудитом актуального кода): 1. **`src/middleware.ts:56`** — matcher `"/((?!api|_next|favicon\\.ico|.*\\..*|ru|en).*)"`. Negative lookahead `|ru|en` матчит любой путь, первый сегмент которого НАЧИНАЕТСЯ с `ru`/`en` (не только ровно `ru`/`en`). Результат: `/rusds12312`, `/enfoo`, `/russia`, `/energy` — исключены из middleware → нет locale-редиректа → рендерится home страница `[locale]/page.tsx` с `locale="rusds12312"`. Подтверждено regex-тестом и live: `/rusds12312` → 200, `/enfoo` → 200, `/foobar` → 307 → `/ru/foobar` (правильно). 2. **`src/middleware.ts:32,47,52`** — `NextResponse.redirect(new URL(...))` без второго аргумента → по умолчанию **307 (Temporary)**. Все locale-редиректы (`/` → `/ru`, `/apps` → `/ru/apps`) temporary. SEO требует 308 (permanent) для постоянных редиректов — иначе Google не передаёт link equity и индексирует оба URL. Тесты `src/__tests__/middleware.test.ts` явно ожидают 307 (строки 63, 70, 77, 84, 93, 100, 107, 115) — их нужно обновить на 308. 3. **`src/app/[locale]/layout.tsx`** — нет `export const dynamicParams = false`. По умолчанию `dynamicParams = true` → неизвестные locale (`/de`, `/fr`, `/xyz`) рендерятся динамически через `[locale]` сегмент. `isLocale()` возвращает false → `loc = DEFAULT_LOCALE = "ru"` (строка 25) → рендерится Russian home с canonical `/ru` (строка 35). Бесконечное число indexable junk URL. 4. **`src/app/[locale]/%5F%5F404%5F%5F/page.tsx`** — обычный `page.tsx` → Next.js отдаёт HTTP **200**. Это textbook soft 404. `robots: noindex` (строка 20) смягчает, но Google всё равно может флагать soft 404 и терять доверие к сайту. Подтверждено: `curl -sI https://slaid098.dev/ru/__404__` → 200. 5. **`src/app/[locale]/page.tsx:13,16`** — `const loc = isLocale(locale) ? locale : DEFAULT_LOCALE` → `alternates: { canonical: \`/${loc}\` }`. Для `/rusds12312` (locale="rusds12312") → `loc="ru"` → canonical `/ru`, но URL `/rusds12312`. Wrong canonical. После фикса `dynamicParams=false` (пункт 3) этот кейс исчезнет (unknown locale → 404), но canonical должен использовать реальный locale сегмент, не fallback. ## Задача 1. **`src/middleware.ts`** — переписать matcher (строка 56) так, чтобы исключать ТОЛЬКО пути, первый сегмент которых ровно `ru` или `en` (с границей сегмента), а не всё что начинается с `ru`/`en`. Рекомендуемый паттерн: `"/((?!api|_next|favicon\\.ico|.*\\..*)(?!(?:ru|en)(?:/|$)).*)"`. Проверить regex-тестом: `/rusds12312` → true (middleware РАБОТАЕТ → редирект), `/ru` → false (исключён), `/ru/apps` → false, `/en/foo` → false, `/foobar` → true, `/apps` → true. 2. **`src/middleware.ts`** — все `NextResponse.redirect(new URL(target, request.url))` (строки 32, 47, 52) → `NextResponse.redirect(new URL(target, request.url), 308)`. Обновить `src/__tests__/middleware.test.ts`: все `expect(res.status).toBe(307)` → `toBe(308)`. 3. **`src/app/[locale]/layout.tsx`** — добавить `export const dynamicParams = false;`. После этого unknown locale (`/de`, `/fr`) → hard 404 (автоматически через static generation). Проверить: `/de` → 404, `/fr/foo` → 404. 4. **`src/app/[locale]/%5F%5F404%5F%5F/page.tsx`** — переделать `/__404__` route так, чтобы он отдавал HTTP 404. Вариант (предпочтительный): убрать отдельный `/__404__` route целиком, перенести локализованный 404-контент в `src/app/[locale]/not-found.tsx` (который уже рендерит `NotFoundView`). Тогда `notFound()` в `[slug]/page.tsx:70` автоматически отдаст HTTP 404 с локализованным контентом. Обновить `src/app/not-found.tsx` — убрать `redirect(\`/${DEFAULT_LOCALE}/__404__\`)` (строка 11), заменить на рендер root 404 (root `not-found.tsx` рендерит локализованный 404 на основе Accept-Language). Альтернатива: оставить `/__404__` но добавить `notFound()` вызов — НО `notFound()` в page.tsx НЕ меняет HTTP статус на 404 в App Router (это документировано). Поэтому правильный путь — использовать `not-found.tsx`, а не `page.tsx`. Уточни поведение в актуальной Next.js версии (проект на Next.js 15.5.20, см. `package.json:25`). Если `notFound()` в page.tsx действительно выставляет 404 статус (Next.js 15+ может) — оставь `/__404__` с `notFound()`. Иначе — удаляй route. Покрой тестом: `curl -sI https://slaid098.dev/ru/__404__` → 404 (после деплоя) или локальный тест статуса. 5. **`src/app/[locale]/page.tsx`** — canonical должен использовать реальный `locale` сегмент (с валидацией `isLocale`), не fallback на `DEFAULT_LOCALE`. Если `!isLocale(locale)` → `notFound()` (в сочетании с `dynamicParams=false` это уже не достижимо, но defense-in-depth). Для валидных locale canonical = `/${locale}` (уже так, строка 16). Главное — после фикса `dynamicParams=false` (пункт 3) fallback-кейс исчезает. 6. Удалить dead code: `LEGACY_REDIRECTS` в `src/middleware.ts` (строки 5-8, 29-34, 44-49) — дублирует `next.config.ts` redirects, unreachable (next.config redirects跑 BEFORE middleware). Удалить связанные тесты в `middleware.test.ts` (блок "middleware legacy redirects", строки 90-110) если они тестируют только dead code. ## Контракты - `GET /rusds12312` → 308 → `/ru/rusds12312` → 404 (hard 404, не 200) - `GET /enfoo` → 308 → `/en/enfoo` → 404 - `GET /foobar` → 308 → `/ru/foobar` → 404 - `GET /` → 308 → `/ru` (или `/en` по Accept-Language) - `GET /apps` → 308 → `/ru/apps` - `GET /ru/nonexistent-slug` → 404 (hard, через `notFound()` в [slug]/page.tsx) - `GET /ru/__404__` → 404 (если route удалён) ИЛИ `/ru/__404__` перестаёт существовать и 404 рендерится через `[locale]/not-found.tsx` - `GET /de` → 404 (unknown locale, `dynamicParams=false`) - `GET /fr/foo` → 404 - Все middleware-редиректы — статус 308 (permanent) - Валидные URL (`/ru`, `/en`, `/ru/apps`, `/ru/opencode-config`) — без изменений, 200 ## Инварианты - Локали: только `ru` и `en` (из `src/lib/i18n.ts`) - `dynamicParams = false` на `[locale]` — unknown locale → hard 404 - Редиректы locale/legacy — 308 (permanent), НЕ 307 - Matcher исключает ровно `/ru`, `/ru/...`, `/en`, `/en/...` (по границе сегмента), не пути начинающиеся с `ru`/`en` - `notFound()` должен выставлять HTTP 404 статус (проверь в актуальной Next.js 15.5.20 — если нет, используй `not-found.tsx` route, не `page.tsx`) - Существующие валидные роуты (`/ru`, `/en`, `/ru/apps`, `/ru/contacts`, `/ru/subscriptions`, `/ru/{slug}`) — без изменений в URL ## Граничные случаи - `/RU` (uppercase) — пока не в scope этого issue, но проверь что matcher не ломается - Trailing slash (`/ru/apps/`) — пока не в scope - Query params — сохраняются в редиректе (`new URL(target, request.url)` наследует query) - `next.config.ts` legacy redirects (`/youtube`, `/take_break`, `/support`) — БЕЗ изменений в этом issue - `/ru/ru/foo` (double locale) — должно 404 (3 сегмента, `[slug]` не матчит) ## Влияние на связанные компоненты - `src/middleware.ts` — matcher + redirect status + удаление dead LEGACY_REDIRECTS - `src/app/[locale]/layout.tsx` — `dynamicParams = false` - `src/app/[locale]/%5F%5F404%5F%5F/page.tsx` — удаление или переделка на real 404 - `src/app/not-found.tsx` — возможно обновление (если `/__404__` удаляется) - `src/app/[locale]/not-found.tsx` — возможно обновление контента - `src/app/[locale]/page.tsx` — canonical фикс - `src/__tests__/middleware.test.ts` — 307 → 308, удаление dead-code тестов - `src/__tests__/slug-static-params.test.ts` — без изменений (generateStaticParams не трогаем) - `src/__tests__/not-found.test.tsx` — возможно обновление - `src/app/sitemap.ts` — БЕЗ изменений (не листит `/__404__`, корректно) ## Вне scope - ❌ In-HTML hreflang теги (отдельная issue) - ❌ og:url/og:image на apps/contacts/subscriptions (отдельная issue) - ❌ robots.txt disallow правила (отдельная issue) - ❌ Legacy redirect цепочки (отдельная issue) - ❌ trailingSlash конфиг (отдельная issue) - ❌ JSON-LD @id коллизии (отдельная issue) - ❌ Uppercase locale `/RU` (отдельная issue) - ❌ manifest.ts lang/start_url (отдельная issue) - ❌ og:type article для [slug] (отдельная issue) - ❌ Sitemap lastmod = updatedAt (отдельная issue) - ❌ switchLocalePath валидация (отдельная issue) ## Критерии приемки - [ ] `curl -sI https://slaid098.dev/rusds12312` → 308 (redirect на `/ru/rusds12312`), затем 404 (не 200) - [ ] `curl -sI https://slaid098.dev/enfoo` → 308, затем 404 - [ ] `curl -sI https://slaid098.dev/foobar` → 308 → `/ru/foobar` → 404 - [ ] `curl -sI https://slaid098.dev/` → 308 (не 307) → `/ru` - [ ] `curl -sI https://slaid098.dev/apps` → 308 (не 307) → `/ru/apps` - [ ] `curl -sI https://slaid098.dev/ru/__404__` → 404 (не 200) ИЛИ route удалён → 404 - [ ] `curl -sI https://slaid098.dev/de` → 404 (не 200 с home контентом) - [ ] `curl -sI https://slaid098.dev/fr/foo` → 404 - [ ] `curl -sI https://slaid098.dev/ru` → 200 (без изменений) - [ ] `curl -sI https://slaid098.dev/en` → 200 (без изменений) - [ ] `curl -sI https://slaid098.dev/ru/apps` → 200 (без изменений) - [ ] `curl -sI https://slaid098.dev/ru/opencode-config` → 200 (без изменений) - [ ] `curl -sI https://slaid098.dev/ru/nonexistent-slug` → 404 - [ ] HTML canonical на `/ru` = `https://slaid098.dev/ru` (без изменений для валидных) - [ ] HTML canonical на `/rusds12312` — отсутствует (страница 404, не рендерит canonical) - [ ] `npm run lint` (biome) проходит - [ ] `npm run typecheck` (tsc) проходит - [ ] `npm test` (vitest) проходит, middleware-тесты обновлены на 308 - [ ] Regex-тест matcher: `/rusds12312` → true (middleware работает), `/ru` → false, `/ru/apps` → false, `/en/foo` → false, `/foobar` → true
Sign in to join this conversation.
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#29
No description provided.