Высокая60 мин Ozon, Wildberries, Яндекс Маркет

Маркетплейс

Архитектура каталога товаров как Ozon

  • SSR/ISR
  • Фильтрация
  • SEO
  • Микрофронтенды

Описание задачи

Вам предлагают спроектировать фронтенд крупного маркетплейса масштаба Ozon, Wildberries или Яндекс Маркет. Ключевые экраны: каталог товаров с фильтрацией, поиск с автокомплитом, карточка товара, корзина и оформление заказа.

Маркетплейс — это продукт, где SEO критичен для выживания бизнеса: значительная часть трафика приходит из поисковых систем на страницы каталога и карточки товара. Это главное отличие от ленты новостей — здесь серверный рендеринг не опция, а необходимость.

Масштаб и контекст

Ozon — это 100 000+ товаров, сотни категорий, десятки фильтров для каждой категории, персонализированные рекомендации. Над фронтендом работают несколько команд, каждая отвечает за свой домен. Это приводит к архитектурным решениям, которых нет в маленьких проектах: микрофронтенды, BFF, ISR.

На интервью уточните: «Сколько команд работают над фронтендом?» Если одна — микрофронтенды не нужны. Если пять — это меняет всю архитектуру. Также спросите про объём каталога и частоту обновления цен — это определит стратегию кэширования.

В этом разборе мы построим архитектуру, которая обеспечивает SEO, быстрые Core Web Vitals и масштабируется при росте команды и каталога.

Requirements Requirements — Сбор требований

Функциональные требования

  • Каталог с фильтрами — навигация по категориям, фильтрация по цене (range slider), бренду (чекбоксы), рейтингу (от 4+), наличию. Фильтры специфичны для категории: у электроники — объём памяти, у одежды — размер.
  • Поиск с автокомплитом — подсказки по мере ввода: товары, категории, бренды. Debounce 300ms, обработка кликов и навигации стрелками.
  • Карточка товара — галерея фото (зум, свайп), описание, характеристики (таблица), отзывы (пагинация, сортировка), блок «Похожие товары».
  • Корзина — добавление/удаление, изменение количества, расчёт итога с учётом скидок и промокодов.
  • Оформление заказа — адрес доставки (автокомплит адреса), выбор способа доставки, оплата (редирект на PSP или встроенная форма).

Нефункциональные требования

  • SEO — страницы каталога и карточки товара должны полностью индексироваться: server-rendered HTML, корректные meta-теги, структурированные данные (JSON-LD).
  • Core Web Vitals — LCP < 2.5s, CLS < 0.1, INP < 200ms. Google учитывает эти метрики при ранжировании.
  • Масштаб — 100K+ товаров, динамические фильтры, частое обновление цен (каждые 5–60 минут).
  • Персонализация — рекомендации, недавно просмотренное, персональные цены. Конфликт с кэшированием!
  • A/B тестирование — разные варианты UI для разных когорт без увеличения bundle size.
Важный trade-off: персонализация и кэширование противоречат друг другу. Персонализированный контент нельзя раздавать с CDN. Решение — разделять страницу: статичный каркас (SSR/ISR + CDN) + динамические персонализированные блоки (CSR после гидрации).

Architecture Architecture — Архитектура

Микрофронтенды: границы и интеграция

При нескольких командах естественно разделить фронтенд по доменам. Границы обычно совпадают с бизнес-доменами:

  • Team Catalog — каталог, фильтры, поиск, карточка товара
  • Team Cart — корзина, чекаут, оплата
  • Team Shell — хедер, футер, поиск, навигация, auth

Интеграция через Module Federation (Webpack 5+): каждая команда собирает свой бандл, shell-приложение загружает их в runtime. Альтернатива — npm-пакеты, но они требуют координированных релизов.

// next.config.js (shell)
const { NextFederationPlugin } = require("@module-federation/nextjs-mf");

module.exports = {
  webpack(config) {
    config.plugins.push(new NextFederationPlugin({
      name: "shell",
      remotes: {
        catalog: "catalog@https://catalog.cdn.example.com/remote.js",
        cart: "cart@https://cart.cdn.example.com/remote.js",
      },
    }));
    return config;
  },
};

Рендеринг: ISR + Streaming SSR

Каталог и карточки товара — ISR (Incremental Static Regeneration) с revalidate 60 секунд. Страницы генерируются статически, обновляются в фоне. Для 100K товаров on-demand revalidation при изменении цены.

Streaming SSR для первой загрузки: хедер и скелет страницы отправляются мгновенно, данные каталога подтягиваются стримом. Пользователь видит структуру за 200ms, контент за 1–2s.

URL-driven state

Фильтры каталога живут в URL query params, а не в React state. Это критично для SEO (каждая комбинация фильтров — уникальный URL) и для UX (кнопка «Назад» работает):

/catalog/electronics?brand=apple,samsung&price_min=10000&price_max=50000&sort=rating&page=2

BFF (Backend for Frontend)

Тонкий серверный слой, который агрегирует данные из нескольких микросервисов (каталог, цены, наличие, рекомендации) в один оптимизированный ответ для фронтенда. Это сокращает количество запросов и позволяет адаптировать payload под нужды UI.

Data Model Data Model — Модель данных

Основные сущности

interface Product {
  id: string;
  title: string;
  price: number;
  originalPrice?: number;    // до скидки (зачёркнутая цена)
  images: string[];          // URL-ы, первая = обложка
  rating: number;            // 0–5, с десятичной
  reviewCount: number;
  brand: string;
  category: Category;
  inStock: boolean;
  specifications: Spec[];    // { label, value }
  badges?: string[];         // "Хит продаж", "Скидка 30%"
}

interface Category {
  id: string;
  name: string;
  slug: string;
  parentId?: string;         // для breadcrumb
}

interface Filter {
  id: string;
  type: "range" | "checkbox" | "rating";
  label: string;
  options?: FilterOption[];  // для checkbox
  min?: number;              // для range
  max?: number;
  selected: string[] | [number, number] | number;
}

interface FilterOption {
  value: string;
  label: string;
  count: number;             // количество товаров
}

interface SearchSuggestion {
  query: string;
  products: Product[];       // топ-3 товара
  categories: Category[];    // релевантные категории
}

Корзина

interface Cart {
  items: CartItem[];
  totalPrice: number;
  totalDiscount: number;
  promoCode?: string;
}

interface CartItem {
  productId: string;
  product: Product;          // денормализовано для отображения
  quantity: number;
  price: number;             // цена на момент добавления
}

Где хранить состояние

  • URL — фильтры, сортировка, страница каталога. Это source of truth для серверного компонента.
  • Server state (React Query / SWR) — каталог, карточка товара, отзывы. Кэшируется, инвалидируется, дедуплицируется автоматически.
  • Client state (Zustand) — корзина, UI-состояния (модалки, тултипы). Корзина синхронизируется с localStorage для persistence.
  • Cookie — auth token, user preferences, A/B группа. Доступно и на сервере, и на клиенте.
На интервью: покажите, что вы понимаете разницу между server state и client state. Каталог — это server state (источник правды на бэкенде), корзина — client state (источник правды на клиенте, синхронизируется с сервером).

Interface Interface — API и контракты

REST API Endpoints

// Каталог с фильтрацией (основной endpoint)
GET /api/products?category={slug}&brand={brands}&price_min={min}
    &price_max={max}&rating={min_rating}&sort={field}
    &page={page}&limit=24
→ {
    products: Product[],
    filters: Filter[],         // доступные фильтры с counts
    totalCount: number,
    page: number,
    totalPages: number
  }

// Карточка товара
GET /api/products/{id}
→ { product: Product, recommendations: Product[] }

// Отзывы товара
GET /api/products/{id}/reviews?sort={recent|helpful}&page={page}
→ { reviews: Review[], totalCount, averageRating }

// Поиск с автокомплитом
GET /api/search/suggest?q={query}
→ { suggestions: SearchSuggestion[] }

// Корзина
GET /api/cart
→ { cart: Cart }

POST /api/cart/items
Body: { productId, quantity }
→ { cart: Cart }

PUT /api/cart/items/{productId}
Body: { quantity }
→ { cart: Cart }

DELETE /api/cart/items/{productId}
→ { cart: Cart }

Особенности API

Фильтры возвращаются вместе с каталогом. Каждый filter option содержит count — количество товаров. Когда пользователь выбирает бренд Apple, фильтр цены обновляет min/max, а другие бренды показывают 0 товаров. Это требует пересчёта на бэкенде при каждом изменении фильтров.

Пагинация — offset-based (не курсорная), потому что пользователь может перейти на страницу 5 напрямую из URL. Курсорная пагинация здесь не подходит.

Автокомплит: debounce и кэширование

// Хук для поиска с debounce
function useSearch(query: string) {
  return useSWR(
    query.length >= 2 ? ["/api/search/suggest", query] : null,
    fetcher,
    {
      dedupingInterval: 300,
      keepPreviousData: true,  // не показывать loading при каждом символе
    }
  );
}
Совет: keepPreviousData: true — ключевой параметр для автокомплита. Без него при каждом нажатии клавиши список результатов мигает (пропадает → loading → новые результаты). С ним предыдущие результаты видны, пока грузятся новые.

Optimizations Optimizations — Оптимизации

ISR и On-Demand Revalidation

Каталог генерируется статически с revalidate: 60. Но при изменении цены ждать 60 секунд — плохо. Решение: on-demand revalidation через webhook от бэкенда:

// /api/revalidate (Next.js Route Handler)
export async function POST(request: Request) {
  const { productId, type } = await request.json();

  if (type === "price_update") {
    revalidatePath(`/products/${productId}`);
    revalidateTag("catalog");
  }

  return Response.json({ revalidated: true });
}

Streaming SSR

Next.js App Router поддерживает loading.tsx и Suspense для streaming. Пользователь видит хедер и скелет каталога за ~200ms, данные подтягиваются в stream:

// app/catalog/[category]/page.tsx
import { Suspense } from "react";

export default function CatalogPage({ searchParams }) {
  return (
    <>
      <CatalogHeader />
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid searchParams={searchParams} />
      </Suspense>
    </>
  );
}

Image optimization

  • Responsive srcset — три размера: 300w (мобильная сетка), 600w (десктопная сетка), 1200w (карточка товара).
  • LQIP (Low Quality Image Placeholder) — blur-up эффект. Base64-строка 20×20 px передаётся в SSR-ответе, полное изображение загружается лениво.
  • Lazy loading ниже fold — только первые 6 товаров загружают изображения eager, остальные — lazy.

Prefetch при наведении

Когда пользователь наводит курсор на карточку товара, начинаем prefetch данных карточки и её изображений. К моменту клика страница открывается мгновенно:

<Link
  href={productUrl}
  onMouseEnter={() => {
    router.prefetch(productUrl);
    preloadImage(product.images[0]);
  }}
>

Кэширование

  • CDN edge cache — ISR-страницы раздаются с CDN, бэкенд не нагружается на каждый запрос.
  • HTTP cache headersCache-Control: s-maxage=60, stale-while-revalidate=300 для каталога.
  • localStorage — корзина, недавно просмотренные товары, поисковая история. Лимит: хранить последние 50 товаров, очищать по FIFO.

URL-driven фильтры: UX-детали

Debounce изменений фильтров (300ms для range slider, мгновенно для чекбоксов). Оптимистичное обновление URL через router.replace с shallow routing — без полной перезагрузки страницы. useTransition для показа pending-состояния, пока данные подгружаются.

Итоги и ключевые trade-offs

Ключевые архитектурные решения

  • ISR + Streaming SSR — лучший выбор для каталога: SEO, скорость, свежесть данных. Альтернатива — чистый SSR — дороже по серверным ресурсам и медленнее TTFB при высокой нагрузке.
  • Микрофронтенды через Module Federation — оправданы при 3+ команд. Для одной команды это overengineering: координация, дублирование зависимостей, сложность отладки. На интервью скажите: «Я бы начал с модульного монолита и вынес микрофронтенды, когда появится третья команда».
  • URL-driven filters — фильтры в URL, а не в state. Это SEO-friendly, поддерживает кнопку «Назад», позволяет шарить ссылку с применёнными фильтрами. Trade-off: сложнее синхронизировать UI и URL, нужен debounce.
  • BFF — добавляет серверный слой, зато фронтенд получает один оптимизированный запрос вместо 5 к разным микросервисам. Trade-off: ещё один сервис для поддержки, потенциальная точка отказа.

Что запомнить для интервью

  • SEO — главный нефункциональный требование для маркетплейса. Это определяет выбор SSR/ISR.
  • Персонализация и кэширование конфликтуют — покажите, что знаете решение (static shell + dynamic islands).
  • Корзина — отдельный контекст: client-first state с optimistic sync на бэкенд.
  • Не забудьте про structured data (JSON-LD для товаров) — это плюс и для SEO, и в глазах интервьюера.
Частый вопрос интервьюера: «Как обеспечить актуальность цен на ISR-странице?» Ответ: «ISR с revalidate 60s + on-demand revalidation при изменении цены. Для критичных данных (наличие, цена) — CSR-запрос после гидрации, который обновляет серверные данные до актуальных».