27. Паттерны проектирования
🎯 Next.js Design Patterns — рецепты для сложных задач
Заголовок раздела «🎯 Next.js Design Patterns — рецепты для сложных задач»Паттерны — это проверенные решения для повторяющихся проблем. Кто-то уже столкнулся с задачей «как грамотно абстрагировать данные» или «как делать A/B тесты без деплоя» — и придумал элегантное решение. Мы просто берём эти решения и применяем! 🧩
❓ Зачем паттерны?
Заголовок раздела «❓ Зачем паттерны?»Аналогия: строители не изобретают колесо каждый раз. Есть шаблоны конструкций — свод, арка, балка. Так же и в программировании: есть проверенные архитектурные решения.
Без паттернов:
- Бизнес-логика смешана с UI
- Сложно тестировать
- Изменение одного ломает другое
- Код понятен только автору
С паттернами:
- Чистое разделение ответственности
- Легко тестировать каждый слой отдельно
- Новый разработчик понимает структуру за 5 минут
- Рефакторинг не страшен
🗄️ Repository Pattern с Server Components
Заголовок раздела «🗄️ Repository Pattern с Server Components»Паттерн Repository — абстракция над источником данных. Компоненты не знают, откуда приходят данные (база данных, API, файл, кэш) — они просто вызывают методы репозитория.
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 — реализация через Prismaimport { 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 injectionimport { PostPrismaRepository } from './post.prisma.repository';
// В production — Prisma. В тестах — подменяем на Mockexport 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 (Backend for Frontend)
Заголовок раздела «🔌 BFF (Backend for Frontend)»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
Заголовок раздела «🍪 Feature Flags с cookies и middleware»Для клиентской стороны — 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 />;}🧪 A/B тестирование через middleware
Заголовок раздела «🧪 A/B тестирование через middleware»// middleware.ts — A/B тест через cookieimport { 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 — вариант Aexport default function LandingA() { // Отправляем аналитику useEffect(() => { analytics.track('landing_view', { variant: 'a' }); }, []); return <h1>Присоединяйся к нам!</h1>;}⚡ Optimistic UI с useOptimistic (React 19)
Заголовок раздела «⚡ Optimistic UI с useOptimistic (React 19)»Optimistic UI — обновляем интерфейс сразу, не дожидаясь ответа сервера. Если сервер вернул ошибку — откатываем.
'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 };}'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.
🛡️ Error Boundaries в App Router
Заголовок раздела «🛡️ Error Boundaries в App Router»В 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💀 Loading Skeletons — skeleton screens
Заголовок раздела «💀 Loading Skeletons — skeleton screens»Скелеты лучше спиннеров: пользователь видит структуру страницы и понимает, что загружается.
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 паттерн
Заголовок раздела «🧩 Compound Components паттерн»Compound Components — компоненты, которые работают вместе как единое целое, но настраиваются раздельно:
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>;}
// Экспорт как namespaceTabs.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 паттерн
Заголовок раздела «📦 Container / Presenter паттерн»Разделяем логику (Container) от отображения (Presenter):
// components/user-profile/user-profile.presenter.tsx — только UIexport 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} /> );}📡 Data Fetching паттерны
Заголовок раздела «📡 Data Fetching паттерны»Параллельное получение данных (лучший вариант)
Заголовок раздела «Параллельное получение данных (лучший вариант)»// 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} />;}Preload паттерн — избегаем Request Waterfalls
Заголовок раздела «Preload паттерн — избегаем Request Waterfalls»// 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 для дашбордов
Заголовок раздела «🛤️ Parallel Routes для дашбордов»Parallel Routes позволяют показывать несколько страниц одновременно в одном layout:
app/dashboard/ layout.tsx ← принимает @team и @analytics page.tsx @team/ page.tsx ← /dashboard показывает это... @analytics/ page.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 для модальных окон
Заголовок раздела «🎪 Intercepting Routes для модальных окон»Intercepting Routes — открываем страницу как модальное окно, но при прямом переходе — полная страница:
app/ photos/ [id]/ page.tsx ← /photos/1 — полная страница @modal/ (.)photos/ [id]/ page.tsx ← при клике — показывает как модальное окно// app/@modal/(.)photos/[id]/page.tsximport { 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> );}'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> );}🔄 Cache Invalidation паттерны
Заголовок раздела «🔄 Cache Invalidation паттерны»'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());}⏰ Stale-while-revalidate паттерн
Заголовок раздела «⏰ Stale-while-revalidate паттерн»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 Boundaries | Graceful degradation |
| Compound Components | Переиспользуемые UI-системы |
| Container/Presenter | Разделение логики и UI |
| Parallel Routes | Независимые разделы страницы |
| Intercepting Routes | Модальные окна «из коробки» |
| SWR | Всегда свежие данные |
Всё вместе — это современная Next.js архитектура 🏆. Посмотри на демо ниже 👇