Рассмотрим пример кэширования страниц на сайте рецептов (подход Cache-First with Authorization Fallback).
Архитектура страницы рецепта создает классический конфликт интересов: между высокой производительностью (кэширование) и доступом к приватным данным (авторизация).
Большинство рецептов являются публичными и практически не меняются. Поэтому нет смысла обращаться к базе данных при каждом открытии страницы. В идеальном случае публичный рецепт можно было бы один раз получить с сервера, сохранить результат в кэше и затем долго отдавать его без повторных запросов к БД.
Но у некоторых рецептов есть статус «скрытый». Такой рецепт не должен быть доступен другим пользователям, однако его автор должен иметь возможность открыть его по тому же URL.
Получается противоречие:
- публичные рецепты хочется максимально кэшировать;
- приватные рецепты требуют проверки авторизации;
- при этом не хочется создавать разные URL для публичного и пользовательского рецепта.
В итоге я использовал комбинацию кэшируемого и динамического серверного запроса с помощью use cache и cacheLife() в Next.js.
Почему нельзя просто использовать SSG
Если бы все рецепты были публичными, задача решалась бы довольно просто: страницу можно было бы статически сгенерировать и отдавать готовый результат без обращения к БД на каждый запрос.
Но скрытый рецепт требует учитывать пользователя, который делает запрос.
Например, рецепт recipe/123 может выглядеть так:
- для обычного пользователя или гостя — рецепт недоступен;
- для автора рецепта — рецепт доступен;
Полностью статический вариант здесь уже не подходит: статическая страница не знает, кто именно её открывает.
Вариант с разными URL
Одно из очевидных решений — разделить публичные и пользовательские рецепты.
Например:
/recipes/123/my-recipes/123
Публичные рецепты можно было бы кэшировать отдельно, а пользовательские загружать только на клиенте или динамически на сервере.
Технически это решает проблему, но появляется другая.
Представим, что пользователь создал рецепт и хочет отправить его другу. Он просто копирует URL из браузера:
/my-recipes/123
Такая ссылка откроется только у автора.
Получается, что ради относительно редкого случая со скрытыми рецептами или черновиками мы усложняем основной сценарий — открытие и распространение обычных рецептов.
Поэтому я решил оставить один URL для обоих типов рецептов.
Кэшируем публичный рецепт
Идея состоит в том, чтобы сначала попробовать получить рецепт как публичный.
Этот запрос выполняется внутри функции с директивой use cache:
async function getPublicRecipeCached(id: string) {
'use cache'
cacheTag(RECIPE_CACHE_TAGS.byId(id))
cacheLife('max')
try {
return await recipesServicePublic.getFullRecipe(id)
} catch (error: unknown) {
if (isHttpError(error) && error.status === 403) return null
throw error
}
}
cacheLife('max') означает, что время жизни кэша без запросов к БД составляет 30 дней.
Если рецепт публичный, результат попадает в серверный кэш.
При следующих обращениях к тому же рецепту сервер может использовать закэшированный результат вместо нового запроса к backend и базе данных.
Для рецептов это особенно полезно, потому что содержимое обычно меняется значительно реже, чем открывается пользователями.
При этом кэш имеет тег:
cacheTag(RECIPE_CACHE_TAGS.byId(id))
Это позволяет явно инвалидировать конкретный рецепт после его изменения.
Если рецепт был отредактирован, необходимо не забыть инвалидировать соответствующий кэш. Иначе пользователи могут продолжить получать старую версию рецепта. Также без инвалидации могут оставаться доступными рецепты, которые получили статус скрытых.
Пример ревалидации:
import { updateTag } from 'next/cache'
// после обновления рецепта
updateTag(RECIPE_CACHE_TAGS.byId(recipe.id))
Что происходит со скрытым рецептом
Если публичный API возвращает 403 Forbidden, это ещё не означает, что рецепт окончательно недоступен.
Возможно, запрос выполняет его автор.
Поэтому после неудачной попытки получить публичную версию я проверяю наличие authentication cookie:
const cookieStore = await cookies()
const hasAuthToken = cookieStore.has(AUTH_COOKIE_NAME)
Если cookie нет, пользователь действительно не может получить скрытый рецепт. Если cookie есть, выполняется уже обычный авторизованный запрос:
return await recipesServiceServer.getFullRecipe(id)
В этот запрос передаётся пользовательская авторизация, поэтому backend может определить, является ли текущий пользователь автором скрытого рецепта.
Что происходит с просроченным access token
Здесь возникает ещё один нюанс.
Авторизованный запрос выполняется непосредственно на сервере. Если access token, находящийся в cookie, уже просрочен, backend вернёт 401 Unauthorized.
В обычном клиентском запросе middleware или axios interceptor мог бы выполнить refresh и повторить запрос.
Но здесь запрос выполняется во время серверного рендера. В этом месте нельзя просто установить новую cookie и продолжить текущий рендер так, как это происходит в обычном браузерном запросе.
Поэтому при 401 я перенаправляю пользователя на endpoint обновления токена:
if (isHttpError(error) && error.status === 401) {
redirect(`/api/auth/refresh?callback=${PUBLIC_URL.recipe(id)}`)
}
После обновления токена пользователь возвращается на исходную страницу.
Да, здесь появляется дополнительный redirect, поэтому первый запрос получается немного дольше. Но с точки зрения UX это лучше, чем показывать ошибку авторизации и заставлять пользователя вручную обновлять страницу.
Итоговая функция
В результате вся логика получения рецепта выглядит следующим образом:
'use server'
import { cacheLife, cacheTag } from 'next/cache'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { RECIPE_CACHE_TAGS, recipesServicePublic } from '@/entities/recipe'
import { recipesServiceServer } from '@/entities/recipe/server'
import { HttpError, isHttpError } from '@/shared/api'
import { AUTH_COOKIE_NAME } from '@/shared/config'
import { PUBLIC_URL } from '@/shared/routes'
export async function getRecipeCached(id: string) {
const publicRecipe = await getPublicRecipeCached(id)
if (publicRecipe) return publicRecipe
const cookieStore = await cookies()
const hasAuthToken = cookieStore.has(AUTH_COOKIE_NAME)
if (hasAuthToken) {
try {
return await recipesServiceServer.getFullRecipe(id)
} catch (error: unknown) {
if (isHttpError(error) && error.status === 401) {
redirect(`/api/auth/refresh?callback=${PUBLIC_URL.recipe(id)}`)
}
throw error
}
}
throw new HttpError(403, 'Forbidden')
}
async function getPublicRecipeCached(id: string) {
'use cache'
cacheTag(RECIPE_CACHE_TAGS.byId(id))
cacheLife('max')
try {
return await recipesServicePublic.getFullRecipe(id)
} catch (error: unknown) {
if (isHttpError(error) && error.status === 403) return null
throw error
}
}
Публичный рецепт. После попадания рецепта в кэш повторные обращения не требуют постоянного запроса к backend и базе данных.
Скрытый рецепт автора. Скрытый рецепт не попадает в общий публичный кэш, поскольку его результат зависит от конкретного пользователя.
Получается компромисс между производительностью и удобством использования:
- публичные рецепты максимально долго обслуживаются из кэша;
- скрытые рецепты остаются доступны своим авторам;
- публичные и приватные рецепты используют один URL;
- ссылкой на рецепт можно делиться независимо от его текущего статуса;
- после редактирования рецепт можно адресно удалить из кэша по тегу;
- просроченный access token автоматически обновляется через отдельный endpoint.
Главный принцип здесь заключается не в том, чтобы выбрать между SSG, SSR и CSR для всей страницы. Вместо этого разные данные на одной странице могут иметь разную стратегию получения: публичные данные кэшируются, а персональные данные запрашиваются динамически только тогда, когда это действительно необходимо.
Результаты
Предложенный паттерн Cache-First with Authorization Fallback позволяет найти золотую середину между производительностью и функциональностью. Он не требует отказа от единого URL и даёт прозрачное управление кэшем.
| Показатель | До внедрения | После внедрения |
|---|---|---|
| Запросов к БД на один публичный рецепт | 1 (при каждом просмотре) | 1 раз в месяц (кеш) или после инвалидации |
| Запросов к БД на один приватный рецепт | 1 (для автора) | 1 (без изменений) |
| Единый URL для всех рецептов | ❌ (приходилось дублировать) | ✅ |
| UX при просрочке токена | ошибка, требовался F5 |
автоматический редирект |
Итого: мы сократили количество запросов к БД для публичного контента более чем на 99,9 %, при этом сохранили идеальный пользовательский сценарий для приватных страниц.