Ревью Next.js-кода: App Router и Server Components
Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».
Пример запуска
Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.
Значения переменных
- Версия next.js
- 14.2.5 (App Router)
- Стратегия рендеринга
- Смешанная: каталог товаров — ISR (revalidate 300с), карточка товара — SSG с generateStaticParams частично + fallback SSR для новых товаров, админка заказов — SSR (force-dynamic)
- Описание приложения
- «Kaktus Store» — интернет-магазин комнатных растений и кашпо для небольшой розничной сети (3 офлайн-точки в Санкт-Петербурге + доставка по РФ). Стек: Next.js 14 App Router, Prisma + PostgreSQL (Neon), NextAuth (email+пароль для админов), Tailwind CSS, Zod для валидации форм. Публичная часть: - /catalog — витрина товаров с фильтрами по категории (кактусы, суккуленты, кашпо), карточки подгружаются списком, обновляется раз в 5 минут. - /catalog/[slug] — страница товара: галерея, описание, блок отзывов покупателей (можно оставить отзыв без регистрации, только имя и текст), блок «похожие товары». - /cart, /checkout — оформление заказа, интеграция с ЮKassa для оплаты. Приватная часть (за NextAuth, роль admin): - /admin/orders — список заказов с фильтром по статусу (новый/оплачен/отправлен/доставлен), возможность сменить статус вручную. - /admin/orders/[id] — карточка заказа, история изменений, кнопка «пересчитать сумму доставки» дергает внешний API СДЭК. Известные проблемы, с которыми уже сталкивались: после смены статуса заказа в админке список на /admin/orders иногда показывает старые данные ещё несколько секунд (приходится руками жать F5); карточка товара на мобильных долго грузится из-за большого JS-бандла; при добавлении отзыва страница товара визуально не обновляется, пока не перезайти. Хотим ревью конкретных файлов ниже, чтобы понять корневые причины.
- Код компонентов или маршрутов
- // app/catalog/[slug]/page.tsx import { prisma } from '@/lib/prisma' import ProductGallery from './ProductGallery' import ReviewsBlock from './ReviewsBlock' import RelatedProducts from './RelatedProducts' export default async function ProductPage({ params }: { params: { slug: string } }) { const product = await prisma.product.findUnique({ where: { slug: params.slug }, include: { images: true }, }) const reviews = await prisma.review.findMany({ where: { productId: product?.id }, orderBy: { createdAt: 'desc' }, }) const related = await prisma.product.findMany({ where: { category: product?.category }, take: 4, }) return ( <div className="grid grid-cols-2 gap-8"> <ProductGallery images={product.images} /> <div> <h1 className="text-2xl font-bold">{product.name}</h1> <p>{product.description}</p> <ReviewsBlock reviews={reviews} db={prisma} productId={product.id} /> </div> <RelatedProducts items={related} /> </div> ) } // app/catalog/[slug]/ReviewsBlock.tsx 'use client' import { addReview } from './actions' export default function ReviewsBlock({ reviews, db, productId }: any) { return ( <div> <ul> {reviews.map((r: any) => ( <li key={r.id}> <b>{r.authorName}</b>: {r.text} </li> ))} </ul> <form action={addReview}> <input type="hidden" name="productId" value={productId} /> <input name="authorName" placeholder="Ваше имя" /> <textarea name="text" placeholder="Отзыв" /> <button type="submit">Отправить</button> </form> </div> ) } // app/catalog/[slug]/actions.ts 'use server' import { prisma } from '@/lib/prisma' export async function addReview(formData: FormData) { const productId = formData.get('productId') as string const authorName = formData.get('authorName') as string const text = formData.get('text') as string await prisma.review.create({ data: { productId, authorName, text }, }) } // app/catalog/[slug]/ProductGallery.tsx 'use client' export default function ProductGallery({ images }: { images: { url: string }[] }) { return ( <div className="flex gap-2"> {images.map((img) => ( <img key={img.url} src={img.url} alt="" width={300} /> ))} </div> ) } // app/admin/orders/page.tsx import { prisma } from '@/lib/prisma' import OrderStatusForm from './OrderStatusForm' export default async function OrdersPage() { const orders = await prisma.order.findMany({ orderBy: { createdAt: 'desc' }, }) return ( <table> <tbody> {orders.map((o) => ( <tr key={o.id}> <td>{o.id}</td> <td>{o.status}</td> <td> <OrderStatusForm orderId={o.id} currentStatus={o.status} /> </td> </tr> ))} </tbody> </table> ) } // app/admin/orders/OrderStatusForm.tsx 'use client' import { updateStatus } from './actions' export default function OrderStatusForm({ orderId, currentStatus }: { orderId: string; currentStatus: string }) { return ( <form action={updateStatus.bind(null, orderId)}> <select name="status" defaultValue={currentStatus}> <option value="new">Новый</option> <option value="paid">Оплачен</option> <option value="shipped">Отправлен</option> <option value="delivered">Доставлен</option> </select> <button type="submit">Сохранить</button> </form> ) } // app/admin/orders/actions.ts 'use server' import { prisma } from '@/lib/prisma' export async function updateStatus(orderId: string, formData: FormData) { const status = formData.get('status') as string await prisma.order.update({ where: { id: orderId }, data: { status }, }) }
Ревью Kaktus Store: страница товара и админка заказов
Резюме
Архитектура в целом правильная: данные загружаются в серверных компонентах, мутации сделаны через Server Actions, клиентские компоненты небольшие. Проблемы сосредоточены в мутациях. Ни один Server Action не проверяет, кто его вызывает и что пришло, и ни один не инвалидирует кеш. Отсутствие инвалидации — корневая причина обоих известных багов: устаревшего списка заказов и «невидимого» нового отзыва. Отсутствие проверки роли в updateStatus — дыра в безопасности, её нужно закрыть до всего остального.
Замечания по приоритету
🔴 Критично
1. updateStatus не проверяет авторизацию и значение статуса
Где: app/admin/orders/actions.ts
Почему это проблема. Server Action — это публичный POST-эндпоинт с ID действия. Проверка сессии в layout.tsx или на странице его не защищает: layout не выполняется при вызове action, а action можно вызвать напрямую, минуя UI. Документация Next.js прямо требует проверять авторизацию внутри каждого action.
Если /admin/* закрыт middleware, это частично спасает. Но полагаться на matcher как на единственную защиту хрупко: достаточно одного изменения конфигурации.
Ещё два момента:
- Аргументы из
.bind(null, orderId)в Next 14 не шифруются. Они приходят с клиента и могут быть подменены, поэтомуorderIdнельзя считать доверенным. status— произвольная строка. В базу можно записать"hacked", а если в Prisma это enum, упадёт необработанное исключение.
До:
export async function updateStatus(orderId: string, formData: FormData) {
const status = formData.get('status') as string
await prisma.order.update({ where: { id: orderId }, data: { status } })
}
После:
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const StatusSchema = z.enum(['new', 'paid', 'shipped', 'delivered'])
export async function updateStatus(orderId: string, formData: FormData) {
const session = await getServerSession(authOptions)
// поле role зависит от ваших callbacks в NextAuth
if (session?.user?.role !== 'admin') {
throw new Error('Forbidden')
}
const status = StatusSchema.parse(formData.get('status'))
const id = z.string().min(1).parse(orderId)
await prisma.order.update({ where: { id }, data: { status } })
revalidatePath('/admin/orders')
revalidatePath(`/admin/orders/${id}`)
}
Если в карточке заказа есть «история изменений», её запись логично делать здесь же, в одной prisma.$transaction с обновлением. В показанном коде история не пишется. Проверьте, что она не теряется при ручной смене статуса.
2. PrismaClient передаётся в Client Component
Где: page.tsx → <ReviewsBlock ... db={prisma} />
Почему это проблема. Через границу server → client можно передавать только сериализуемые значения: примитивы, простые объекты, массивы, Date, промисы и Server Actions. PrismaClient — экземпляр класса, и React выдаст ошибку «Only plain objects… can be passed to Client Components».
Даже если бы это работало, клиенту нечего делать с подключением к базе. Проп при этом нигде не используется. Проскочил он из-за any в типах ReviewsBlock (см. п. 14).
До:
<ReviewsBlock reviews={reviews} db={prisma} productId={product.id} />
После:
<ReviewsBlock reviews={reviews} productId={product.id} />
3. Нет обработки несуществующего товара: краш и выборка чужих данных
Где: page.tsx, всё после findUnique.
Почему это проблема. Если slug не найден, product === null, и происходит следующее:
where: { productId: product?.id }превращается в{ productId: undefined }. Prisma трактуетundefinedкак «фильтра нет», поэтому загружаются все отзывы всех товаров.where: { category: undefined }возвращает 4 случайных товара.product.imagesвыбрасывает TypeError. Вместо 404 пользователь получает 500, а поисковик — ошибку сервера.
При dynamicParams = true (fallback для новых товаров) любой бот, перебирающий URL, будет генерировать тяжёлые запросы и ошибки.
До:
const product = await prisma.product.findUnique({ ... })
const reviews = await prisma.review.findMany({ where: { productId: product?.id }, ... })
После:
import { notFound } from 'next/navigation'
const product = await getProduct(params.slug)
if (!product) notFound()
// ниже product гарантированно не null, и TS это знает
Добавьте app/catalog/[slug]/not-found.tsx с дружелюбной страницей «Товар не найден» и ссылкой в каталог.
4. Корневая причина бага «список заказов показывает старые данные»
Где: admin/orders/actions.ts: нет revalidatePath.
Почему это проблема. Когда Server Action завершается вызовом revalidatePath, Next.js в том же ответе отдаёт свежий RSC-payload текущего маршрута и сбрасывает клиентский Router Cache. Без этого вызова происходит следующее:
- Запись в базу проходит, но клиент новый payload не получает.
<select>показывает выбранное значение (он uncontrolled), а ячейка<td>{o.status}</td>остаётся старой. - Если перейти на
/admin/orders/[id]и вернуться, Router Cache отдаёт закешированную версию. В 14.2 она хранится 30 секунд для динамических страниц. Отсюда и «несколько секунд, потом F5».
Исправление уже включено в п. 1: revalidatePath('/admin/orders') и revalidatePath('/admin/orders/${id}').
Отдельно проверьте, что страница действительно динамическая. В показанном page.tsx нет export const dynamic = 'force-dynamic'. Prisma — это не fetch, поэтому Next сам динамику не обнаружит. Если layout админки не читает cookies()/headers() (например, через getServerSession), страница соберётся статически на этапе build и будет показывать снимок заказов с момента деплоя. Лучше объявить это явно:
// app/admin/orders/page.tsx (или app/admin/layout.tsx)
export const dynamic = 'force-dynamic'
5. Корневая причина бага «отзыв не появляется»
Где: catalog/[slug]/actions.ts: нет revalidatePath.
Почему это проблема. Страница товара статическая (SSG/ISR). HTML и RSC-payload лежат в Full Route Cache. addReview пишет в базу, но кеш страницы не трогает. Пользователь видит старую версию, пока не истечёт revalidate, а если его нет — вообще никогда (см. п. 7). «Перезайти» помогает только тогда, когда совпадёт с фоновой регенерацией.
Исправление объединено с п. 6 ниже: там же валидация, и slug для инвалидации берётся из базы.
🟠 Важно
6. addReview — анонимная запись без валидации и защиты от спама
Где: catalog/[slug]/actions.ts
Почему это проблема. Это единственный эндпоинт, в который пишет кто угодно без регистрации. Сейчас:
- Пустое имя, пустой текст или текст на 5 МБ сохраняются без ограничений.
- Несуществующий
productIdприводит к ошибке внешнего ключа, пользователь видит error boundary вместо сообщения. - Каждый вызов после исправления п. 5 запускает регенерацию ISR. Без rate limit это ещё и способ нагрузить сервер.
XSS здесь, кстати, нет: React экранирует {r.text}. Это хорошо, только не переходите на dangerouslySetInnerHTML.
После:
// app/catalog/[slug]/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
const ReviewSchema = z.object({
productId: z.string().min(1),
authorName: z.string().trim().min(1, 'Укажите имя').max(60),
text: z.string().trim().min(3, 'Слишком короткий отзыв').max(2000),
})
export type ReviewState = {
ok: boolean
errors?: Record<string, string[] | undefined>
}
export async function addReview(
_prev: ReviewState,
formData: FormData,
): Promise<ReviewState> {
const parsed = ReviewSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors }
}
// slug берём из БД, а не с клиента, — нельзя инвалидировать произвольный путь
const product = await prisma.product.findUnique({
where: { id: parsed.data.productId },
select: { slug: true },
})
if (!product) return { ok: false, errors: { productId: ['Товар не найден'] } }
// TODO: rate limit по IP (например, @upstash/ratelimit) + honeypot-поле
await prisma.review.create({ data: parsed.data })
revalidatePath(`/catalog/${product.slug}`)
return { ok: true }
}
Премодерация (флаг approved) — продуктовое решение, не требование. Но для анонимных отзывов её стоит обсудить.
7. На странице товара нет конфигурации рендеринга, заявленной в контексте
Где: catalog/[slug]/page.tsx
Почему это проблема. По описанию это SSG с частичным generateStaticParams и fallback. В коде нет ни generateStaticParams, ни revalidate. Если их нет и в родительском layout, происходит следующее:
- На этапе build ничего не пререндерится, каждый товар рендерится при первом запросе.
- Отрендеренная страница кешируется бессрочно: запросы через Prisma не делают маршрут динамическим. Цена, наличие и описание не обновятся до следующего деплоя.
После:
export const revalidate = 300 // как у каталога; плюс on-demand через revalidatePath
export const dynamicParams = true // новые товары рендерятся по запросу (это и есть fallback)
export async function generateStaticParams() {
const products = await prisma.product.findMany({
select: { slug: true },
take: 100, // например, топ по продажам/новизне
})
return products.map((p) => ({ slug: p.slug }))
}
Когда в админке появится редактирование товаров, добавьте туда revalidatePath('/catalog/' + slug) и revalidatePath('/catalog').
8. Водопад из трёх последовательных запросов
Где: page.tsx, три await подряд.
Почему это проблема. reviews и related зависят от product (id и category), но не друг от друга. Сейчас время ответа складывается из трёх сетевых кругов до Neon. У serverless-Postgres каждый круг заметен, особенно на холодном соединении. Параллельный запуск убирает один круг.
После (вместе с п. 3 и п. 11):
import { cache } from 'react'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { prisma } from '@/lib/prisma'
// cache() дедуплицирует запрос между generateMetadata и page в рамках одного рендера.
// Для fetch это делается автоматически, для Prisma — нет.
const getProduct = cache((slug: string) =>
prisma.product.findUnique({ where: { slug }, include: { images: true } }),
)
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug)
if (!product) notFound()
const [reviews, related] = await Promise.all([
prisma.review.findMany({
where: { productId: product.id },
orderBy: { createdAt: 'desc' },
take: 50,
}),
prisma.product.findMany({
where: { category: product.category, id: { not: product.id } },
take: 4,
}),
])
return (/* ... */)
}
9. Медленная загрузка на мобильных: скорее всего, дело в картинках, а не в JS
Где: ProductGallery.tsx
Почему это проблема. Честно: показанные клиентские компоненты крошечные и не объясняют «большой JS-бандл». Реальная причина медленной карточки, скорее всего, в другом. Сырой <img width={300}> заставляет браузер качать оригинал (часто 2–4 МБ с камеры) без srcset, без WebP/AVIF и без lazy loading для нижних картинок. На мобильном интернете это и даёт медленный LCP.
next/image сжимает, отдаёт нужный размер под экран и формат, лениво грузит всё, кроме priority, и резервирует место (нет CLS).
До:
'use client'
export default function ProductGallery({ images }: { images: { url: string }[] }) {
return (
<div className="flex gap-2">
{images.map((img) => <img key={img.url} src={img.url} alt="" width={300} />)}
</div>
)
}
После:
// без 'use client' — интерактивности нет
import Image from 'next/image'
type Props = { images: { url: string }[]; productName: string }
export default function ProductGallery({ images, productName }: Props) {
return (
<div className="flex gap-2">
{images.map((img, i) => (
<Image
key={img.url}
src={img.url}
alt={`${productName} — фото ${i + 1}`}
width={300}
height={300}
sizes="(max-width: 768px) 50vw, 300px"
priority={i === 0} // первое фото — кандидат в LCP
className="object-cover"
/>
))}
</div>
)
}
Для внешнего хранилища картинок добавьте images.remotePatterns в next.config.js. Пустой alt="" у фото товара вредит и доступности, и SEO картинок.
Чтобы найти, где на самом деле тяжёлый JS, посмотрите колонку First Load JS в выводе next build и прогоните @next/bundle-analyzer. Типичные подозреваемые, которых нет в показанных файлах:
RelatedProducts(не показан; если там'use client'и слайдер, это кандидат №1);SessionProviderиз NextAuth в корневом layout: он тянет next-auth/react на все публичные страницы, хотя авторизация нужна только в/admin;- библиотеки каруселей и дат, импортированные целиком.
10. Лишний 'use client' у ReviewsBlock
Где: ReviewsBlock.tsx
Почему это проблема. <form action={serverAction}> работает и в серверном компоненте, причём с progressive enhancement: форма отправляется даже до гидратации. Сейчас весь список отзывов уходит в клиентский компонент: массив сериализуется в RSC-payload как пропсы, плюс JS компонента. Клиентская интерактивность нужна только форме, и то лишь если хотите показывать ошибки валидации и состояние отправки. Поэтому лучше разделить: список на сервере, форма — маленький клиентский остров.
После:
// ReviewsBlock.tsx — серверный компонент
import type { Review } from '@prisma/client'
import ReviewForm from './ReviewForm'
export default function ReviewsBlock({ reviews, productId }: { reviews: Review[]; productId: string }) {
return (
<section>
<ul>
{reviews.map((r) => (
<li key={r.id}><b>{r.authorName}</b>: {r.text}</li>
))}
</ul>
<ReviewForm productId={productId} />
</section>
)
}
// ReviewForm.tsx — единственный клиентский кусок
'use client'
import { useEffect, useRef } from 'react'
import { useFormState, useFormStatus } from 'react-dom'
import { addReview, type ReviewState } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return <button type="submit" disabled={pending}>{pending ? 'Отправляем…' : 'Отправить'}</button>
}
export default function ReviewForm({ productId }: { productId: string }) {
const [state, action] = useFormState<ReviewState, FormData>(addReview, { ok: false })
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => { if (state.ok) formRef.current?.reset() }, [state])
return (
<form ref={formRef} action={action}>
<input type="hidden" name="productId" value={productId} />
<input name="authorName" placeholder="Ваше имя" />
{state.errors?.authorName && <p className="text-red-600">{state.errors.authorName[0]}</p>}
<textarea name="text" placeholder="Отзыв" />
{state.errors?.text && <p className="text-red-600">{state.errors.text[0]}</p>}
<SubmitButton />
</form>
)
}
Благодаря revalidatePath в action новый отзыв появится в списке сразу, в том же ответе, без перезахода.
11. Нет generateMetadata на странице товара
Где: catalog/[slug]/page.tsx
Почему это проблема. Для интернет-магазина карточка товара — главная посадочная страница из поиска. Сейчас у всех товаров одинаковый title из корневого layout, нет description и OG-картинки для превью в Telegram/VK.
После (использует getProduct из п. 8, второго запроса к базе не будет):
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const product = await getProduct(params.slug)
if (!product) return {}
return {
title: `${product.name} — Kaktus Store`,
description: product.description?.slice(0, 160),
openGraph: { images: product.images[0] ? [product.images[0].url] : [] },
}
}
🟡 Желательно
12. «Похожие товары» включают сам товар
where: { category } без исключения текущего id показывает в блоке ту же позицию. Исправление уже есть в п. 8 (id: { not: product.id }). Если RelatedProducts показывает только название, цену и превью, добавьте select, чтобы не тянуть описания целиком.
13. Админка: пагинация, фильтр и обратная связь
prisma.order.findMany()безtakeиselectсо временем начнёт загружать тысячи заказов со всеми полями. Добавьтеselectнужных колонок и пагинацию. Фильтр по статусу из описания логично сделать черезsearchParams(?status=paid): URL станет shareable, а страница и так динамическая.- Кнопке «Сохранить» нужен
useFormStatus(какSubmitButtonв п. 10). Иначе админ жмёт повторно, не видя, что запрос идёт. app/admin/orders/loading.tsxдаст мгновенную обратную связь при навигации: страница динамическая и каждый раз ждёт базу.
14. any в пропсах ReviewsBlock
Именно any позволил проп db={prisma} пройти незамеченным (п. 2). Типы из @prisma/client (Review, Product) бесплатны и ловят такие ошибки на этапе компиляции.
15. Suspense-границы
Для статической страницы товара выигрыш от Suspense вокруг отзывов и похожих товаров небольшой: всё рендерится один раз при build или регенерации, а пользователь получает готовый HTML. Сейчас это не приоритет. Suspense станет полезен, если отзывы когда-нибудь станут динамическими (например, персонализированными). Для админки хватит loading.tsx из п. 13.
Шрифты: в показанных файлах их нет. Проверьте в корневом layout, что используется next/font, а не @font-face или <link> на Google Fonts.
Что сделано хорошо
- Мутации через
<form action={serverAction}>. Это правильный идиоматичный паттерн App Router: нет ручных API-роутов, формы работают до гидратации. После правок выше останется только добавить проверки, архитектуру менять не нужно. updateStatus.bind(null, orderId)вместо hidden-поля. Чистый способ передать контекст в action. Стоит лишь помнить, что bound-аргументы приходят с клиента и валидируются как любой ввод.- Правильное направление границ. Страницы — серверные компоненты, которые загружают данные напрямую из Prisma без промежуточного API. Клиентские компоненты — листья дерева, а не обёртки. Лишний
'use client'в двух местах легко снять, а не переделывать всю структуру.
Похожие промпты
Ревью pull request по diff
Проводит ревью изменений по diff и описанию PR: соответствует ли код заявленной цели, что сломается рядом, чего не хватает в тестах, — и пишет комментарии в формате для GitHub или GitLab.
Ревью Python-кода: типизация и идиомы
Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».
Ревью SQL-запросов и миграций
Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.
SEO-аудит Next.js-приложения
Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.