Next.js 15 App Router: Server Components, кэширование и реальные подводные камни
Когда Next.js App Router появился, почти все переписали его неправильно. Слишком много 'use client' там, где оно не нужно; кэширование, которое работает неожиданно; Suspense без понимания что он делает; Server Actions, которые непонятно как дебажить.
В этой статье — честный технический разбор того, как это всё работает на самом деле, с реальными примерами кода.
Server Component vs Client Component: реальное правило
Маркетинговое объяснение: «Server Components рендерятся на сервере, Client Components — на клиенте». Практическое правило:
Компонент должен быть Client Component если:
- Использует React hooks (
useState,useEffect,useCallbackи т.д.) - Подписывается на browser-only API (
window,document,localStorage) - Использует event handlers (
onClick,onChange) - Использует Context с
useContext
Во всех остальных случаях — Server Component по умолчанию (в App Router файлы без 'use client' — серверные).
Частая ошибка: добавлять 'use client' на layout или крупный компонент-обёртку только потому что один дочерний элемент нуждается в интерактивности. Правильно — изолировать интерактивную часть:
// ❌ Неправильно: делаем весь ProductPage клиентским из-за одной кнопки
'use client';
export default function ProductPage({ product }) {
const [count, setCount] = useState(1);
return (
<div>
<h1>{product.name}</h1> {/* Это статично — не нужен Client */}
<p>{product.description}</p> {/* Это статично — не нужен Client */}
<button onClick={() => setCount(c => c + 1)}>{count}</button>
</div>
);
}
// ✅ Правильно: Server Component + маленький Client Component
// components/ProductPage.tsx (Server Component — нет 'use client')
export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} /> {/* Client Component */}
</div>
);
}
// components/AddToCartButton.tsx
'use client';
export function AddToCartButton({ productId }: { productId: string }) {
const [count, setCount] = useState(1);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Server Components могут содержать Client Components. Обратное — нет: Client Component не может импортировать Server Component напрямую (но может получить его через children props).
Кэширование в Next.js 15: что изменилось
Next.js 14 кэшировал fetch по умолчанию (force-cache). Это создавало сюрпризы: данные не обновлялись, разработчики не понимали почему.
Next.js 15 изменил дефолт: fetch теперь no-store по умолчанию.
// Next.js 15 — это не кэшируется (no-store по умолчанию):
const data = await fetch('/api/products');
// Явное кэширование с ревалидацией каждые 60 секунд:
const data = await fetch('/api/products', {
next: { revalidate: 60 }
});
// Статический кэш на всё время сборки:
const data = await fetch('/api/products', {
cache: 'force-cache'
});
Три стратегии ревалидации
Time-based: данные пересчитываются через N секунд после последнего запроса.
// page.tsx — вся страница ревалидируется каждые 5 минут
export const revalidate = 300;
On-demand revalidation: ревалидация через вызов API — например, после обновления в CMS.
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const { tag, secret } = await req.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
revalidateTag(tag);
return Response.json({ revalidated: true });
}
// В компоненте — тегируем fetch:
const products = await fetch('/api/products', {
next: { tags: ['products'] }
});
// После вызова revalidateTag('products') — кэш сбрасывается
Render-dynamic: страница всегда рендерится динамически.
export const dynamic = 'force-dynamic'; // никакого кэша
Suspense: как это реально работает
Suspense позволяет показывать fallback пока дочерний компонент ожидает данные. Это не магия — React отлавливает throw Promise (или в Server Components — асинхронный рендер).
// app/products/page.tsx
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<main>
<h1>Каталог</h1>
{/* Заголовок рендерится сразу */}
<Suspense fallback={<ProductsSkeleton />}>
<ProductList /> {/* Стримится когда готово */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<LatestReviews /> {/* Независимо от ProductList */}
</Suspense>
</main>
);
}
// Каждый компонент загружает свои данные независимо:
async function ProductList() {
const products = await db.product.findMany({ take: 20 }); // медленный запрос
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
async function LatestReviews() {
const reviews = await db.review.findMany({ take: 5 }); // быстрый запрос
return <div>{reviews.map(r => <p key={r.id}>{r.text}</p>)}</div>;
}
LatestReviews не ждёт ProductList. Пользователь видит быстрый блок сразу. Это настоящий streaming SSR.
Частая ошибка — waterfall через вынос fetch наверх:
// ❌ Waterfall: сначала ждём products, потом reviews
async function ProductsPage() {
const products = await fetchProducts(); // 800ms
const reviews = await fetchReviews(); // 600ms
// Итого: 1400ms
}
// ✅ Параллельно:
async function ProductsPage() {
const [products, reviews] = await Promise.all([
fetchProducts(),
fetchReviews(),
]);
// Итого: 800ms
}
Server Actions: правильный паттерн
Server Actions — это функции, которые выполняются на сервере, но вызываются с клиента. Идеальны для форм.
// app/orders/new/page.tsx
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
const OrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(100),
address: z.string().min(10),
});
// Server Action — пометка 'use server' внутри функции или файла
async function createOrder(formData: FormData) {
'use server';
const raw = {
productId: formData.get('productId'),
quantity: Number(formData.get('quantity')),
address: formData.get('address'),
};
const parsed = OrderSchema.safeParse(raw);
if (!parsed.success) {
// Возвращаем ошибки — клиент их получит
return { error: parsed.error.flatten() };
}
// Прямое обращение к БД — нет API round-trip
await db.order.create({
data: {
...parsed.data,
userId: await getCurrentUserId(), // серверная сессия
}
});
revalidatePath('/orders');
redirect('/orders');
}
// Форма — нет JavaScript обработчика на клиенте
export default function NewOrderPage() {
return (
<form action={createOrder}>
<input type="hidden" name="productId" value="..." />
<input name="quantity" type="number" defaultValue={1} />
<textarea name="address" />
<button type="submit">Оформить заказ</button>
</form>
);
}
Форма работает даже без JavaScript (progressive enhancement). С JS — React перехватывает сабмит и вызывает action асинхронно.
Optimistic Updates с useOptimistic
'use client';
import { useOptimistic, useTransition } from 'react';
export function LikeButton({ postId, initialLikes }: Props) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, increment: number) => state + increment
);
const [isPending, startTransition] = useTransition();
async function handleLike() {
startTransition(async () => {
addOptimisticLike(1); // мгновенно обновляем UI
await likePost(postId); // Server Action — реальный запрос
});
}
return (
<button onClick={handleLike} disabled={isPending}>
❤️ {optimisticLikes}
</button>
);
}
Динамический vs Статический рендеринг: как Next.js решает
Next.js автоматически определяет режим рендеринга. Страница становится динамической если:
- Использует
cookies(),headers(),searchParams - Использует
noStore()илиfetchсno-store - Данные в runtime не известны на этапе сборки
Если ни одного из этого нет — страница статическая. Статические страницы генерируются один раз при сборке и раздаются через CDN мгновенно.
import { cookies } from 'next/headers'; // ← делает страницу динамической
export default async function Dashboard() {
const session = await getSession(cookies()); // динамически
const user = await db.user.findUnique({ where: { id: session.userId } });
// ...
}
Совет: держите динамические части в компонентах-листьях, обёрнутых в Suspense. Остальная страница будет статической/стримящейся.
Aunimeda строит production Next.js приложения для бизнеса в Кыргызстане. Обсудите проект.
Смотрите также: tRPC + Zod для fullstack, Разработка сайтов Бишкек