Next.js с Redis: кэширование и управление сессиями

16 октября 2026
Автор

Антон Ларичев

Зачем Redis в Next.js

Redis (Remote Dictionary Server) — in-memory хранилище данных, которое используется как кэш, база данных и брокер сообщений. В Next.js-приложениях Redis решает две ключевые задачи: ускорение работы за счёт кэширования результатов запросов и хранение пользовательских сессий в stateless-окружениях.

Когда Redis необходим

  • API-маршруты делают тяжёлые запросы к базе данных, одинаковые для разных пользователей
  • Нужно хранить сессии в serverless-среде, где нет общей памяти между вызовами
  • Требуется ограничение частоты запросов (rate limiting)
  • Несколько инстансов приложения должны разделять общее состояние

Курсы PurpleSchool

Учитесь у практикующих разработчиков — без воды

40 000+ студентов · рейтинг 4.8 · гарантия возврата 30 дней

Установка и подключение

Установка зависимостей

npm install ioredis

ioredis — наиболее функциональная библиотека для Node.js с поддержкой TypeScript из коробки, автоматическим переподключением и встроенным pipelining.

Создание клиента Redis

Создайте файл lib/redis.ts:

import Redis from 'ioredis';

const getRedisClient = () => {
  const client = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
    maxRetriesPerRequest: 3,
    lazyConnect: true,
  });

  client.on('error', (err) => {
    console.error('Redis error:', err);
  });

  return client;
};

declare global {
  var redis: Redis | undefined;
}

const redis = global.redis || getRedisClient();

if (process.env.NODE_ENV !== 'production') {
  global.redis = redis;
}

export default redis;

Паттерн с global.redis предотвращает создание нового соединения при каждом hot-reload в режиме разработки — точно так же, как это делается с Prisma или другими клиентами баз данных.

Переменные окружения

Добавьте в .env.local:

REDIS_URL=redis://localhost:6379

Для продакшена используйте redis://:password@host:port или rediss:// для TLS-соединения.

Кэширование в API-маршрутах

Базовый паттерн Cache-Aside

Cache-Aside (Lazy Loading) — самый распространённый паттерн: сначала проверяем кэш, при промахе идём в источник данных и сохраняем результат.

// app/api/products/route.ts
import { NextResponse } from 'next/server';
import redis from '@/lib/redis';
import { db } from '@/lib/db';

const CACHE_TTL = 60 * 5; // 5 минут

export async function GET() {
  const cacheKey = 'products:all';

  const cached = await redis.get(cacheKey);
  if (cached) {
    return NextResponse.json(JSON.parse(cached), {
      headers: { 'X-Cache': 'HIT' },
    });
  }

  const products = await db.product.findMany({
    where: { active: true },
    orderBy: { createdAt: 'desc' },
  });

  await redis.set(cacheKey, JSON.stringify(products), 'EX', CACHE_TTL);

  return NextResponse.json(products, {
    headers: { 'X-Cache': 'MISS' },
  });
}

Заголовок X-Cache помогает при отладке — сразу видно, откуда пришли данные.

Кэширование с параметрами

Для запросов с параметрами включайте их в ключ кэша:

// app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import redis from '@/lib/redis';
import { db } from '@/lib/db';

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const cacheKey = `product:${params.id}`;

  const cached = await redis.get(cacheKey);
  if (cached) {
    return NextResponse.json(JSON.parse(cached));
  }

  const product = await db.product.findUnique({
    where: { id: params.id },
    include: { category: true, images: true },
  });

  if (!product) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 });
  }

  await redis.set(cacheKey, JSON.stringify(product), 'EX', 600);

  return NextResponse.json(product);
}

Инвалидация кэша

При обновлении данных удаляйте соответствующие ключи:

export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await request.json();

  const product = await db.product.update({
    where: { id: params.id },
    data: body,
  });

  await redis.del(`product:${params.id}`, 'products:all');

  return NextResponse.json(product);
}

Для массовой инвалидации используйте паттерн с тегами — храните список ключей в отдельном множестве:

// lib/cache.ts
import redis from './redis';

export async function cacheWithTag(
  key: string,
  tag: string,
  value: unknown,
  ttl: number
) {
  const pipeline = redis.pipeline();
  pipeline.set(key, JSON.stringify(value), 'EX', ttl);
  pipeline.sadd(`tag:${tag}`, key);
  pipeline.expire(`tag:${tag}`, ttl);
  await pipeline.exec();
}

export async function invalidateTag(tag: string) {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    await redis.del(...keys, `tag:${tag}`);
  }
}
// Кэшируем с тегом
await cacheWithTag('products:all', 'products', products, 300);
await cacheWithTag('product:123', 'products', product, 300);

// При изменении любого продукта удаляем всё связанное
await invalidateTag('products');

Управление сессиями

Хранилище сессий на основе Redis

Вместо того чтобы держать данные сессии в зашифрованных cookie, храним их в Redis, а в cookie — только идентификатор.

Создайте lib/redis-session.ts:

import redis from './redis';
import { randomUUID } from 'crypto';
import { cookies } from 'next/headers';

const SESSION_TTL = 60 * 60 * 24 * 7; // 7 дней
const SESSION_COOKIE = 'session-id';

export interface SessionData {
  userId: string;
  role: string;
  email: string;
  createdAt: number;
}

export async function createSession(data: SessionData): Promise<string> {
  const sessionId = randomUUID();
  const key = `session:${sessionId}`;

  await redis.set(key, JSON.stringify(data), 'EX', SESSION_TTL);

  const cookieStore = await cookies();
  cookieStore.set(SESSION_COOKIE, sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: SESSION_TTL,
    path: '/',
  });

  return sessionId;
}

export async function getSession(): Promise<SessionData | null> {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get(SESSION_COOKIE)?.value;

  if (!sessionId) return null;

  const data = await redis.get(`session:${sessionId}`);
  if (!data) return null;

  // Продлеваем TTL при каждом обращении (скользящее окно)
  await redis.expire(`session:${sessionId}`, SESSION_TTL);

  return JSON.parse(data) as SessionData;
}

export async function destroySession(): Promise<void> {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get(SESSION_COOKIE)?.value;

  if (sessionId) {
    await redis.del(`session:${sessionId}`);
    cookieStore.delete(SESSION_COOKIE);
  }
}

API-маршруты аутентификации

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createSession } from '@/lib/redis-session';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';

export async function POST(request: NextRequest) {
  const { email, password } = await request.json();

  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    return NextResponse.json({ error: 'Неверные данные' }, { status: 401 });
  }

  const isValid = await bcrypt.compare(password, user.passwordHash);
  if (!isValid) {
    return NextResponse.json({ error: 'Неверные данные' }, { status: 401 });
  }

  await createSession({
    userId: user.id,
    role: user.role,
    email: user.email,
    createdAt: Date.now(),
  });

  return NextResponse.json({ success: true });
}
// app/api/auth/logout/route.ts
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/redis-session';

export async function POST() {
  await destroySession();
  return NextResponse.json({ success: true });
}

Middleware для защиты маршрутов

Middleware в Next.js работает в Edge Runtime, где ioredis недоступен. Для прямой работы с Redis используйте @upstash/redis — HTTP-клиент, совместимый с Edge:

npm install @upstash/redis
// middleware.ts
import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

export async function middleware(request: NextRequest) {
  const sessionId = request.cookies.get('session-id')?.value;

  if (!sessionId) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const session = await redis.get(`session:${sessionId}`);

  if (!session) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/profile/:path*'],
};

Upstash предоставляет бесплатный тариф и совместим как с Edge Runtime, так и с Node.js. В продакшене он удобен ещё и тем, что не требует отдельного сервера — подключение идёт через HTTPS REST API.

Rate Limiting

Redis идеально подходит для ограничения частоты запросов благодаря атомарным операциям:

// lib/rate-limit.ts
import redis from './redis';

interface RateLimitResult {
  success: boolean;
  remaining: number;
  resetAt: number;
}

export async function rateLimit(
  identifier: string,
  limit: number = 10,
  windowSeconds: number = 60
): Promise<RateLimitResult> {
  const key = `rate-limit:${identifier}`;
  const resetAt = Math.floor(Date.now() / 1000) + windowSeconds;

  const pipeline = redis.pipeline();
  pipeline.incr(key);
  pipeline.expire(key, windowSeconds);
  const results = await pipeline.exec();

  const count = (results?.[0]?.[1] as number) || 0;
  const remaining = Math.max(0, limit - count);

  return {
    success: count <= limit,
    remaining,
    resetAt,
  };
}
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';

export async function POST(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
  const { success, remaining, resetAt } = await rateLimit(ip, 5, 60);

  if (!success) {
    return NextResponse.json(
      { error: 'Слишком много запросов. Попробуйте позже.' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': resetAt.toString(),
          'Retry-After': '60',
        },
      }
    );
  }

  // Обработка запроса...
  return NextResponse.json({ success: true });
}

Запуск Redis локально

Для локальной разработки используйте Docker Compose:

# docker-compose.yml
services:
  redis:
    image: redis:alpine
    ports:
      - '6379:6379'
    volumes:
      - redis-data:/data
    command: redis-server --save 60 1 --loglevel warning

volumes:
  redis-data:
docker compose up -d redis

Лучшие практики

Структура ключей

Используйте иерархическую структуру с разделителем : — это улучшает читаемость и позволяет группировать ключи в Redis Insight:

session:{sessionId}       — данные сессии пользователя
product:{id}              — данные конкретного продукта
products:all              — список всех продуктов
rate-limit:{ip}           — счётчик запросов по IP
tag:{tagName}             — множество ключей для групповой инвалидации
user:{userId}:cart        — корзина конкретного пользователя

Защита от сбоев Redis

Redis не должен быть единственной точкой отказа. Оборачивайте операции кэша в try-catch, чтобы приложение продолжало работать при недоступности Redis:

// lib/cache.ts
import redis from './redis';

export async function getWithCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number
): Promise<T> {
  try {
    const cached = await redis.get(key);
    if (cached) return JSON.parse(cached) as T;
  } catch {
    console.warn(`Cache read failed for key: ${key}`);
  }

  const data = await fetcher();

  try {
    await redis.set(key, JSON.stringify(data), 'EX', ttl);
  } catch {
    console.warn(`Cache write failed for key: ${key}`);
  }

  return data;
}

Использование утилиты:

// app/api/products/route.ts
import { getWithCache } from '@/lib/cache';
import { db } from '@/lib/db';

export async function GET() {
  const products = await getWithCache(
    'products:all',
    () => db.product.findMany({ where: { active: true } }),
    300
  );

  return Response.json(products);
}

Валидация данных из кэша

Данные в Redis могут оказаться устаревшей схемы. Для критичных данных добавьте валидацию при десериализации:

import { z } from 'zod';

const ProductSchema = z.object({
  id: z.string(),
  name: z.string(),
  price: z.number(),
  active: z.boolean(),
});

async function getCachedProduct(id: string) {
  const raw = await redis.get(`product:${id}`);
  if (!raw) return null;

  const result = ProductSchema.safeParse(JSON.parse(raw));
  return result.success ? result.data : null;
}

Если валидация проваливается, функция возвращает null, и вызывающий код обратится к базе данных, как при обычном cache miss.

Выбор TTL

Правильный TTL — баланс между свежестью данных и нагрузкой на базу:

  • Статичные справочники (категории, настройки) — 1-24 часа
  • Списки сущностей — 1-5 минут
  • Данные конкретной сущности — 5-10 минут
  • Пользовательские сессии — 7-30 дней со скользящим окном
  • Rate limiting — длина окна (обычно 60 секунд)

Подробнее об архитектуре Next.js-приложений, включая работу с внешними сервисами и оптимизацию производительности, рассматривается на курсе Next.js на PurpleSchool.

Стрелочка влевоSEO-оптимизация в Next.jsPartial Prerendering (PPR) в Next.jsСтрелочка вправо

Все гайды по Next-js

useSearchParams и usePathname в Next.js App RouterStreaming и Suspense в Next.jsServer Actions в Next.jsrevalidateTag и revalidatePath — инвалидация кэша в Next.jsPrisma ORM в Next.jsnotFound и redirect в серверных компонентах Next.jsNext.js с Supabase — авторизация и база данныхNext.js с GraphQL и Apollo ClientNext.js 15: новые возможности и breaking changesБлог и документация на Next.js с MDXloading.js и error.js в Next.js App RouterАутентификация с JWT в App Router без сторонних библиотекИнтернационализация i18n в Next.js App RoutergenerateStaticParams — статическая генерация динамических маршрутовNext.js cookies и headers в App RouterNext.js с Clerk: аутентификация пользователейNext.js after() — выполнение кода после отправки ответа
Открыть базу знаний

Отправить комментарий