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

27. Паттерны проектирования

🎯 Next.js Design Patterns — рецепты для сложных задач

Заголовок раздела «🎯 Next.js Design Patterns — рецепты для сложных задач»

Паттерны — это проверенные решения для повторяющихся проблем. Кто-то уже столкнулся с задачей «как грамотно абстрагировать данные» или «как делать A/B тесты без деплоя» — и придумал элегантное решение. Мы просто берём эти решения и применяем! 🧩


Аналогия: строители не изобретают колесо каждый раз. Есть шаблоны конструкций — свод, арка, балка. Так же и в программировании: есть проверенные архитектурные решения.

Без паттернов:

  • Бизнес-логика смешана с UI
  • Сложно тестировать
  • Изменение одного ломает другое
  • Код понятен только автору

С паттернами:

  • Чистое разделение ответственности
  • Легко тестировать каждый слой отдельно
  • Новый разработчик понимает структуру за 5 минут
  • Рефакторинг не страшен

Паттерн Repository — абстракция над источником данных. Компоненты не знают, откуда приходят данные (база данных, API, файл, кэш) — они просто вызывают методы репозитория.

lib/repositories/post.repository.ts
export interface Post {
id: string;
title: string;
slug: string;
content: string;
publishedAt: Date;
author: { name: string; avatar: string };
}
// Интерфейс репозитория — контракт
export interface IPostRepository {
findAll(): Promise<Post[]>;
findBySlug(slug: string): Promise<Post | null>;
findByAuthor(authorId: string): Promise<Post[]>;
create(data: Omit<Post, 'id'>): Promise<Post>;
update(id: string, data: Partial<Post>): Promise<Post>;
delete(id: string): Promise<void>;
}
// lib/repositories/post.prisma.repository.ts — реализация через Prisma
import { prisma } from '@/lib/prisma';
import type { IPostRepository, Post } from './post.repository';
export class PostPrismaRepository implements IPostRepository {
async findAll(): Promise<Post[]> {
return prisma.post.findMany({
include: { author: { select: { name: true, avatar: true } } },
orderBy: { publishedAt: 'desc' },
});
}
async findBySlug(slug: string): Promise<Post | null> {
return prisma.post.findUnique({
where: { slug },
include: { author: { select: { name: true, avatar: true } } },
});
}
async findByAuthor(authorId: string): Promise<Post[]> {
return prisma.post.findMany({
where: { authorId },
include: { author: { select: { name: true, avatar: true } } },
});
}
async create(data: Omit<Post, 'id'>): Promise<Post> {
return prisma.post.create({ data });
}
async update(id: string, data: Partial<Post>): Promise<Post> {
return prisma.post.update({ where: { id }, data });
}
async delete(id: string): Promise<void> {
await prisma.post.delete({ where: { id } });
}
}
// lib/repositories/post.mock.repository.ts — для тестов
import type { IPostRepository, Post } from './post.repository';
export class PostMockRepository implements IPostRepository {
private posts: Post[] = [];
async findAll() { return this.posts; }
async findBySlug(slug: string) { return this.posts.find(p => p.slug === slug) ?? null; }
async findByAuthor(authorId: string) { return this.posts.filter(p => p.author.name === authorId); }
async create(data: Omit<Post, 'id'>) {
const post = { ...data, id: Math.random().toString(36).slice(2) };
this.posts.push(post);
return post;
}
async update(id: string, data: Partial<Post>) {
const i = this.posts.findIndex(p => p.id === id);
if (i === -1) throw new Error('Not found');
this.posts[i] = { ...this.posts[i]!, ...data };
return this.posts[i]!;
}
async delete(id: string) {
this.posts = this.posts.filter(p => p.id !== id);
}
}
// lib/repositories/index.ts — dependency injection
import { PostPrismaRepository } from './post.prisma.repository';
// В production — Prisma. В тестах — подменяем на Mock
export const postRepository = new PostPrismaRepository();
// app/blog/page.tsx — Server Component использует репозиторий
import { postRepository } from '@/lib/repositories';
export default async function BlogPage() {
const posts = await postRepository.findAll(); // Не знает о Prisma!
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

💡 Главное преимущество: смени базу данных с Prisma на Drizzle — поменяй одну строку в index.ts. Компоненты не трогаешь!


BFF — это промежуточный слой между фронтендом и бэкендом, оптимизированный под конкретный UI. Next.js — идеальный BFF!

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

Frontend → UserService (10 полей)
→ PostService (15 полей)
→ CommentService (8 полей)
= Собирай данные в 3 запроса, получай лишние поля

С BFF (Next.js Route Handlers):

Frontend → Next.js BFF → UserService
→ PostService
→ CommentService
Один запрос, только нужные поля!
// app/api/dashboard/route.ts — BFF агрегирует данные
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
// Параллельно запрашиваем несколько сервисов
const [user, posts, stats] = await Promise.all([
fetch(`${process.env.USER_SERVICE_URL}/users/${userId}`).then(r => r.json()),
fetch(`${process.env.POST_SERVICE_URL}/posts?author=${userId}`).then(r => r.json()),
fetch(`${process.env.ANALYTICS_URL}/stats/${userId}`).then(r => r.json()),
]);
// Возвращаем ТОЛЬКО то, что нужно UI
return NextResponse.json({
name: user.displayName,
avatar: user.profilePicture,
postCount: posts.length,
latestPost: posts[0]?.title ?? null,
totalViews: stats.pageViews,
});
}
// Клиент — один запрос вместо трёх!
const { data: dashboard } = useSWR('/api/dashboard?userId=123');

🚩 Feature Flags — включение функций без деплоя

Заголовок раздела «🚩 Feature Flags — включение функций без деплоя»

Feature Flags позволяют включать/выключать функции в production без деплоя. Незаменимо для:

  • Постепенного ролаута (сначала 10% пользователей)
  • A/B тестирования
  • Экстренного отключения сломанной фичи
// lib/flags.ts — центральный реестр флагов
export type Flag =
| 'new-checkout'
| 'ai-search'
| 'dark-mode-v2'
| 'react-19-features';
export interface FlagConfig {
enabled: boolean;
rolloutPercentage?: number; // 0-100
allowedUserIds?: string[];
}
export const FLAGS: Record<Flag, FlagConfig> = {
'new-checkout': { enabled: true, rolloutPercentage: 25 },
'ai-search': { enabled: false },
'dark-mode-v2': { enabled: true },
'react-19-features': { enabled: true, allowedUserIds: ['user_1', 'user_2'] },
};
export function isEnabled(flag: Flag, userId?: string): boolean {
const config = FLAGS[flag];
if (!config.enabled) return false;
if (config.allowedUserIds && userId) {
return config.allowedUserIds.includes(userId);
}
if (config.rolloutPercentage !== undefined) {
// Детерминированный rollout по userId
const hash = userId
? parseInt(userId.split('').reduce((acc, c) => acc + c.charCodeAt(0).toString(), '')) % 100
: Math.random() * 100;
return hash < config.rolloutPercentage;
}
return true;
}
// app/checkout/page.tsx — использование флага
import { isEnabled } from '@/lib/flags';
import { auth } from '@/lib/auth';
export default async function CheckoutPage() {
const session = await auth();
const useNewCheckout = isEnabled('new-checkout', session?.user?.id);
if (useNewCheckout) {
const { NewCheckout } = await import('./new-checkout');
return <NewCheckout />;
}
return <OldCheckout />;
}

Для клиентской стороны — feature flags через cookies:

// middleware.ts — устанавливаем флаги для пользователя
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Установить cookie с флагами для нового пользователя
if (!request.cookies.has('feature-flags')) {
const flags = {
'new-ui': Math.random() < 0.5, // 50% пользователей
'beta-features': false,
};
response.cookies.set('feature-flags', JSON.stringify(flags), {
httpOnly: false, // Доступен клиенту
maxAge: 60 * 60 * 24 * 30, // 30 дней
});
}
return response;
}
export const config = { matcher: ['/((?!api|_next).*)'] };
// hooks/useFeatureFlag.ts — хук для клиента
'use client';
import { useMemo } from 'react';
export function useFeatureFlag(flag: string): boolean {
return useMemo(() => {
if (typeof document === 'undefined') return false;
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith('feature-flags='));
if (!cookie) return false;
try {
const flags = JSON.parse(decodeURIComponent(cookie.split('=')[1] ?? ''));
return Boolean(flags[flag]);
} catch {
return false;
}
}, [flag]);
}
// Использование в компоненте
'use client';
export function Header() {
const hasNewUI = useFeatureFlag('new-ui');
return hasNewUI ? <NewHeader /> : <OldHeader />;
}

// middleware.ts — A/B тест через cookie
import { NextRequest, NextResponse } from 'next/server';
const AB_BUCKET_COOKIE = 'ab-bucket';
export function middleware(request: NextRequest) {
// Только для главной страницы
if (request.nextUrl.pathname !== '/') {
return NextResponse.next();
}
let bucket = request.cookies.get(AB_BUCKET_COOKIE)?.value as 'a' | 'b' | undefined;
if (!bucket) {
bucket = Math.random() < 0.5 ? 'a' : 'b';
}
// Перенаправляем на нужный вариант
const url = request.nextUrl.clone();
url.pathname = bucket === 'b' ? '/landing-b' : '/landing-a';
const response = NextResponse.rewrite(url);
response.cookies.set(AB_BUCKET_COOKIE, bucket, {
maxAge: 60 * 60 * 24 * 7, // 7 дней
httpOnly: true,
});
return response;
}
// app/landing-a/page.tsx — вариант A
export default function LandingA() {
// Отправляем аналитику
useEffect(() => {
analytics.track('landing_view', { variant: 'a' });
}, []);
return <h1>Присоединяйся к нам!</h1>;
}

Optimistic UI — обновляем интерфейс сразу, не дожидаясь ответа сервера. Если сервер вернул ошибку — откатываем.

app/posts/[id]/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function toggleLike(postId: string, liked: boolean) {
// Имитация задержки сети
await new Promise(resolve => setTimeout(resolve, 1000));
// Здесь — сохранение в базу данных
// await db.likes.upsert({ where: { postId }, data: { liked } });
revalidatePath(`/posts/${postId}`);
return { success: true, newCount: liked ? 42 : 40 };
}
app/posts/[id]/like-button.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from './actions';
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
initialCount: number;
}
export function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimisticState, setOptimisticState] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(current, newLiked: boolean) => ({
liked: newLiked,
count: newLiked ? current.count + 1 : current.count - 1,
})
);
const handleClick = () => {
startTransition(async () => {
const newLiked = !optimisticState.liked;
setOptimisticState(newLiked); // Сразу обновляем UI!
await toggleLike(postId, newLiked); // Ждём сервер
});
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all
${optimisticState.liked
? 'bg-red-100 text-red-600 border-red-300'
: 'bg-gray-100 text-gray-600 border-gray-300'
} border`}
>
<span className={`text-xl ${isPending ? 'animate-pulse' : ''}`}>
{optimisticState.liked ? '❤️' : '🤍'}
</span>
<span className="font-medium">{optimisticState.count}</span>
</button>
);
}

💡 Пользователь видит мгновенный отклик. Если сервер вернул ошибку — React автоматически откатит состояние к initialLiked.


В App Router ошибки обрабатываются через error.tsx файлы:

// app/error.tsx — глобальный Error Boundary
'use client';
import { useEffect } from 'react';
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function GlobalError({ error, reset }: ErrorProps) {
useEffect(() => {
// Отправляем в Sentry или другой сервис
console.error('Global error:', error);
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center bg-red-50">
<div className="text-center p-8">
<div className="text-6xl mb-4">💥</div>
<h2 className="text-2xl font-bold text-red-700 mb-2">Что-то пошло не так!</h2>
<p className="text-red-600 mb-6">{error.message}</p>
{error.digest && (
<p className="text-xs text-gray-400 mb-4">ID: {error.digest}</p>
)}
<button
onClick={reset}
className="bg-red-600 text-white px-6 py-2 rounded-lg hover:bg-red-700"
>
Попробовать снова
</button>
</div>
</div>
);
}
// app/dashboard/error.tsx — Error Boundary для конкретного сегмента
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-8 border border-red-200 rounded-xl bg-red-50">
<h3 className="text-red-700 font-semibold">Ошибка в дашборде</h3>
<p className="text-red-600 text-sm mt-1">{error.message}</p>
<button onClick={reset} className="mt-4 text-sm text-red-700 underline">
Перезагрузить
</button>
</div>
);
}

Структура error.tsx файлов:

app/
error.tsx ← ловит ошибки всего приложения
layout.tsx
dashboard/
error.tsx ← ловит только ошибки /dashboard
page.tsx
analytics/
error.tsx ← ловит только ошибки /dashboard/analytics

Скелеты лучше спиннеров: пользователь видит структуру страницы и понимает, что загружается.

components/skeletons/post-card-skeleton.tsx
export function PostCardSkeleton() {
return (
<div className="animate-pulse rounded-xl border border-gray-200 p-6">
{/* Аватар + имя автора */}
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-gray-200 rounded-full" />
<div>
<div className="h-3 bg-gray-200 rounded w-24 mb-2" />
<div className="h-2 bg-gray-100 rounded w-16" />
</div>
</div>
{/* Заголовок */}
<div className="h-5 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-5 bg-gray-200 rounded w-1/2 mb-4" />
{/* Текст */}
<div className="space-y-2">
<div className="h-3 bg-gray-100 rounded w-full" />
<div className="h-3 bg-gray-100 rounded w-5/6" />
<div className="h-3 bg-gray-100 rounded w-4/6" />
</div>
{/* Footer */}
<div className="flex gap-4 mt-4">
<div className="h-4 bg-gray-200 rounded w-12" />
<div className="h-4 bg-gray-200 rounded w-16" />
</div>
</div>
);
}
// app/blog/loading.tsx — автоматически показывается при загрузке
import { PostCardSkeleton } from '@/components/skeletons';
export default function BlogLoading() {
return (
<div className="container mx-auto p-8">
<div className="h-8 bg-gray-200 rounded w-48 mb-8 animate-pulse" />
<div className="grid grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
</div>
);
}
// Использование Suspense + skeleton в компоненте
import { Suspense } from 'react';
export default function BlogPage() {
return (
<Suspense fallback={<BlogLoading />}>
<PostList /> {/* Async Server Component */}
</Suspense>
);
}

Compound Components — компоненты, которые работают вместе как единое целое, но настраиваются раздельно:

components/tabs/index.tsx
import { createContext, useContext, useState } from 'react';
interface TabsContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextType | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs.* must be used inside <Tabs>');
return ctx;
}
// Корневой компонент — владеет состоянием
function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="w-full">{children}</div>
</TabsContext.Provider>
);
}
// Список вкладок
function TabList({ children }: { children: React.ReactNode }) {
return (
<div className="flex border-b border-gray-200 gap-1">{children}</div>
);
}
// Отдельная вкладка
function Tab({ value, children }: { value: string; children: React.ReactNode }) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === value;
return (
<button
onClick={() => setActiveTab(value)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors
${isActive
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{children}
</button>
);
}
// Содержимое вкладки
function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== value) return null;
return <div className="py-4">{children}</div>;
}
// Экспорт как namespace
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
export { Tabs };
// Использование — очень читаемо!
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab value="overview">Обзор</Tabs.Tab>
<Tabs.Tab value="analytics">Аналитика</Tabs.Tab>
<Tabs.Tab value="settings">Настройки</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview"><OverviewPanel /></Tabs.Panel>
<Tabs.Panel value="analytics"><AnalyticsPanel /></Tabs.Panel>
<Tabs.Panel value="settings"><SettingsPanel /></Tabs.Panel>
</Tabs>

Разделяем логику (Container) от отображения (Presenter):

// components/user-profile/user-profile.presenter.tsx — только UI
export interface UserProfileData {
name: string;
email: string;
avatar: string;
postCount: number;
followerCount: number;
}
interface UserProfilePresenterProps {
data: UserProfileData;
onFollow: () => void;
isFollowing: boolean;
isLoading: boolean;
}
export function UserProfilePresenter({
data, onFollow, isFollowing, isLoading,
}: UserProfilePresenterProps) {
return (
<div className="flex items-start gap-6 p-6 bg-white rounded-xl shadow-sm">
<img src={data.avatar} alt={data.name} className="w-20 h-20 rounded-full object-cover" />
<div className="flex-1">
<h2 className="text-xl font-bold">{data.name}</h2>
<p className="text-gray-500">{data.email}</p>
<div className="flex gap-6 mt-3 text-sm text-gray-600">
<span><b>{data.postCount}</b> постов</span>
<span><b>{data.followerCount}</b> подписчиков</span>
</div>
</div>
<button
onClick={onFollow}
disabled={isLoading}
className={`px-6 py-2 rounded-lg font-medium transition-colors
${isFollowing ? 'bg-gray-100 text-gray-700' : 'bg-blue-600 text-white hover:bg-blue-700'}`}
>
{isLoading ? '...' : isFollowing ? 'Отписаться' : 'Подписаться'}
</button>
</div>
);
}
// components/user-profile/user-profile.container.tsx — логика
'use client';
import { useState, useEffect } from 'react';
import { UserProfilePresenter, type UserProfileData } from './user-profile.presenter';
export function UserProfileContainer({ userId }: { userId: string }) {
const [data, setData] = useState<UserProfileData | null>(null);
const [isFollowing, setIsFollowing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setData);
}, [userId]);
const handleFollow = async () => {
setIsLoading(true);
await fetch(`/api/users/${userId}/follow`, { method: 'POST' });
setIsFollowing(f => !f);
setIsLoading(false);
};
if (!data) return <Skeleton />;
return (
<UserProfilePresenter
data={data}
onFollow={handleFollow}
isFollowing={isFollowing}
isLoading={isLoading}
/>
);
}

Параллельное получение данных (лучший вариант)

Заголовок раздела «Параллельное получение данных (лучший вариант)»
// app/dashboard/page.tsx — всё параллельно!
async function fetchUser(id: string) {
return fetch(`/api/users/${id}`).then(r => r.json());
}
async function fetchPosts(userId: string) {
return fetch(`/api/posts?author=${userId}`).then(r => r.json());
}
async function fetchStats(userId: string) {
return fetch(`/api/stats/${userId}`).then(r => r.json());
}
export default async function DashboardPage({ params }: { params: { id: string } }) {
// Все запросы стартуют одновременно
const [user, posts, stats] = await Promise.all([
fetchUser(params.id),
fetchPosts(params.id),
fetchStats(params.id),
]);
return <Dashboard user={user} posts={posts} stats={stats} />;
}

Последовательное (когда нужны данные из первого запроса)

Заголовок раздела «Последовательное (когда нужны данные из первого запроса)»
// Правильно: последовательно, когда есть зависимость
export default async function PostWithComments({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug); // Сначала пост
const comments = await getComments(post.id); // Потом комментарии (нужен post.id)
return <PostView post={post} comments={comments} />;
}
// utils/preload.ts — запускаем запрос заранее
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
return fetch(`/api/users/${id}`).then(r => r.json());
});
// Preload — запускает запрос, но не ждёт результата
export function preloadUser(id: string) {
void getUser(id); // fire and forget
}
// app/users/[id]/page.tsx — запускаем запрос как можно раньше
import { preloadUser, getUser } from '@/utils/preload';
export default async function UserPage({ params }: { params: { id: string } }) {
preloadUser(params.id); // Стартуем запрос немедленно!
// Делаем другую работу...
const layout = await getLayout();
// К этому моменту данные уже могут быть в кэше
const user = await getUser(params.id);
return <UserProfile user={user} layout={layout} />;
}

Parallel Routes позволяют показывать несколько страниц одновременно в одном layout:

app/dashboard/
layout.tsx ← принимает @team и @analytics
page.tsx
@team/
page.tsx ← /dashboard показывает это...
@analytics/
page.tsx ← ...и это — одновременно!
app/dashboard/layout.tsx
export default function DashboardLayout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div>{team}</div>
<div>{analytics}</div>
</div>
);
}

Intercepting Routes — открываем страницу как модальное окно, но при прямом переходе — полная страница:

app/
photos/
[id]/
page.tsx ← /photos/1 — полная страница
@modal/
(.)photos/
[id]/
page.tsx ← при клике — показывает как модальное окно
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/modal';
import { getPhoto } from '@/lib/photos';
export default async function PhotoModal({ params }: { params: { id: string } }) {
const photo = await getPhoto(params.id);
return (
<Modal>
<img src={photo.url} alt={photo.title} className="max-w-2xl" />
<h2>{photo.title}</h2>
</Modal>
);
}
components/modal.tsx
'use client';
import { useRouter } from 'next/navigation';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => router.back()}
>
<div
className="bg-white rounded-xl p-6 max-w-2xl"
onClick={e => e.stopPropagation()}
>
{children}
<button onClick={() => router.back()} className="mt-4 text-sm text-gray-500">
Закрыть
</button>
</div>
</div>
);
}

app/posts/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
// Инвалидируем конкретный путь
export async function updatePost(id: string, data: Partial<Post>) {
await db.posts.update({ where: { id }, data });
revalidatePath('/blog'); // Инвалидирует /blog
revalidatePath(`/blog/${id}`); // Инвалидирует /blog/конкретный-пост
}
// Инвалидируем по тегу — гибче
export async function publishPost(id: string) {
await db.posts.update({ where: { id }, data: { published: true } });
revalidateTag('posts'); // Все запросы с тегом 'posts'
revalidateTag(`post-${id}`); // Только этот пост
}
// Теггирование запросов
async function getPosts() {
return fetch('/api/posts', {
next: { tags: ['posts'] } // Помечаем тегом
}).then(r => r.json());
}
async function getPost(id: string) {
return fetch(`/api/posts/${id}`, {
next: { tags: ['posts', `post-${id}`] }
}).then(r => r.json());
}

SWR — показываем старые данные сразу, обновляем в фоне:

// Серверная сторона — ISR (Incremental Static Regeneration)
async function getProducts() {
return fetch('https://api.example.com/products', {
next: {
revalidate: 60, // Обновлять каждые 60 секунд
tags: ['products'], // Можно инвалидировать вручную
},
}).then(r => r.json());
}
// При первом запросе — генерируем страницу
// При повторных — отдаём кэш
// После 60 секунд — следующий запрос регенерирует в фоне
// Клиентская сторона — SWR библиотека (useSWR)
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
export function ProductList() {
const { data, error, isValidating } = useSWR('/api/products', fetcher, {
revalidateOnFocus: true, // Обновлять при фокусе окна
revalidateOnReconnect: true, // Обновлять при восстановлении сети
refreshInterval: 30000, // Автообновление каждые 30 сек
});
// data — моментально из кэша
// isValidating — идёт фоновое обновление
return (
<div>
{isValidating && <span className="text-xs text-gray-400">Обновляется...</span>}
{data?.map(product => <ProductCard key={product.id} product={product} />)}
</div>
);
}

ПаттернРешает
RepositoryАбстракция над данными
BFFОптимизация API для UI
Feature FlagsДеплой без страха
Optimistic UIМгновенный отклик
Error BoundariesGraceful degradation
Compound ComponentsПереиспользуемые UI-системы
Container/PresenterРазделение логики и UI
Parallel RoutesНезависимые разделы страницы
Intercepting RoutesМодальные окна «из коробки»
SWRВсегда свежие данные

Всё вместе — это современная Next.js архитектура 🏆. Посмотри на демо ниже 👇