SEO-аудит Next.js-приложения

Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.

// промпт
Ты — технический SEO-специалист, который глубоко знает Next.js (App Router и Pages Router). Твоя задача — провести SEO-аудит приложения и выдать конкретный план правок с кодом, а не общие советы «улучшите скорость». ## Данные для аудита - **Версия и роутер:** {{versiia_nextjs_i_tip_routera}} - **Тип сайта:** {{tip_saita}} (контентный, e-commerce, SaaS-лендинг) - **Код ключевых страниц:** {{kod_layout_i_vaznyx_stranic}} - **Известные проблемы:** {{cto_uze_bespokoit}} (необязательно) ## Чеклист аудита **1. Метаданные** - title/description на каждом шаблоне: уникальные, из данных страницы - canonical и обработка параметров (пагинация, фильтры, UTM) - Open Graph и структурированные данные JSON-LD под тип контента - hreflang, если сайт мультиязычный **2. Рендеринг и индексация** - Что реально видит бот: SSR/SSG против клиентских заглушек и спиннеров - Статусы ответа: честные 404/410 на несуществующих маршрутах, 301 без цепочек - robots.txt и sitemap.xml: генерация, свежесть lastmod, лимиты - Внутренние ссылки: <Link> с реальными href, доступность без JS **3. Core Web Vitals** - LCP: приоритет hero-изображения (priority у next/image), серверные данные без Suspense-задержки - CLS: размеры изображений и шрифтов (next/font), резервирование места под динамику - INP: тяжёлые клиентские компоненты, гидратация, размер бандла ## Формат ответа 1. **Оценка по трём блокам** — коротко: что в порядке, что нет. 2. **Проблемы по приоритету** — Критично (мешает индексации) / Важно (теряет позиции) / Желательно. Для каждой: как проявляется, как чинить, код правки. 3. **План на неделю** — упорядоченный список правок с ожидаемым эффектом.
Заполните переменные

Пример запуска

Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.

Значения переменных

Версия next.js и тип роутера
Next.js 14.2.5, App Router
Тип сайта
e-commerce (интернет-магазин мебели ручной работы)
Код layout и важных страниц
// app/layout.tsx import './globals.css' export const metadata = { title: 'WoodCraft — мебель ручной работы', description: 'Магазин мебели ручной работы из массива дерева', } export default function RootLayout({ children }) { return ( <html lang="ru"> <body> <header>{/* навигация */}</header> {children} <footer>{/* футер */}</footer> </body> </html> ) } // app/product/[slug]/page.tsx 'use client' import { useEffect, useState } from 'react' import Image from 'next/image' export default function ProductPage({ params }) { const [product, setProduct] = useState(null) useEffect(() => { fetch(`/api/products/${params.slug}`) .then(res => res.json()) .then(data => { if (!data) { setProduct({ notFound: true }) } else { setProduct(data) } }) }, [params.slug]) if (!product) return <div className="spinner">Загрузка...</div> if (product.notFound) return <div>Товар не найден</div> return ( <div> <h1>{product.title}</h1> <Image src={product.image} width={600} height={600} alt="" /> <p className="price">{product.price} ₽</p> <div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} /> </div> ) } // app/category/[slug]/page.tsx export default async function CategoryPage({ params, searchParams }) { const res = await fetch( `https://api.woodcraft.ru/categories/${params.slug}?color=${searchParams.color || ''}&sort=${searchParams.sort || ''}`, { cache: 'no-store' } ) const category = await res.json() return ( <div> <h1>{category.name}</h1> <div className="grid"> {category.products.map(p => ( <a key={p.id} href={`/product/${p.slug}`}> <img src={p.image} /> <span>{p.title}</span> </a> ))} </div> </div> ) }
Что уже беспокоит
В Google Search Console category-страницы с фильтрами (?color=&sort=) индексируются как отдельные URL и дублируют контент; LCP на карточке товара ~4.1s по PageSpeed (мобильный); часть товаров, снятых с продажи, отдаёт 200 с текстом "Товар не найден" вместо 404.

SEO-аудит WoodCraft (Next.js 14.2.5, App Router)

1. Оценка по трём блокам

Блок В порядке Не в порядке
Метаданные lang="ru". hreflang не нужен, сайт одноязычный. Все страницы наследуют один title/description из layout. Нет metadataBase, canonical, Open Graph и JSON-LD. У фото товара пустой alt.
Рендеринг и индексация Категория рендерится на сервере, ссылки на товары есть в HTML. Карточка товара — клиентский компонент: в исходном HTML только «Загрузка...». Soft 404 вместо 404. Фильтры и сортировка плодят дубли. Нет robots.txt и sitemap.xml. Нет 404 для несуществующих категорий.
Core Web Vitals Используется next/image с размерами. На карточке товара LCP-изображение появляется только после цепочки HTML → JS → гидратация → fetch → картинка, и у него нет priority. На категории <img> без размеров (CLS) и cache: 'no-store' (TTFB). Спиннер заменяется контентом, что даёт сдвиг макета.

2. Проблемы по приоритету

🔴 Критично: мешает индексации

К1. Карточка товара рендерится только на клиенте

Как проявляется. Проверить можно так: curl -s https://woodcraft.ru/product/<slug> | grep -o '<h1.*</h1>' ничего не найдёт. В HTML только <div class="spinner">Загрузка...</div>.

  • Googlebot откладывает рендеринг JS в отдельную очередь.
  • Яндекс рендерит JS ограниченно, а для .ru-магазина это основной поисковик.
  • Соцсети и мессенджеры JS не выполняют вообще.

Кроме того, 'use client'-страница не может экспортировать metadata. Поэтому у всех товаров один title: «WoodCraft — мебель ручной работы».

Скрытые баги в текущем коде:

  • Если API вернёт 404 с телом {"error": "..."}, то data не пустое. Страница отрендерит пустой <h1> и цену undefined ₽.
  • Если fetch упадёт, спиннер будет крутиться бесконечно.
  • descriptionHtml вставляется без санитизации. Это XSS-риск.

Как чинить. Сделать страницу серверным компонентом и читать данные напрямую из слоя данных, а не через свой же /api. Интерактив вынести в клиентские «островки».

// lib/api.ts
import 'server-only'
import { cache } from 'react'

export type Product = {
  id: string
  slug: string
  title: string
  seoTitle?: string
  seoDescription?: string
  plainDescription: string
  descriptionHtml: string   // санитизировать при сохранении (sanitize-html / DOMPurify)
  price: number
  sku: string
  material?: string
  images: { url: string; width: number; height: number; blurDataURL?: string }[]
  status: 'active' | 'out_of_stock' | 'discontinued'
  replacementSlug?: string
  category: { slug: string; name: string }
  updatedAt: string
}

const API = process.env.API_URL! // https://api.woodcraft.ru

// cache() дедуплицирует вызов между generateMetadata и page в одном запросе
export const getProduct = cache(async (slug: string): Promise<Product | null> => {
  const res = await fetch(`${API}/products/${encodeURIComponent(slug)}`, {
    next: { revalidate: 300, tags: [`product:${slug}`] },
  })
  if (res.status === 404) return null
  // При сбое API отдаём 500, а не «не найдено». Google перепроверит страницу, а не выкинет её из индекса
  if (!res.ok) throw new Error(`Products API ${res.status}`)
  return res.json()
})
// app/product/[slug]/page.tsx — серверный компонент, без 'use client'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { notFound, permanentRedirect } from 'next/navigation'
import { getProduct, type Product } from '@/lib/api'
import AddToCart from './AddToCart' // 'use client' — только кнопка

type Props = { params: { slug: string } } // в Next 14 params синхронные

const rub = (n: number) => new Intl.NumberFormat('ru-RU').format(n)

async function resolveProduct(slug: string): Promise<Product> {
  const product = await getProduct(slug)
  if (!product) notFound()
  // Снят с производства и есть замена — 308 (Google трактует как 301)
  if (product.status === 'discontinued' && product.replacementSlug) {
    permanentRedirect(`/product/${product.replacementSlug}`)
  }
  // API вернул товар по старому slug — одна переадресация на актуальный
  if (product.slug !== slug) permanentRedirect(`/product/${product.slug}`)
  return product
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const p = await resolveProduct(params.slug)
  const url = `/product/${p.slug}`
  const title = p.seoTitle ?? `${p.title} из массива — купить за ${rub(p.price)} ₽`
  const description =
    p.seoDescription ?? p.plainDescription.slice(0, 155).replace(/\s+\S*$/, '') + '…'

  return {
    title,
    description,
    alternates: { canonical: url }, // отсекает ?utm_*, ?ref и т.п.
    openGraph: {
      type: 'website',
      url,
      title,
      description,
      images: p.images.slice(0, 1).map(i => ({ url: i.url, width: i.width, height: i.height, alt: p.title })),
    },
  }
}

export default async function ProductPage({ params }: Props) {
  const p = await resolveProduct(params.slug)
  const hero = p.images[0]

  return (
    <main>
      <nav aria-label="Хлебные крошки">
        <Link href="/">Главная</Link> / <Link href={`/category/${p.category.slug}`}>{p.category.name}</Link>
      </nav>

      <h1>{p.title}</h1>

      <Image
        src={hero.url}
        width={hero.width}
        height={hero.height}
        alt={p.title}
        priority                                   // preload + fetchpriority="high"
        sizes="(max-width: 768px) 100vw, 600px"    // без этого мобильный качает лишний размер
        placeholder={hero.blurDataURL ? 'blur' : 'empty'}
        blurDataURL={hero.blurDataURL}
      />

      {p.status === 'discontinued' ? (
        <p>Модель снята с производства. <Link href={`/category/${p.category.slug}`}>Похожие товары</Link></p>
      ) : (
        <>
          <p className="price">{rub(p.price)} ₽</p>
          <AddToCart productId={p.id} disabled={p.status !== 'active'} />
        </>
      )}

      <div dangerouslySetInnerHTML={{ __html: p.descriptionHtml }} />

      <ProductJsonLd product={p} />
    </main>
  )
}

Для популярных товаров добавьте пререндер:

// там же
export const dynamicParams = true
export async function generateStaticParams() {
  const top = await fetch(`${process.env.API_URL}/products?top=200`).then(r => r.json())
  return top.map((p: { slug: string }) => ({ slug: p.slug }))
}
К2. Soft 404: товары, снятые с продажи

Как проявляется. В GSC эти страницы помечены как «Ложная ошибка 404». Краулинговый бюджет тратится на мусор, мёртвые страницы висят в индексе.

Как чинить. notFound() из К1 даёт настоящий 404 и app/product/[slug]/not-found.tsx. Для каждого снятого товара выберите политику явно:

Ситуация Ответ
Есть прямая замена 308 на замену (уже в коде выше)
У страницы есть трафик или внешние ссылки 200, плашка «снят с производства» + похожие товары + availability: Discontinued в JSON-LD
Страница без ценности 404 через notFound() или 410 (см. ниже)
// app/product/[slug]/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
  return (
    <main>
      <h1>Товар не найден</h1>
      <p>Возможно, модель снята с производства. <Link href="/catalog">Перейти в каталог</Link></p>
    </main>
  )
}

Если нужен именно 410, чтобы товары быстрее выпадали из индекса: из page-компонента App Router 410 не отдать. Используйте middleware со списком slug-ов:

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import gone from './data/gone-products.json' // генерируется при деплое / по вебхуку

const goneSet = new Set<string>(gone)

export function middleware(req: NextRequest) {
  const slug = req.nextUrl.pathname.split('/')[2]
  if (slug && goneSet.has(slug)) {
    return new NextResponse('Товар снят с продажи', { status: 410 })
  }
}
export const config = { matcher: '/product/:slug*' }

⚠️ Нюанс Next.js. Если ответ уже начал стримиться (например, над сегментом есть loading.tsx), вызов notFound() в теле страницы вернёт статус 200 и добавит <meta name="robots" content="noindex">. Поэтому notFound() вызывается и в generateMetadata: в 14.2 метаданные резолвятся до первого байта. И не кладите loading.tsx в app/product/[slug]/. После деплоя проверьте статус: curl -o /dev/null -s -w "%{http_code}\n" https://woodcraft.ru/product/nesushchestvuyushchiy

К3. Дубли категорий из-за ?color= и ?sort=

Как проявляется. Каждая комбинация параметров становится отдельным URL в индексе. Сигналы размываются, в GSC растёт раздел «Дубликат, канонический вариант не выбран пользователем».

Как чинить.

  1. Задать canonical на чистый URL категории. Если есть пагинация, у каждой страницы свой canonical: ?page=2 указывает на себя, а не на первую страницу.
  2. Пропускать в API только известные значения фильтров из белого списка.
  3. Для Яндекса добавить Clean-param (см. В5).
  4. Не закрывать ?sort= в robots.txt сразу. Робот должен сначала увидеть canonical. Если заблокировать URL раньше, уже проиндексированные дубли зависнут со статусом «Проиндексировано, несмотря на блокировку». Закрывать можно только после того, как дубли выпадут.
  5. Не смешивать canonical и noindex на одном URL: это противоречивые сигналы.
// lib/api.ts (дополнение)
export const ALLOWED_SORT = ['price_asc', 'price_desc', 'new'] as const
export const ALLOWED_COLOR = ['oak', 'walnut', 'ash', 'pine'] as const

export const getCategory = cache(async (
  slug: string,
  opts: { color?: string; sort?: string; page?: number } = {},
) => {
  const qs = new URLSearchParams()
  if (opts.color && (ALLOWED_COLOR as readonly string[]).includes(opts.color)) qs.set('color', opts.color)
  if (opts.sort && (ALLOWED_SORT as readonly string[]).includes(opts.sort)) qs.set('sort', opts.sort)
  if (opts.page && opts.page > 1) qs.set('page', String(opts.page))

  const res = await fetch(`${API}/categories/${encodeURIComponent(slug)}?${qs}`, {
    next: { revalidate: 300, tags: [`category:${slug}`] }, // вместо no-store
  })
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`Categories API ${res.status}`)
  return res.json() as Promise<{
    name: string; seoTitle?: string; seoDescription?: string; intro?: string
    totalPages: number; updatedAt: string
    products: { id: string; slug: string; title: string; price: number; image: { url: string; width: number; height: number } }[]
  }>
})

🟠 Важно: теряет позиции

В1. Категория: нет метаданных, 404, оптимизации картинок и кэша
// app/category/[slug]/page.tsx
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getCategory } from '@/lib/api'

type Props = {
  params: { slug: string }
  searchParams: Record<string, string | string[] | undefined>
}
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v)
const pageOf = (sp: Props['searchParams']) => Math.max(1, Number(one(sp.page)) || 1)

export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
  const cat = await getCategory(params.slug)
  if (!cat) notFound()
  const page = pageOf(searchParams)
  const base = `/category/${params.slug}`
  const title = (cat.seoTitle ?? `${cat.name} из массива ручной работы — купить`) + (page > 1 ? ` — страница ${page}` : '')

  return {
    title,
    description: cat.seoDescription ?? `${cat.name} ручной работы из дуба, ясеня и ореха. Изготовление на заказ, доставка по России.`,
    alternates: { canonical: page > 1 ? `${base}?page=${page}` : base }, // color/sort/utm отбрасываются
    openGraph: { url: base, title },
  }
}

export default async function CategoryPage({ params, searchParams }: Props) {
  const page = pageOf(searchParams)
  const cat = await getCategory(params.slug, {
    color: one(searchParams.color),
    sort: one(searchParams.sort),
    page,
  })
  if (!cat) notFound()
  if (page > cat.totalPages && cat.totalPages > 0) notFound() // ?page=999 → 404

  return (
    <main>
      <h1>{cat.name}</h1>
      {page === 1 && cat.intro && <p>{cat.intro}</p>}

      <ul className="grid">
        {cat.products.map((p, i) => (
          <li key={p.id}>
            <Link href={`/product/${p.slug}`}>
              <Image
                src={p.image.url}
                width={p.image.width}
                height={p.image.height}
                alt={p.title}
                sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 300px"
                priority={i < 2} // первый ряд на мобильном — кандидат на LCP
              />
              <span>{p.title}</span>
              <span>{new Intl.NumberFormat('ru-RU').format(p.price)} ₽</span>
            </Link>
          </li>
        ))}
      </ul>

      {/* Пагинация — обычные ссылки, доступные без JS */}
      <nav aria-label="Страницы">
        {page > 1 && <Link href={page === 2 ? `/category/${params.slug}` : `?page=${page - 1}`}>← Назад</Link>}
        {page < cat.totalPages && <Link href={`?page=${page + 1}`}>Вперёд →</Link>}
      </nav>
    </main>
  )
}

Сами фильтры в UI лучше делать формой или кнопками с router.push, а не <a href="?color=oak&sort=...">. Тогда бот не будет обходить комбинаторный взрыв URL.

В2. Корневой layout: metadataBase, шаблон title, шрифты
// app/layout.tsx
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'

const font = Inter({ subsets: ['latin', 'cyrillic'], display: 'swap', variable: '--font-main' })

export const metadata: Metadata = {
  metadataBase: new URL('https://woodcraft.ru'), // без него canonical/OG будут относительными
  title: {
    default: 'WoodCraft — мебель ручной работы из массива дерева',
    template: '%s | WoodCraft',
  },
  description: 'Магазин мебели ручной работы из массива дуба, ясеня и ореха. Изготовление на заказ, доставка по России.',
  openGraph: { siteName: 'WoodCraft', locale: 'ru_RU', type: 'website' },
  robots: process.env.VERCEL_ENV === 'preview' ? { index: false, follow: false } : undefined,
  verification: { google: 'XXX', yandex: 'YYY' },
  // ❗ НЕ задавайте здесь alternates.canonical. Иначе все страницы без своего
  // canonical унаследуют его и «склеятся» с главной.
}

const orgJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  name: 'WoodCraft',
  url: 'https://woodcraft.ru',
  logo: 'https://woodcraft.ru/logo.png',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ru" className={font.variable}>
      <body>
        <header>{/* навигация через <Link> */}</header>
        {children}
        <footer>{/* футер */}</footer>
        <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }} />
      </body>
    </html>
  )
}

next/font загружает шрифт с вашего домена и подстраивает метрики fallback-шрифта. Это убирает CLS от подмены шрифта. Если сейчас шрифты подключаются через <link> или @import в CSS, удалите это подключение.

В3. JSON-LD для товара и хлебных крошек

Это даёт расширенные сниппеты с ценой и наличием в Google и помогает товарным блокам Яндекса.

// app/product/[slug]/ProductJsonLd.tsx
import type { Product } from '@/lib/api'

const SITE = 'https://woodcraft.ru'
const availability = {
  active: 'https://schema.org/InStock',
  out_of_stock: 'https://schema.org/OutOfStock',
  discontinued: 'https://schema.org/Discontinued',
} as const

export function ProductJsonLd({ product: p }: { product: Product }) {
  const url = `${SITE}/product/${p.slug}`
  const data = [
    {
      '@context': 'https://schema.org',
      '@type': 'Product',
      name: p.title,
      description: p.plainDescription,
      image: p.images.map(i => i.url),
      sku: p.sku,
      material: p.material,
      brand: { '@type': 'Brand', name: 'WoodCraft' },
      offers: {
        '@type': 'Offer',
        url,
        priceCurrency: 'RUB',
        price: p.price.toFixed(2),
        availability: availability[p.status],
        itemCondition: 'https://schema.org/NewCondition',
        // Рекомендуется для Merchant listings: shippingDetails, hasMerchantReturnPolicy
      },
    },
    {
      '@context': 'https://schema.org',
      '@type': 'BreadcrumbList',
      itemListElement: [
        { '@type': 'ListItem', position: 1, name: 'Главная', item: SITE },
        { '@type': 'ListItem', position: 2, name: p.category.name, item: `${SITE}/category/${p.category.slug}` },
        { '@type': 'ListItem', position: 3, name: p.title, item: url },
      ],
    },
  ]
  return (
    <script
      type="application/ld+json"
      // экранируем "<", чтобы текст из CMS не закрыл тег script
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
    />
  )
}

aggregateRating добавляйте только если отзывы реально показаны на странице.

В4. LCP на карточке товара: 4.1 с

Основная причина — водопад: HTML → загрузка JS → гидратация → fetch('/api/...') → только потом браузер узнаёт URL картинки. Изображение без priority и sizes к тому же грузится лениво и крупнее, чем нужно.

Что уже сделано в К1:

  • SSR: URL картинки есть в первом HTML;
  • priority: preload и fetchpriority="high";
  • sizes: на мобильном грузится вариант ~640–750px вместо 1200px+.

Осталось настроить конфиг:

// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  images: {
    remotePatterns: [{ protocol: 'https', hostname: 'cdn.woodcraft.ru' }], // ваш хост картинок
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 60 * 60 * 24 * 30,
  },
}

Ориентир: LCP около 2–2.5 с в лабораторных замерах. Полевые данные CrUX обновятся примерно через 28 дней.

В5. robots.txt и sitemap.xml

Тип MetadataRoute.Robots в Next 14 не поддерживает Clean-param, поэтому robots.txt отдаём через route handler:

// app/robots.txt/route.ts
export const dynamic = 'force-static'

export function GET() {
  const body = `User-agent: *
Disallow: /api/
Disallow: /cart
Disallow: /checkout
Disallow: /account
Allow: /

Clean-param: utm_source&utm_medium&utm_campaign&utm_content&utm_term&yclid&gclid /
Clean-param: sort&color /category/

Sitemap: https://woodcraft.ru/sitemap.xml
`
  return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
}

Clean-param понимает Яндекс, Google эту строку проигнорирует. Закрывать /api/ безопасно только после К1. Сейчас это сломало бы рендер карточки у Googlebot.

// app/sitemap.ts
import type { MetadataRoute } from 'next'

export const revalidate = 3600
const SITE = 'https://woodcraft.ru'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const [products, categories] = await Promise.all([
    fetch(`${process.env.API_URL}/products?fields=slug,updatedAt,status`).then(r => r.json()),
    fetch(`${process.env.API_URL}/categories?fields=slug,updatedAt`).then(r => r.json()),
  ])

  return [
    { url: SITE, lastModified: new Date(categories[0]?.updatedAt ?? Date.now()) },
    ...categories.map((c: any) => ({ url: `${SITE}/category/${c.slug}`, lastModified: new Date(c.updatedAt) })),
    ...products
      .filter((p: any) => p.status !== 'discontinued') // без 404/редиректов/неканоничных URL
      .map((p: any) => ({ url: `${SITE}/product/${p.slug}`, lastModified: new Date(p.updatedAt) })),
  ]
}
  • lastModified берите только из реального updatedAt, а не new Date(). Иначе поисковики перестанут доверять полю.
  • При объёме больше 50 000 URL используйте generateSitemaps() и разбейте карту по чанкам.
  • changefreq и priority Google игнорирует, их можно не заполнять.

🟡 Желательно

  1. Посадочные страницы под ценные фильтры. Спрос вида «стол из дуба» или «комод из ореха» лучше закрыть отдельными URL, например /category/stoly/dub. У такой страницы должны быть свой H1, вводный текст, self-canonical и место в sitemap. Параметр ?color= оставить для UI.
  2. INP и бандл. После перевода карточки на RSC в клиенте останутся только AddToCart, галерея и зум. Тяжёлую галерею грузите через next/dynamic, а размер бандла проверьте с помощью @next/bundle-analyzer.
  3. Редиректы без цепочек. http → https, www → без www и trailing slash нужно сводить в один 301 на уровне CDN или nginx. Цепочка http://www… → https://www… → https://… недопустима.
  4. Инвалидация кэша. В CMS или бэкенде настройте вебхук на revalidateTag('product:<slug>') при смене цены или наличия. Тогда цена в выдаче и в JSON-LD не будет расходиться с сайтом.
  5. Санитизация descriptionHtml при сохранении (sanitize-html). Это прежде всего безопасность, но заодно чистит мусорную разметку из CMS.
  6. Alt-тексты для галереи. Формат вроде «{название} — вид сбоку, массив дуба». Для мебели поиск по картинкам даёт заметный трафик.

3. План на неделю

День Правки Ожидаемый эффект
1 К1 + К2: карточка товара на RSC, generateMetadata, notFound(), 308 для замен, not-found.tsx. Проверка через curl: есть <h1>, правильный <title>, статус 404 на несуществующем slug. Контент и уникальные title видны всем ботам с первого ответа. Soft 404 превращаются в честные 404/308. Исчезают зависающие спиннеры.
2 В2 + К3: metadataBase, шаблон title, canonical на категориях, белый список фильтров, revalidate вместо no-store. Дубли с ?color/?sort/utm склеиваются с каноническим URL. Ниже TTFB на категориях.
3 В4 + В1: priority и sizes, images в next.config.js, next/font, next/image и <Link> на категории. Замер в PageSpeed и WebPageTest. LCP карточки с 4.1 до ~2–2.5 с (лаб.). CLS на категориях близок к 0.
4 В3: JSON-LD Product, BreadcrumbList, Organization. Проверка в Rich Results Test и валидаторе микроразметки Яндекса. Право на расширенные сниппеты с ценой, наличием и крошками. Выше CTR.
5 В5: robots.txt с Clean-param, sitemap.ts. Отправка sitemap в GSC и Яндекс.Вебмастер. Политика для снятых товаров, при необходимости 410 через middleware. Бот обходит только канонические URL. Быстрее выпадают мёртвые страницы. Свежий lastmod ускоряет переобход.
6–7 Мониторинг: проверка URL в GSC на 5–10 товарах, отчёт «Страницы» (динамика soft 404 и дублей), «Страницы в поиске» в Вебмастере. Пункты из «Желательно»: посадочные под фильтры, bundle-analyzer, вебхук revalidateTag. Подтверждение исправлений. Первые изменения в индексе через 1–3 недели, в полевых CWV через ~28 дней.

Когда закрывать ?sort= в robots.txt. Только когда в GSC число проиндексированных URL с ?sort= упадёт до нуля, обычно через 3–6 недель. Сделать это раньше значит заморозить дубли в индексе.

Похожие промпты

Программирование и Разработка

Ревью pull request по diff

Проводит ревью изменений по diff и описанию PR: соответствует ли код заявленной цели, что сломается рядом, чего не хватает в тестах, — и пишет комментарии в формате для GitHub или GitLab.

{} 3 перем.
Программирование и Разработка

Ревью Python-кода: типизация и идиомы

Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».

{} 3 перем.
Программирование и Разработка

Ревью SQL-запросов и миграций

Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.

{} 3 перем.
Программирование и Разработка

Ревью Next.js-кода: App Router и Server Components

Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».

{} 4 перем.