Пошук уроків, статей та іншого контенту
Побудуєте стратегію кешування для потокового рендерингу, персоналізованих даних і частково динамічних сторінок.
У складних застосунках сторінка рідко буває повністю статичною або повністю динамічною. Наприклад, сторінка товару може містити:
опис і зображення товару, які змінюються нечасто;
список рекомендацій, спільний для всіх користувачів;
ім’я поточного користувача;
персональну ціну або доступність товару;
довгі блоки, які не повинні блокувати початкову відповідь.
У цьому уроці побудуємо стратегію, у якій:
спільні дані кешуються;
персональні дані не потрапляють у спільний кеш;
сторінка віддає готові частини поступово через streaming;
повільні блоки ізолюються за допомогою Suspense.
Приклади використовують App Router у Next.js.
Під час роботи з кешуванням важливо розрізняти кілька рівнів.
Data Cache зберігає результати отримання даних на сервері. Найчастіше він використовується з fetch:
fetch(url, {
next: {
revalidate: 3600,
},
});Цей запит може бути повторно використаний для різних відвідувачів, якщо він не містить персональних даних.
Також можна призначати тег:
fetch(url, {
next: {
revalidate: 3600,
tags: ['products'],
},
});Тег дає змогу пізніше інвалідувати пов’язані записи.
Це кеш результату серверного рендерингу маршруту. Якщо маршрут є статичним, Next.js може зберігати його HTML і RSC-дані.
Якщо під час рендерингу маршруту викликається cookies() або headers(), маршрут стає динамічним. Його результат не можна безпечно зберігати як спільну сторінку, оскільки відповідь може залежати від конкретного користувача.
Це кеш на стороні браузера для переходів між маршрутами в межах застосунку. Він не замінює серверне кешування і не повинен використовуватися для захисту персональних даних.
Для кожного фрагмента сторінки потрібно окремо визначити його властивості:
| Фрагмент | Характер даних | Стратегія | |---|---|---| | Інформація про товар | Спільна для всіх | fetch з revalidate | | Рекомендації | Спільні або майже спільні | Кеш із коротшим TTL | | Поточний користувач | Персональні дані | Динамічне отримання | | Персональна ціна | Персональні дані | Не кешувати спільно | | Повільна статистика | Може бути спільною або персональною | Окремий Suspense-блок |
Важливо: Suspense керує моментом відправлення HTML, але сам по собі не кешує дані. Кешування потрібно налаштувати окремо.
Розглянемо сторінку товару:
app/
└── products/
└── [slug]/
├── page.tsx
├── ProductDetails.tsx
├── Recommendations.tsx
├── UserPanel.tsx
└── loading.tsxНа сторінці будуть такі частини:
ProductDetails — кешований опис товару;
Recommendations — кешований список рекомендацій;
UserPanel — персональна панель;
Suspense — потокова передача незалежних частин.
Почнемо з функції отримання товару.
// app/products/[slug]/ProductDetails.tsx
type Product = {
id: string;
slug: string;
name: string;
description: string;
price: number;
};
async function getProduct(slug: string): Promise<Product> {
const response = await fetch(
`https://api.example.com/products/${encodeURIComponent(slug)}`,
{
next: {
revalidate: 3600,
tags: [`product:${slug}`],
},
},
);
if (!response.ok) {
throw new Error('Не вдалося отримати товар');
}
return response.json();
}
export default async function ProductDetails({
slug,
}: {
slug: string;
}) {
const product = await getProduct(slug);
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
<strong>{product.price} грн</strong>
</section>
);
}У цьому прикладі:
результат запиту може зберігатися протягом однієї години;
дані одного товару мають окремий тег;
відповідь не залежить від cookies або заголовків конкретного користувача;
ці дані можна безпечно використовувати для всіх відвідувачів.
revalidate не означає «зробити маршрут статичним»revalidate налаштовує кеш конкретного запиту. Це не гарантує, що весь маршрут буде статичним.
Якщо інша частина сторінки викликає cookies(), маршрут усе одно має динамічний результат. Проте кешований запит товару не обов’язково потрібно виконувати заново для кожного користувача.
Це і є важлива відмінність:
маршрут може бути динамічним;
окремі джерела даних усередині маршруту можуть залишатися кешованими.
Персональні дані не можна зберігати у спільному кеші.
Наприклад, ідентифікатор користувача можна отримати з cookie:
// app/products/[slug]/UserPanel.tsx
import { cookies } from 'next/headers';
type User = {
id: string;
name: string;
};
async function getCurrentUser(): Promise<User | null> {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session')?.value;
if (!sessionToken) {
return null;
}
const response = await fetch('https://api.example.com/me', {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
cache: 'no-store',
});
if (!response.ok) {
return null;
}
return response.json();
}
export default async function UserPanel() {
const user = await getCurrentUser();
if (!user) {
return (
<aside>
<p>Увійдіть, щоб побачити персональні пропозиції.</p>
</aside>
);
}
return (
<aside>
<p>Вітаємо, {user.name}!</p>
</aside>
);
}Тут є дві важливі деталі:
cookies() робить читання залежним від поточного запиту;
cache: 'no-store' забороняє зберігати відповідь профілю в кеші fetch.
Не можна перетворювати такий запит на спільний кешований запит:
// Небезпечна стратегія для персональних даних
fetch('https://api.example.com/me', {
next: {
revalidate: 3600,
},
});Якщо відповідь залежить від cookie або токена, її кешування без правильної ізоляції може призвести до витоку даних одного користувача іншому.
Не всі джерела даних використовують fetch. Для запитів до бази даних можна застосувати unstable_cache.
import { unstable_cache } from 'next/cache';
async function loadProductFromDatabase(slug: string) {
// Тут міг би бути запит до бази даних.
return {
slug,
name: 'Механічна клавіатура',
};
}
const getCachedProduct = unstable_cache(
async (slug: string) => loadProductFromDatabase(slug),
['product'],
{
revalidate: 3600,
},
);
export async function getProduct(slug: string) {
return getCachedProduct(slug);
}Аргументи функції враховуються під час формування ключа кешу. Тому результат для різних slug не повинен змішуватися.
Персональний контекст не слід читати всередині функції, яку ви кешуєте:
// Неправильна ідея
const getCachedAccount = unstable_cache(
async () => {
// Читання cookie або поточного користувача всередині спільного кешу
return getCurrentAccount();
},
['account'],
);У такому разі перший збережений результат може бути повернений іншим користувачам.
Безпечніше розділити операції:
отримати ідентифікатор користувача з поточного запиту;
передати цей ідентифікатор у кешовану функцію;
переконатися, що ключ кешу містить ідентифікатор користувача.
Але для персональних даних зазвичай простіше та безпечніше використовувати no-store, якщо немає чіткої потреби в окремому кеші для кожного користувача.
SuspenseСерверний компонент може бути асинхронним. Якщо він повільний, не обов’язково чекати на нього перед відправленням усієї сторінки.
import { Suspense } from 'react';
import ProductDetails from './ProductDetails';
import Recommendations from './Recommendations';
import UserPanel from './UserPanel';
function RecommendationsFallback() {
return <p aria-live="polite">Завантажуємо рекомендації…</p>;
}
function UserPanelFallback() {
return <p aria-live="polite">Завантажуємо ваш профіль…</p>;
}
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<main>
<ProductDetails slug={slug} />
<Suspense fallback={<RecommendationsFallback />}>
<Recommendations slug={slug} />
</Suspense>
<Suspense fallback={<UserPanelFallback />}>
<UserPanel />
</Suspense>
</main>
);
}Порядок роботи може бути таким:
Next.js починає рендерити сторінку;
готовий HTML для основної частини надсилається клієнту;
замість повільних блоків спочатку надсилаються fallback-компоненти;
після завершення серверних компонентів Next.js доповнює сторінку їхнім результатом.
Це не означає, що JavaScript у браузері самостійно виконує серверний запит. Сервер продовжує рендеринг і передає результат потоково.
Нижче наведено спрощений, але цілісний приклад маршруту.
app/products/[slug]/page.tsximport { Suspense } from 'react';
import ProductDetails from './ProductDetails';
import Recommendations from './Recommendations';
import UserPanel from './UserPanel';
export const dynamic = 'force-dynamic';
function RecommendationsFallback() {
return <p>Завантажуємо рекомендації…</p>;
}
function UserPanelFallback() {
return <p>Перевіряємо сесію…</p>;
}
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<main>
<ProductDetails slug={slug} />
<Suspense fallback={<RecommendationsFallback />}>
<Recommendations slug={slug} />
</Suspense>
<Suspense fallback={<UserPanelFallback />}>
<UserPanel />
</Suspense>
</main>
);
}dynamic = 'force-dynamic' явно документує намір: результат маршруту не повинен зберігатися як загальна статична сторінка.
Це особливо корисно, коли маршрут має персональні компоненти. Навіть якщо Next.js може визначити динамічність автоматично, явна конфігурація робить стратегію зрозумілішою для команди.
app/products/[slug]/ProductDetails.tsxtype Product = {
name: string;
description: string;
price: number;
};
async function getProduct(slug: string): Promise<Product> {
const response = await fetch(
`https://api.example.com/products/${encodeURIComponent(slug)}`,
{
next: {
revalidate: 3600,
tags: [`product:${slug}`],
},
},
);
if (!response.ok) {
throw new Error('Не вдалося отримати інформацію про товар');
}
return response.json();
}
export default async function ProductDetails({
slug,
}: {
slug: string;
}) {
const product = await getProduct(slug);
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>{product.price} грн</p>
</section>
);
}app/products/[slug]/Recommendations.tsxtype Recommendation = {
id: string;
name: string;
};
async function getRecommendations(
slug: string,
): Promise<Recommendation[]> {
const response = await fetch(
`https://api.example.com/products/${encodeURIComponent(slug)}/recommendations`,
{
next: {
revalidate: 300,
tags: [`recommendations:${slug}`],
},
},
);
if (!response.ok) {
throw new Error('Не вдалося отримати рекомендації');
}
return response.json();
}
export default async function Recommendations({
slug,
}: {
slug: string;
}) {
const recommendations = await getRecommendations(slug);
return (
<section>
<h2>Вам також може сподобатися</h2>
<ul>
{recommendations.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</section>
);
}app/products/[slug]/UserPanel.tsximport { cookies } from 'next/headers';
type User = {
name: string;
};
async function getCurrentUser(): Promise<User | null> {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) {
return null;
}
const response = await fetch('https://api.example.com/me', {
headers: {
Authorization: `Bearer ${session}`,
},
cache: 'no-store',
});
if (!response.ok) {
return null;
}
return response.json();
}
export default async function UserPanel() {
const user = await getCurrentUser();
return (
<aside>
{user ? (
<p>Вітаємо, {user.name}!</p>
) : (
<p>Увійдіть, щоб отримати персональні пропозиції.</p>
)}
</aside>
);
}У результаті:
ProductDetails використовує кеш на одну годину;
Recommendations використовує кеш на п’ять хвилин;
UserPanel виконується для поточного запиту;
сторінка може передавати результати поступово;
персональна відповідь не потрапляє до спільного кешу.
Уявімо API, яке повертає товар разом із персональною ціною:
{
"name": "Механічна клавіатура",
"publicPrice": 4000,
"personalPrice": 3500
}Такий запит уже не можна кешувати як загальний для всіх користувачів. Якщо результат кешується без урахування користувача, персональна ціна може стати доступною не тому користувачеві.
Кращий дизайн — розділити дані:
окремий кешований запит для публічної інформації про товар;
окремий динамічний запит для персональної ціни.
async function getPublicProduct(slug: string) {
const response = await fetch(
`https://api.example.com/products/${encodeURIComponent(slug)}`,
{
next: {
revalidate: 3600,
},
},
);
if (!response.ok) {
throw new Error('Не вдалося отримати товар');
}
return response.json();
}
async function getPersonalPrice(slug: string, session: string) {
const response = await fetch(
`https://api.example.com/products/${encodeURIComponent(slug)}/price`,
{
headers: {
Authorization: `Bearer ${session}`,
},
cache: 'no-store',
},
);
if (!response.ok) {
throw new Error('Не вдалося отримати персональну ціну');
}
return response.json();
}Так межа між кешованими та персональними даними залишається очевидною.
Якщо товар оновлено в адміністративній панелі, чекати завершення TTL не завжди потрібно. Кеш можна інвалідувати за тегом.
// app/api/revalidate-product/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const body = await request.json();
if (typeof body.slug !== 'string') {
return Response.json(
{ error: 'Некоректний slug' },
{ status: 400 },
);
}
revalidateTag(`product:${body.slug}`);
return Response.json({ revalidated: true });
}Після цього наступний запит до товару отримає актуальні дані.
Маршрут інвалідації потрібно захищати автентифікацією або секретним токеном. Не можна залишати публічний endpoint, який дозволяє будь-кому очищати кеш.
loading.tsx і SuspenseФайл loading.tsx зручний для загального стану завантаження маршруту:
// app/products/[slug]/loading.tsx
export default function Loading() {
return <p>Завантажуємо сторінку товару…</p>;
}Він не замінює локальні межі Suspense.
Використовуйте:
loading.tsx, коли весь маршрут має спільний стан завантаження;
Suspense, коли різні блоки повинні з’являтися незалежно один від одного.
Наприклад, якщо рекомендації повільні, не потрібно приховувати вже готову інформацію про товар. Локальний Suspense залишає основний контент доступним.
TTL має відповідати характеру даних:
каталог товарів: десятки хвилин або години;
рекомендації: кілька хвилин;
новини чи статуси: короткий інтервал;
профіль користувача: no-store або окремий контрольований кеш;
дані, які повинні бути актуальними після кожної зміни: інвалідація за тегом.
Надто довгий TTL показує застарілі дані. Надто короткий TTL збільшує навантаження на API і базу даних.
// Неправильно: результат залежить від сесії
fetch('https://api.example.com/me', {
next: {
revalidate: 600,
},
});Для персональної відповіді використовуйте cache: 'no-store' або окремий кеш із правильно ізольованим ключем.
Suspense механізмом кешуванняSuspense лише визначає fallback і межі потокового рендерингу. Він не зменшує кількість запитів сам по собі.
Якщо сторінка читає cookies або headers, не намагайтеся зберігати її готовий HTML як спільну відповідь. Кешуйте безпечні джерела даних усередині сторінки.
Токени та інші секрети не повинні випадково потрапляти в ключі або логи кешування. Для персональних запитів найпростіша безпечна стратегія — no-store.
Наявність одного персонального блоку не означає, що всі запити повинні виконуватися без кешу. Публічні дані можна залишити кешованими незалежно від динамічності маршруту.
Fallback — це тимчасовий інтерфейс. Він не повинен містити дані, які можуть бути помилково сприйняті як остаточні, наприклад персональну ціну або статус замовлення.
Перед розгортанням перевірте кожен блок сторінки:
Чи є дані однаковими для всіх користувачів?
Чи залежить запит від cookie, сесії або заголовка авторизації?
Який допустимий час застарівання даних?
Чи потрібна потокова передача цього блоку?
Чи є для повільного блоку власний fallback?
Як кеш буде інвалідовано після зміни даних?
Чи може кешований результат містити секрети або персональну інформацію?
Корисно також перевірити сторінку під різними обліковими записами та без сесії. Якщо один користувач бачить дані іншого, проблема найімовірніше пов’язана з неправильним рівнем кешування.
Динамічний маршрут не забороняє кешувати безпечні публічні запити всередині нього.
Публічні дані кешуйте через fetch з revalidate і тегами.
Персональні дані отримуйте динамічно та не зберігайте у спільному кеші.
cookies() і headers() роблять результат залежним від поточного запиту.
Suspense дає змогу потоково передавати незалежні частини сторінки.
loading.tsx підходить для загального стану маршруту, а локальний Suspense — для окремих блоків.
Не об’єднуйте публічні та персональні дані в один кешований запит.
Для актуалізації кешу використовуйте TTL або інвалідацію за тегами.
Кешування потрібно проєктувати окремо для кожного джерела даних, а не лише для всієї сторінки.