О насБлогКонтакты
Разработка17 апреля 2026 г. 6 мин 3

Next.js 15 App Router: Server Components, кэширование и реальные подводные камни

AunimedaAunimeda
📋 Содержание

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, Разработка сайтов Бишкек

Читайте также

Чистая архитектура в Node.js: как организовать код чтобы не стать заложникомaunimeda
Разработка

Чистая архитектура в Node.js: как организовать код чтобы не стать заложником

Большинство Node.js проектов начинаются с routes + controllers + models и через полгода превращаются в спагетти. Показываем как правильно разделить бизнес-логику от инфраструктуры — use cases, repository pattern, тестируемый код без базы данных.

Вайб-кодинг в Бишкеке: как местные разработчики используют ИИ для ускорения работы в 2026aunimeda
Разработка

Вайб-кодинг в Бишкеке: как местные разработчики используют ИИ для ускорения работы в 2026

Разработчики в Бишкеке используют Cursor AI, Claude Code и ChatGPT не как поиск по Stack Overflow, а как полноценного соавтора кода. Что изменилось, кто выигрывает и как не потерять квалификацию.

Микросервисы или монолит: что выбрать для разработки в Кыргызстане в 2026aunimeda
Разработка

Микросервисы или монолит: что выбрать для разработки в Кыргызстане в 2026

Честное сравнение монолитной и микросервисной архитектуры для разработки в Кыргызстане: когда микросервисы губят стартап, а когда монолит тормозит рост. С примерами из практики.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Кыргызстане. Бесплатная консультация.

Получить консультацию Все статьи