Перейти к содержимому

29. Next.js 15: новые возможности

Привет! 👋 Next.js 15 вышел и принёс с собой несколько изменений, которые ломают обратную совместимость. Паниковать не нужно — большинство изменений можно мигрировать автоматически. Но понять их важно, иначе приложение начнёт вести себя странно.

Думай об этом как об обновлении операционной системы 💻. Большинство программ работает как раньше, но некоторые API изменились, и нужно обновить свои привычки. Погнали разбираться!


🗺️ Обзор Next.js 15: что нового и почему это важно

Заголовок раздела «🗺️ Обзор Next.js 15: что нового и почему это важно»

Next.js 15 — это не революция, а эволюция. Команда Vercel исправила несколько архитектурных решений, которые создавали неожиданное поведение:

  • ⚡ Async Request APIscookies(), headers(), params теперь возвращают Promise
  • 🗄️ Изменения кэшированияfetch больше НЕ кэшируется автоматически
  • 🚀 Turbopack стабиленnext dev --turbo выходит из beta
  • 🔀 Partial Prerendering — PPR экспериментально стабилизирован
  • unstable_after() — выполнение кода после отправки response
  • 📝 Новый <Form> — умный компонент формы с prefetch
  • ⚛️ React 19 — полная поддержка React 19 с новыми хуками
  • 📁 next.config.ts — TypeScript конфиг вместо .mjs

Версия React минимум — 18.2.0, но рекомендуется 19.x.


Это самое важное breaking change в Next.js 15. Функции cookies(), headers(), params и searchParams теперь асинхронные!

Почему это сломает твой код:

// ❌ Next.js 14 — синхронный доступ (скоро устареет!)
import { cookies, headers } from 'next/headers';
export default function Page({ params }: { params: { id: string } }) {
const cookieStore = cookies(); // синхронно
const headersList = headers(); // синхронно
const token = cookieStore.get('auth-token');
const id = params.id; // синхронно
return <div>User ID: {id}</div>;
}
// ✅ Next.js 15 — асинхронный доступ
import { cookies, headers } from 'next/headers';
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const cookieStore = await cookies(); // обязательный await
const headersList = await headers(); // обязательный await
const token = cookieStore.get('auth-token');
const { id } = await params; // await params тоже!
return <div>User ID: {id}</div>;
}

Это касается всех API динамических данных:

app/products/[id]/page.tsx
import { cookies, headers } from 'next/headers';
import type { NextRequest } from 'next/server';
// Все эти API теперь асинхронные:
export default async function ProductPage({
params,
searchParams,
}: {
params: Promise<{ id: string; category: string }>;
searchParams: Promise<{ sort: string; page: string }>;
}) {
// params — динамические сегменты пути
const { id, category } = await params;
// searchParams — параметры строки запроса
const { sort, page } = await searchParams;
// cookies и headers — тоже async
const cookieStore = await cookies();
const headersList = await headers();
const userAgent = headersList.get('user-agent');
const session = cookieStore.get('session');
return (
<div>
<h1>Продукт {id} в категории {category}</h1>
<p>Сортировка: {sort}, Страница: {page}</p>
</div>
);
}

🤔 Почему сделали Async? Философия изменения

Заголовок раздела «🤔 Почему сделали Async? Философия изменения»

Это может казаться усложнением, но за этим стоит важная причина!

В Next.js 14 cookies() и headers() были синхронными, но при этом делали весь роут динамическим — даже если ты только импортировал их, но не вызывал!

// Next.js 14: ВЕСЬ роут становится динамическим из-за импорта!
import { cookies } from 'next/headers'; // ← импорт = динамический роут
export default async function Page() {
// Даже если ты не вызываешь cookies() —
// Next.js всё равно не может статически рендерить эту страницу
const data = await fetch('https://api.example.com/static-data');
return <div>{data}</div>;
}

С асинхронными API: динамичность происходит только в момент вызова await cookies(). Это открывает путь к Partial Prerendering (PPR), где статическая и динамическая части могут сосуществовать!


🔧 Практика миграции: await cookies(), await headers(), await params

Заголовок раздела «🔧 Практика миграции: await cookies(), await headers(), await params»

Вот конкретные сценарии миграции:

Middleware — без изменений! (уже использовал async)

// middleware.ts — не меняется
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// request.cookies — всё ещё синхронный в middleware
const token = request.cookies.get('token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}

Route Handlers:

app/api/profile/route.ts
import { cookies, headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET() {
const cookieStore = await cookies(); // await!
const headersList = await headers(); // await!
const session = cookieStore.get('session')?.value;
const contentType = headersList.get('content-type');
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ session, contentType });
}

Server Actions:

app/actions.ts
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function login(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const user = await authenticate(email, password);
if (user) {
const cookieStore = await cookies(); // await!
cookieStore.set('session', user.token, {
httpOnly: true,
secure: true,
maxAge: 60 * 60 * 24 * 7, // 7 дней
});
redirect('/dashboard');
}
}

Не хочешь менять вручную? Next.js предлагает автоматический codemod!

Окно терминала
npx @next/codemod@canary upgrade latest
# Только конкретный codemod для async API
npx @next/codemod@canary next-async-request-api .
# Проверить что изменится (dry-run)
npx @next/codemod@canary next-async-request-api . --dry
# С подробным выводом
npx @next/codemod@canary next-async-request-api . --verbose

Что делает codemod:

// До запуска codemod
export default function Page({ params }: { params: { id: string } }) {
const id = params.id;
return <div>{id}</div>;
}
// После запуска codemod (автоматически!)
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <div>{id}</div>;
}

💡 Совет: Codemod хорошо справляется с очевидными случаями, но может не распознать сложные паттерны. После запуска проверь изменения вручную!


Next.js 15 полностью поддерживает React 19. Давай посмотрим на самые полезные новинки.

useActionState (бывший useFormState):

// Улучшенная обработка состояния формы
'use client';
import { useActionState } from 'react'; // из react, не из react-dom!
import { createPost } from '@/app/actions';
type FormState = {
success: boolean;
error?: string;
post?: { id: string; title: string };
};
const initialState: FormState = { success: false };
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
initialState
);
return (
<form action={formAction}>
{state.error && (
<div className="error">{state.error}</div>
)}
{state.success && (
<div className="success">Пост создан: {state.post?.title}</div>
)}
<input name="title" placeholder="Заголовок" disabled={isPending} />
<textarea name="content" placeholder="Текст" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '⏳ Создаём...' : '✨ Создать пост'}
</button>
</form>
);
}

useFormStatus:

// Компонент знает о состоянии родительской формы
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
style={{ opacity: pending ? 0.6 : 1 }}
>
{pending ? '⏳ Отправляем...' : '🚀 Отправить'}
</button>
);
}
// Используется внутри <form>
export function MyForm() {
return (
<form action={someServerAction}>
<input name="email" type="email" />
<SubmitButton /> {/* автоматически получает pending состояние */}
</form>
);
}

use() хук для Promise:

// use() — разворачивает Promise или Context в рендере
'use client';
import { use, Suspense } from 'react';
// Можно передать Promise как проп из Server Component!
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // разворачивает Promise синхронно!
return <div>{user.name}</div>;
}
// Server Component передаёт Promise клиенту
export default function Page() {
const userPromise = getUser(); // не await!
return (
<Suspense fallback={<div>Загрузка...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}

Turbopack — это новый бандлер, написанный на Rust. В Next.js 15 он официально стабилен для next dev!

Окно терминала
# Next.js 15: стабильный Turbopack
npx next dev --turbo
# Или в package.json:
# "dev": "next dev --turbo"

Сравнение производительности (реальные проекты):

МетрикаWebpackTurbopackУлучшение
Cold start (большой проект)~8-15s~1-3s5x быстрее
HMR update~500ms-2s~50-200ms7x быстрее
Initial compilation~5-12s~1-2.5s5x быстрее
Использование памяти~1-2 GB~400-600 MB3x меньше

Как это работает:

Webpack (JavaScript):
Изменение файла → пересборка зависимостей → обновление
Время: медленно, линейно растёт с проектом
Turbopack (Rust, инкрементальный):
Изменение файла → только затронутые модули
Время: стабильно быстро независимо от размера проекта

⚠️ Важно: Turbopack стабилен только для next dev. Для next build всё ещё используется webpack. Поддержка next build --turbo — в планах!

Несовместимые плагины webpack:

// next.config.ts — некоторые webpack плагины не работают с Turbopack
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Turbopack не поддерживает webpack плагины напрямую
// Используй turbopack-специфичные алиасы если нужно:
turbopack: {
resolveAlias: {
'@/utils': './src/utils',
},
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
};
export default nextConfig;

🔀 Partial Prerendering (PPR): статическая оболочка + динамические дыры

Заголовок раздела «🔀 Partial Prerendering (PPR): статическая оболочка + динамические дыры»

PPR — это одна из самых инновационных концепций в Next.js. Представь страницу как Swiss cheese 🧀: большая часть статическая (предсгенерированная), а “дырки” — это динамические части, которые стримятся при запросе.

Проблема без PPR:

Статическая страница (fast): /about, /blog — весь HTML предсгенерирован
Динамическая страница (slow): /dashboard — нужно ждать ответа сервера
Что если нам нужно и то, и другое на одной странице?
Product Page: заголовок статический + корзина динамическая → всё медленно!

PPR решает это:

// next.config.ts — включаем PPR экспериментально
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // включаем постепенно (per-route)
},
};
export default nextConfig;
// app/products/[id]/page.tsx — PPR в действии
import { Suspense } from 'react';
import { unstable_noStore as noStore } from 'next/cache';
// Оптимистично включаем PPR для этого роута
export const experimental_ppr = true;
// Эта функция статическая — рендерится при сборке
async function ProductInfo({ id }: { id: string }) {
const product = await fetch('https://api.example.com/products/' + id, {
next: { revalidate: 3600 }, // статические данные
}).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p className="price">{product.price} ₽</p>
</div>
);
}
// Эта функция динамическая — выполняется при каждом запросе
async function PersonalizedRecommendations() {
noStore(); // явно помечаем как динамическую
const recommendations = await getPersonalizedRecs(); // использует cookies
return <div>{recommendations.map(r => <ProductCard key={r.id} {...r} />)}</div>;
}
// Основная страница — статическая оболочка + динамические "дыры"
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div>
{/* Статическая часть — рендерится при сборке */}
<ProductInfo id={id} />
{/* Динамическая "дыра" — стримится при запросе */}
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
</div>
);
}

Визуально PPR работает так:

При сборке (build time):
┌─────────────────────────────────┐
│ <ProductInfo> │ ← Статический HTML
│ Название: iPhone 15 Pro │
│ Цена: 99 990 ₽ │
│ </ProductInfo> │
│ <Suspense fallback={skeleton}> │ ← "Дыра" в HTML
│ ░░░░░░░░░░░░░░░░░░░░░░░░ │
│ </Suspense> │
└─────────────────────────────────┘
При запросе пользователя (request time):
1. Статический HTML приходит МГНОВЕННО
2. "Дыра" заполняется динамическим контентом через стриминг

Это второе важное breaking change. В Next.js 14 fetch кэшировался по умолчанию. В Next.js 15 — нет!

Next.js 14 (кэш по умолчанию):

// Next.js 14: АВТОМАТИЧЕСКОЕ кэширование!
export default async function Page() {
// Этот запрос кэшируется навсегда (cache: 'force-cache' по умолчанию)
const data = await fetch('https://api.example.com/data');
// Чтобы НЕ кэшировать — нужно явно указать
const fresh = await fetch('https://api.example.com/fresh', {
cache: 'no-store', // отключаем кэш
});
return <div>{/* ... */}</div>;
}

Next.js 15 (нет кэша по умолчанию):

// Next.js 15: fetch НЕ кэшируется по умолчанию!
export default async function Page() {
// Этот запрос ДИНАМИЧЕСКИЙ — выполняется при каждом запросе
const data = await fetch('https://api.example.com/data');
// Для кэширования теперь нужно явно указать:
const cached = await fetch('https://api.example.com/cached', {
cache: 'force-cache', // или next: { revalidate: 3600 }
});
// ISR — обновлять каждый час
const isr = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 },
});
return <div>{/* ... */}</div>;
}

Таблица изменений кэша:

ПоведениеNext.js 14Next.js 15
fetch() без опцийforce-cache (кэш)no-store (без кэша)
GET Route HandlersКэшируетсяНЕ кэшируется
Client Router CacheКэш на 30 секНет кэша по умолчанию
cache: 'force-cache'РаботаетРаботает
next: { revalidate }РаботаетРаботает

Как адаптировать существующий код:

// Если раньше полагался на автокэш — нужно добавить явно:
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // ISR: обновлять каждый час
});
// Или настроить кэш на уровне роута:
export const revalidate = 3600; // все fetch в этом файле кэшируются 1 час
export const dynamic = 'force-static'; // весь роут статический
// Или через fetchCache:
export const fetchCache = 'force-cache'; // старое поведение Next.js 14

Часто нужно выполнить что-то после того, как пользователь уже получил ответ: логирование, аналитика, очистка кэша. unstable_after() позволяет это!

Без unstable_after — пользователь ждёт всего:

// ❌ Аналитика блокирует ответ пользователю!
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
// Пользователь ждёт, пока мы пишем в базу аналитики...
await logPageView({ page: '/products/' + id, productId: id });
await updatePopularProducts(id);
return <ProductPage product={product} />;
}

С unstable_after — пользователь получает ответ сразу:

// ✅ Аналитика выполняется ПОСЛЕ ответа!
import { unstable_after as after } from 'next/server';
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
// Регистрируем задачу — она выполнится после response
after(async () => {
// Пользователь уже видит страницу, пока это выполняется
await logPageView({ page: '/products/' + id, productId: id });
await updatePopularProducts(id);
await sendAnalyticsEvent('product_view', { id });
});
// Ответ отправляется немедленно!
return <ProductPage product={product} />;
}

after() также работает в Route Handlers и Server Actions:

app/api/orders/route.ts
import { unstable_after as after } from 'next/server';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const order = await createOrder(await request.json());
after(async () => {
// Email, уведомления, обновление статистики — после response
await sendOrderConfirmationEmail(order);
await updateInventory(order.items);
await notifyWarehouse(order);
});
// Клиент получает ответ сразу
return NextResponse.json(order, { status: 201 });
}

⚠️ Ограничение: after() не работает в static rendering. Только для динамических страниц и роутов.


Файл instrumentation.ts позволяет подключиться к жизненному циклу Next.js сервера. В Next.js 15 он стал более стабильным.

// instrumentation.ts (в корне проекта)
export async function register() {
// Выполняется один раз при старте сервера
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Node.js runtime — для тяжёлой инициализации
const { NodeSDK } = await import('@opentelemetry/sdk-node');
const sdk = new NodeSDK({
// конфигурация OpenTelemetry
});
sdk.start();
}
if (process.env.NEXT_RUNTIME === 'edge') {
// Edge runtime — лёгкая инициализация
await import('./lib/edge-monitoring');
}
}
// Новое в Next.js 15: onRequestError
export async function onRequestError(
error: Error,
request: { path: string; method: string },
context: { routerKind: string; routePath: string }
) {
// Вызывается при каждой ошибке запроса
await reportErrorToMonitoring({
error: error.message,
stack: error.stack,
path: request.path,
method: request.method,
});
}

next/form — это умный компонент, который расширяет HTML <form> для Next.js. Он автоматически обрабатывает prefetch и soft navigation!

Обычная HTML форма vs next/form:

// ❌ Обычная HTML форма — full page reload при отправке
export default function SearchPage() {
return (
<form action="/search">
<input name="q" placeholder="Поиск..." />
<button type="submit">Найти</button>
{/* При отправке → полная перезагрузка страницы */}
</form>
);
}
// ✅ next/form — soft navigation + prefetch!
import Form from 'next/form';
export default function SearchPage() {
return (
<Form action="/search">
{/*
- При hover/focus на input → prefetch layout страницы /search
- При submit → soft navigation (без перезагрузки)
- Параметры формы → URL search params (?q=запрос)
- Работает с Server Actions тоже!
*/}
<input name="q" placeholder="Поиск..." />
<button type="submit">Найти</button>
</Form>
);
}

next/form с Server Actions:

app/search/page.tsx
import Form from 'next/form';
import { searchProducts } from '@/app/actions';
export default function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
return (
<div>
<Form action={searchProducts}>
<input
name="q"
placeholder="Найти продукты..."
autoComplete="off"
/>
<select name="category">
<option value="">Все категории</option>
<option value="electronics">Электроника</option>
<option value="clothing">Одежда</option>
</select>
<button type="submit">🔍 Поиск</button>
</Form>
</div>
);
}

Next.js 15 добавил нативную поддержку TypeScript для конфигурационного файла!

До: next.config.mjs (JavaScript):

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true,
},
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'example.com' },
],
},
};
export default nextConfig;

После: next.config.ts (TypeScript):

// next.config.ts — нативный TypeScript!
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
// TypeScript подскажет доступные поля с автодополнением!
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
port: '',
pathname: '/images/**',
},
],
},
turbopack: {
resolveAlias: {
'@/utils': './src/utils/index.ts',
},
},
// TypeScript проверяет типы — нельзя написать неверные опции!
// Ошибка компиляции сразу укажет на проблему
};
export default nextConfig;

💡 Бонус: Теперь в next.config.ts можно импортировать типы и утилиты из TypeScript-проекта. Никаких проблем с require vs import!


Пошаговое руководство:

Шаг 1: Обновить зависимости:

Окно терминала
# Автоматическое обновление через codemod
npx @next/codemod@canary upgrade latest
# Или вручную
npm install next@15 react@19 react-dom@19
npm install --save-dev @types/react@19 @types/react-dom@19

Шаг 2: Запустить автомиграцию:

Окно терминала
# Мигрирует async APIs автоматически
npx @next/codemod@canary next-async-request-api .
# Мигрирует устаревший useFormState → useActionState
npx @next/codemod@canary next-form-request-api .

Шаг 3: Проверить кэширование:

// Проверь все fetch запросы — теперь они динамические!
// Добавь явное кэширование там, где нужно:
// ISR — каждый час
const data = await fetch(url, { next: { revalidate: 3600 } });
// Статический — навсегда (до следующего деплоя)
const data = await fetch(url, { cache: 'force-cache' });
// Динамический — при каждом запросе (поведение по умолчанию в Next.js 15)
const data = await fetch(url); // или { cache: 'no-store' }

Шаг 4: Переименовать конфиг (опционально):

Окно терминала
# next.config.mjs → next.config.ts
mv next.config.mjs next.config.ts
# Добавить типы вручную или через codemod

Шаг 5: Протестировать:

Окно терминала
# Запустить dev с Turbopack
npm run dev -- --turbo
# Проверить build
npm run build
# Запустить тесты
npm test