SEO-аудит Next.js-приложения
Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.
Пример запуска
Сгенерировано 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 растёт раздел «Дубликат, канонический вариант не выбран пользователем».
Как чинить.
- Задать canonical на чистый URL категории. Если есть пагинация, у каждой страницы свой canonical:
?page=2указывает на себя, а не на первую страницу. - Пропускать в API только известные значения фильтров из белого списка.
- Для Яндекса добавить
Clean-param(см. В5). - Не закрывать
?sort=вrobots.txtсразу. Робот должен сначала увидеть canonical. Если заблокировать URL раньше, уже проиндексированные дубли зависнут со статусом «Проиндексировано, несмотря на блокировку». Закрывать можно только после того, как дубли выпадут. - Не смешивать 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иpriorityGoogle игнорирует, их можно не заполнять.
🟡 Желательно
- Посадочные страницы под ценные фильтры. Спрос вида «стол из дуба» или «комод из ореха» лучше закрыть отдельными URL, например
/category/stoly/dub. У такой страницы должны быть свой H1, вводный текст, self-canonical и место в sitemap. Параметр?color=оставить для UI. - INP и бандл. После перевода карточки на RSC в клиенте останутся только
AddToCart, галерея и зум. Тяжёлую галерею грузите черезnext/dynamic, а размер бандла проверьте с помощью@next/bundle-analyzer. - Редиректы без цепочек.
http → https,www → без wwwи trailing slash нужно сводить в один 301 на уровне CDN или nginx. Цепочкаhttp://www… → https://www… → https://…недопустима. - Инвалидация кэша. В CMS или бэкенде настройте вебхук на
revalidateTag('product:<slug>')при смене цены или наличия. Тогда цена в выдаче и в JSON-LD не будет расходиться с сайтом. - Санитизация
descriptionHtmlпри сохранении (sanitize-html). Это прежде всего безопасность, но заодно чистит мусорную разметку из CMS. - 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.
Ревью Python-кода: типизация и идиомы
Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».
Ревью SQL-запросов и миграций
Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.
Ревью Next.js-кода: App Router и Server Components
Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».