Масштабована фронтенд-архітектура

Детальний посібник з організації застосунків на React і Next.js для масштабування. Розглядаємо feature-based і layer-based структури, доменно-орієнтований дизайн, організацію API-клієнтів, межі між серверними й клієнтськими компонентами та керування залежностями між модулями.

Vladyslav Tsyvinda
Vladyslav Tsyvinda
·Оновлено
Обкладинка: Масштабована фронтенд-архітектура

Масштабована фронтенд-архітектура: як не дати проєкту перетворитися на смітник

Більшість фронтенд-проєктів починаються однаково. Є create-next-app, дві-три сторінки й папка components, куди складається все, що не є сторінкою. Перші кілька тижнів це працює бездоганно. Проблеми приходять пізніше — коли проєкт переживає третій рефакторинг, четвертого розробника й момент, коли вже ніхто точно не пам'ятає, чому компонент Card приймає сімнадцять пропсів і чому його імпортують у дванадцяти місцях, кожне з яких очікує трохи іншої поведінки.

Ця стаття не про те, як правильно писати на React. Вона про те, як організувати код, щоб після року роботи проєкт залишався проєктом, а не археологічними розкопками. Усі приклади тут — із реального застосунку на Next.js 16 (App Router) з React 19 і TypeScript. Це персональний сайт із публічними сторінками, блогом, авторизованою зоною та кількома 3D-сценами на three.js. Він невеликий за розміром, але достатньо різноманітний за типами навантаження, щоб показати, де архітектурні рішення починають окупатися, а де вони — навпаки — заважають.

Ми розберемо сім тем, які найсильніше визначають, чи буде архітектура масштабованою: чому папка components завжди перетворюється на смітник, у чому різниця між feature-based і layer-based підходами, як влаштувати спільний UI-шар, що означає доменне мислення на фронтенді, як організувати API-клієнти, де пролягає межа між серверними й клієнтськими компонентами і, нарешті, як керувати залежностями між модулями, щоб зв'язність не вбила гнучкість.

1. Чому папка components перетворюється на смітник

Почнімо з найболючішого, бо це трапляється майже з усіма. Папку components ніхто ніколи не задумує як смітник. Її задумують як охайне місце для перевикористовуваних шматків інтерфейсу. Але в неї вбудований механізм деградації, і він працює непомітно.

Уявіть типову послідовність подій. Ви робите кнопку — кладете її в components/Button. Логічно. Потім робите форму логіну — куди її покласти? Теж у components, бо «це ж компонент». Далі з'являються LoginForm, RegisterForm, ProfileForm, PostCard, PostsList, CylinderScene, LocationMap. І ось у вас уже сорок папок в одній директорії, де річ, яку перевикористовують усюди (кнопка), лежить поруч із річчю, яку використовують рівно в одному місці й більше ніде (форма логіну на сторінці логіну).

Корінь проблеми не в дисципліні. Проблема в тому, що пласка папка components змішує дві принципово різні речі за однією зовнішньою ознакою. Обидві — «компоненти», тобто функції, що повертають JSX. Але одна з них — частина спільного словника інтерфейсу, а інша — деталь реалізації конкретної сторінки. У них різний життєвий цикл, різна аудиторія і різні причини для змін. Кнопка змінюється, коли змінюється дизайн-система. Форма логіну змінюється, коли змінюється логіка авторизації. Це абсолютно не пов'язані події, але пласка папка тримає ці речі на однаковій відстані одна від одної.

Далі вмикається друга сила — гравітація знайомого місця. Коли в проєкті вже є components, кожен новий компонент має нульовий поріг входу туди. Не треба думати, не треба обґрунтовувати, не треба створювати нову структуру. Просто кидаєш файл в існуючу папку. А все, що має нульовий поріг і жодного зворотного тиску, накопичується безмежно. Тому папка росте не через те, що хтось ухвалив таке рішення, а через те, що ніхто ніколи не ухвалив іншого.

Третя сила — втрата сигналу про область видимості. Коли компонент лежить у глобальній components, сам факт його розташування каже: «це можна використовувати будь-де». І рано чи пізно хтось саме так і робить — імпортує LoginForm у якесь несподіване місце, бо технічно ніщо цьому не заважає. Тепер у форми логіну два споживачі з різними очікуваннями, і будь-яка її зміна — це ризик зламати щось у другому місці, про яке автор змін навіть не підозрює. Так народжується найгірший вид зв'язності: випадкова, ненавмисна, помічена лише в продакшені.

У застосунку, який ми розбираємо, цієї пастки вдалося уникнути завдяки свідомому рішенню: компонент живе якомога ближче до місця використання і «підіймається» у спільний шар лише тоді, коли його справді починають перевикористовувати. Подивіться на структуру сторінки реєстрації:

src/app/registration/
  page.tsx
  layout.tsx
  page.module.css
  components/
    RegisterForm/
      RegisterForm.tsx
      RegisterForm.module.css
      RegisterForm.test.tsx
      RegisterForm.stories.tsx
      submitHandler.ts
      validation/
        schema.ts
        index.ts
      index.ts
    ConfirmEmail/
      ConfirmEmail.tsx
  ...

RegisterForm ніколи не потрапляє в глобальну папку. Він фізично прив'язаний до маршруту /registration разом зі своїми стилями, тестами, сторіз, логікою сабміту та схемою валідації. Якщо завтра реєстрацію приберуть, видаляється одна папка — і в глобальному смітнику не лишається ані осиротілих компонентів, ані мертвих імпортів. Локальність розташування — це не естетика, а механізм самоочищення.

Ключова ідея така: смітник утворюється не тому, що люди ліниві, а тому, що структура не чинить жодного опору. Щойно ви зробите так, щоб «правильне місце» для компонента визначалося його реальною областю використання, а не зручністю папки, проблема зникає сама собою. Папка components стає смітником саме тоді, коли в неї немає відповіді на запитання «хто це використовує?».

2. Feature-based vs layer-based: дві осі однієї структури

Тепер про головне роздоріжжя, довкола якого ламають списи в кожній другій дискусії про архітектуру. Є два способи нарізати застосунок на частини, і вони відповідають на різні запитання.

Підхід layer-based ріже за технічною роллю. Усі компоненти разом, усі хуки разом, усі сервіси разом, усі типи разом. Структура виглядає як components/, hooks/, services/, utils/, types/. Це інтуїтивно, бо повторює те, як ми класифікуємо код у голові: «це в мене компонент, а це утиліта». У малому масштабі таке читається чудово.

Підхід feature-based ріже за призначенням. Усе, що стосується блогу, — компоненти, хуки, типи, стилі — лежить разом, у папці блогу. Усе, що стосується реєстрації, — разом, у папці реєстрації. Структура повторює продуктові фічі, а не технічні ролі.

Різниця стає відчутною не тоді, коли ви пишете код, а тоді, коли ви його змінюєте. Бо зміни майже ніколи не приходять у форматі «зміни всі хуки». Вони приходять у форматі «додай нескінченний скрол на сторінку блогу» або «зроби, щоб після реєстрації показувалася панель підтвердження email». Тобто зміни приходять за фічами, а не за шарами. І якщо ваша структура нарізана шарами, то одна продуктова зміна розповзається по п'яти різних папках: трохи в components, трохи в hooks, трохи в types. Ви тримаєте відкритими п'ять файлів із різних кінців дерева, щоб зробити одну логічно цілісну річ. Це і є ціна layer-based у масштабі — її платять не дисковим простором, а увагою розробника, який щоразу змушений збирати фічу в голові.

Feature-based змінює рівняння: зміна локалізована. Подивіться, як влаштований блог у нашому застосунку:

src/app/blog/
  page.tsx               // серверний компонент, ISR
  layout.tsx             // метадані маршруту
  error.tsx              // error boundary
  const/
    seo.ts
  hooks/
    useInfinitePosts.ts  // логіка нескінченного скролу
    useInfinitePosts.test.tsx
  components/
    BlogView/            // клієнтська обгортка
    PostsList/
    PostCard/
    BlogScene/           // 3D-фон
    BlogBackground/
  [slug]/
    page.tsx             // сторінка окремого поста
    const/seo.ts
    components/

Усе, що означає «блог», — під одним дахом. Хук нескінченного скролу лежить не в глобальних hooks, а в blog/hooks, бо поза блогом він не має жодного сенсу. SEO-константи блогу — в blog/const. Коли треба змінити поведінку списку постів, ви відкриваєте папку блогу й працюєте в межах однієї піддиректорії. Контекст не розпорошується.

Але було б нечесно подавати це як «feature-based перемагає». Точніша картина — не «або-або», а дві осі. Feature-based чудово відповідає на запитання «де код конкретної фічі». І погано — на запитання «де код, спільний для всіх фіч». Бо кнопка, хедер сайту, футер, обгортка для API-запитів — це не фіча. Якщо запхати їх у якусь випадкову папку фічі, інші фічі почнуть тягнути перехресні імпорти, і ви отримаєте ту саму випадкову зв'язність, тільки тепер схована глибше.

Тому в зрілій архітектурі ці дві осі співіснують. Є нарізка за фічами — кожен маршрут у src/app/ зі своїми локальними components, const, hooks. І є один спільний горизонтальний шар — src/shared/, куди винесено все наскрізне. У нашому застосунку це виглядає так:

src/
  app/      // нарізка за фічами (маршрути App Router)
  shared/   // горизонтальний спільний шар
  api/      // клієнти бекенду (теж горизонтальний шар)

Усередині app панує feature-based логіка: кожна сторінка — самодостатній модуль. Усередині shared панує layer-based логіка: там є components, contexts, providers, lib, const. І це нормально, бо shared за визначенням маленький і стабільний. Layer-based погано масштабується саме тоді, коли шарів багато і вони ростуть; на компактному стабільному спільному шарі його недоліки не встигають проявитися, а переваги — групування схожих речей разом — працюють на повну.

Правило, яке з цього випливає, просто сформулювати, але важко дотримуватися: фічу ріжте за фічами, спільне — за шарами, і ніколи не дозволяйте одній фічі імпортувати з іншої. Щойно blog починає імпортувати щось із profile, це сигнал, що спільна річ насправді належить до shared і її треба туди підняти.

3. Спільний UI-шар: словник, а не смітник

Розберімо той самий shared детальніше, бо часто саме він або рятує архітектуру, або стає другим смітником — просто на рівень вище.

Спільний UI-шар має одну функцію: бути словником інтерфейсу. Це набір елементів, на які спираються всі фічі і на які жодна окрема фіча не має виключних прав. Хедер сайту, футер, логотип, кнопка авторизації, банер статусу акаунта — це не частина якоїсь однієї сторінки, це частина візуальної мови всього продукту. Подивіться на склад спільних компонентів:

src/shared/components/
  SiteHeader/
  SiteFooter/
  Logo/
  AuthButton/
  ApprovalBanner/
  index.ts        // реекспорт

Кожен із цих елементів пройшов той самий тест, щоб сюди потрапити: його справді використовують більш ніж в одному місці, і він не несе логіки конкретної фічі. SiteHeader рендериться на всіх публічних маршрутах. AuthButton показує стан авторизації незалежно від того, на якій ви сторінці. Це справді спільне.

Тепер найважливіше — чим спільний шар відрізняється від смітника, хоча структурно вони можуть виглядати однаково (пласкі списки папок із компонентами). Різниця — в напрямку залежностей. Спільний шар нічого не знає про фічі. SiteHeader нічого не імпортує з app/blog чи app/profile. Залежності течуть строго в одному напрямку: фічі залежать від shared, shared не залежить від фіч. Щойно цей закон порушено — щойно якийсь «спільний» компонент тягне імпорт із конкретної сторінки, — він перестає бути спільним. Він став деталлю тієї сторінки, яка випадково лежить не там, де треба.

Цей односпрямований потік — не бюрократія заради бюрократії. Він дає конкретну властивість: спільний шар можна прочитати й зрозуміти ізольовано, не тримаючи в голові жодної фічі. І навпаки — будь-яку фічу можна видалити, і спільний шар це переживе, бо він про неї не знав. Двоспрямовані залежності забирають саме цю властивість: щойно shared починає знати про фічу, видалення фічі ламає shared, а зміна shared може непередбачувано зламати фічу.

Окремо варто звернути увагу на реекспорт в index.ts:

// src/shared/components/index.ts
export { SiteHeader } from './SiteHeader/SiteHeader';
export { SiteFooter } from './SiteFooter/SiteFooter';
export { AuthButton } from './AuthButton/AuthButton';
// ...

Це дрібниця, але важлива. Індекс задає публічний інтерфейс шару. Фічі імпортують from '@/shared/components', а не лізуть напряму у внутрішні файли. Завдяки цьому внутрішню структуру компонента можна реорганізовувати скільки завгодно — перейменовувати файли, розбивати на підкомпоненти — і це не зачепить жодного споживача, доки індекс експортує те саме ім'я. Індекс перетворює папку з розрізненими файлами на модуль із чітко визначеною поверхнею. Без нього кожен внутрішній файл стає потенційною точкою входу, і ви вже не контролюєте, на що саме спираються споживачі.

До спільного шару входять не лише компоненти. Туди ж винесено контексти (UserContext), провайдери (AntdProvider), бібліотечні утиліти (lib/cookies.ts, lib/schemas.ts, lib/site.ts) та константи (const/routes.ts, const/navigation.ts, const/social.tsx). Логіка та сама: це наскрізні речі, потрібні багатьом фічам і не належні жодній окремо. Константи маршрутів — гарний приклад. Замість того щоб розкидати рядки на кшталт «/blog» і «/registration» по всьому коду, є єдине джерело правди:

// src/shared/const/routes.ts
export const ROUTES = {
  HOME: "/",
  ABOUT: "/about",
  BLOG: "/blog",
  REGISTER: "/registration",
  PROFILE: "/profile",
  blogPost: (slug: string) => `/blog/${slug}`,
} as const;

Коли маршрут змінюється, він змінюється в одному місці. Коли його треба знайти — його шукають в одному місці. Коли новий розробник хоче зрозуміти карту застосунку — він читає один файл. Це той самий принцип словника, тільки застосований не до візуальних елементів, а до навігаційної структури.

4. Доменно-орієнтований фронтенд: типи як форма домену

Тут варто відійти на крок від файлів і папок і поговорити про мислення. Доменно-орієнтований підхід — це не про структуру директорій, а про те, щоб код описував бізнес-домен, а не технічні механізми. На бекенді цю ідею обговорюють давно; на фронтенді її часто ігнорують, бо здається, що фронтенд — це «просто рендеринг». Але це оманливе спрощення. У фронтенду є свій домен: користувач, його стан авторизації, пости, профілі, форми та їхні правила.

Найпряміше домен проявляється в коді через типи. Подивіться на тип користувача:

// src/shared/contexts/UserContext.tsx
export type User = {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  profileImageUrl: string | null;
  emailVerified: boolean;
  approvedByAdmin: boolean;
  createdAt: string;
};

Цей тип — не випадковий набір полів. Він кодує доменні правила. emailVerified і approvedByAdmin — два різні етапи життєвого циклу акаунта: спершу користувач підтверджує email, потім адміністратор схвалює доступ. Те, що це два окремі булеві поля, а не один статус, — це твердження про домен: підтверджений email і схвалений доступ незалежні, можна мати одне без іншого. profileImageUrl явно nullable, бо аватар у цьому домені необов'язковий. Тип розповідає історію про те, як працює акаунт, ще до того, як ви відкриєте бодай один компонент.

Доменне мислення проявляється і в тому, як описано операції. Подивіться на коментарі біля функцій API авторизації — вони фіксують доменні правила, а не технічні деталі HTTP:

/**
 * Register a new account. On success the backend emails a verification link and
 * responds with 201 + a generic message — it does NOT establish a session, so
 * the caller must not treat this as a login. Returns 409 when the email is
 * already registered.
 */
export function register(payload: RegisterPayload): Promise<PasswordResetMessage>{
  return API.post<PasswordResetMessage>('/auth/register', payload);
}

Тут зафіксовано доменне правило: реєстрація не створює сесію. Із типів це не очевидно — обидві операції повертають проміс. Але для коректності це критично, бо споживач, який сприйме реєстрацію як логін, помилиться. І цю помилку відвернено в коді сабміту, який поважає правило:

// src/app/registration/components/RegisterForm/submitHandler.ts
await register({ /* ... */ });
// Registration only triggers a verification email; it does not set a
// session, so we switch to a confirmation panel rather than redirecting
// into the authenticated area.
setIsSubmitted(true);

Замість редиректу в авторизовану зону — перемикання на панель підтвердження. UI-логіка випливає безпосередньо з доменного правила. Це і є доменно-орієнтований фронтенд у дії: поведінку UI не вигадують із міркувань зручності, вона — наслідок того, як насправді працює домен.

Ще одне місце, де домен формує код, — керування станом сесії. Подивіться, як UserProvider вирішує, чи взагалі звертатися до бекенду:

const [loading, setLoading] = useState(
  initialUser === null && hasCookie(AUTH_COOKIES.SESSION_FLAG)
);

useEffect(() => {
  if (initialUser === null && hasCookie(AUTH_COOKIES.SESSION_FLAG)) {
    void fetchUser();
  }
}, [fetchUser, initialUser]);

Тут закодовано доменне знання: запитувати профіль має сенс лише тоді, коли є ознака сесії. Access- і refresh-куки — HttpOnly, JS їх не бачить, але є окремий видимий для JS прапорець has_session, який бекенд встановлює під час логіну. Фронтенд не смикає наосліп /auth/me для кожного гостя — спершу він перевіряє прапорець. Це рішення народжується не з технічної зручності, а з розуміння того, як влаштована авторизація в цьому домені: сесія має спостережуваний маркер на клієнті, і вся клієнтська логіка побудована довкола нього.

Сенс цього розділу не в тому, щоб завести якісь нові папки. Сенс у тому, що масштабованість — це не лише про файлову структуру, а й про те, наскільки точно код відображає реальність, якій він служить. Коли типи й функції говорять мовою домену, новий розробник вивчає бізнес-логіку, читаючи код. Коли вони говорять мовою механізмів — він вивчає лише механізми, а домен лишається в чиїйсь голові й зникає разом із цією людиною, коли вона йде.

5. Організація API-клієнтів: транспорт окремо, домен окремо

Робота з бекендом — це місце, де архітектура або тримається, або тихо розвалюється через тисячу дрібних дублювань. Найпоширеніша помилка — викликати fetch просто всередині компонентів. Кожен такий виклик тягне за собою однакове риштовання: базовий URL, заголовки, обробку помилок, серіалізацію тіла, логіку повторних спроб. Спочатку це кілька рядків, потім ці кілька рядків розмножуються на тридцять компонентів із дрібними варіаціями, і коли треба змінити одну спільну річ — наприклад, додати заголовок до всіх запитів, — ви правите тридцять місць і про одне забуваєте.

Рішення — розділити транспорт і домен на два окремі шари. У нашому застосунку це папка src/api/ з такою структурою:

src/api/
  index.ts      // транспортний шар: API.{get,post,put,...}, ApiError
  auth.ts       // доменні операції авторизації
  posts.ts      // доменні операції блогу
  profile.ts    // доменні операції профілю

Транспортний шар — це index.ts. Він знає все про те, як спілкуватися з бекендом, і нічого про те, що саме передається. Усі наскрізні питання тут вирішено раз і назавжди:

const request = async <T>(path: string, options: RequestOptions = {}): Promise<T> => {
  const { body, params, headers, _isRetry, ...rest } = options;

  const finalHeaders = new Headers(headers);
  finalHeaders.set('X-Timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);

  let serializedBody: BodyInit | undefined;
  if (body !== undefined) {
    if (body instanceof FormData || body instanceof Blob || typeof body === 'string') {
      serializedBody = body as BodyInit;
    } else {
      serializedBody = JSON.stringify(body);
      if (!finalHeaders.has('Content-Type')) {
        finalHeaders.set('Content-Type', 'application/json');
      }
    }
  }

  const response = await fetch(buildUrl(path, params), {
    ...rest,
    headers: finalHeaders,
    credentials: 'include',
    body: serializedBody,
  });

  if (response.status === 401 && !_isRetry && hasCookie(AUTH_COOKIES.SESSION_FLAG)) {
    try {
      await refreshTokens();
      return request<T>(path, { ...options, _isRetry: true });
    } catch (e) {
      throw e;
    }
  }

  const contentType = response.headers.get('Content-Type') || '';
  const isJson = contentType.includes('application/json');
  const data = isJson ? await response.json().catch(() => null) : await response.text();

  if (!response.ok) {
    throw new ApiError(response.statusText || 'Request failed', response.status, data);
  }

  return data as T;
};

Подивіться, скільки наскрізних рішень зосереджено тут в одному місці. До кожного запиту додається часовий пояс. Куки завжди йдуть разом із запитом (credentials: 'include'). Тіло серіалізується з розумом — FormData і Blob передаються як є, об'єкти стають JSON із правильним Content-Type. Помилки нормалізуються в єдиний тип ApiError, що несе статус і тіло відповіді. І найцікавіше — логіка повтору на 401: якщо є прапорець сесії, транспорт сам пробує оновити токени й повторити запит рівно один раз (захист від нескінченних циклів через прапорець _isRetry). Жоден компонент про це не знає і не повинен знати. Для компонента запит або повертає дані, або кидає ApiError — і все.

Над транспортом розташований доменний шар. Це тонкі типізовані функції, по одному файлу на область. Вони не знають, як працює fetch, — вони знають лише, який ендпоінт відповідає якій доменній операції і якої форми буде відповідь:

// src/api/posts.ts
export type Post = {
  id: string;
  slug: string;
  title: string;
  // ...
};

export type PostsPage = {
  items: Post[];
  nextCursor: string | null;
};

export function getPosts(params: GetPostsParams = {}): Promise<PostsPage>{
  const { cursor, limit } = params;
  return API.get<PostsPage>('/posts', {
    params: { cursor: cursor ?? undefined, limit: limit ?? undefined },
  });
}

export function getPostBySlug(slug: string): Promise<Post>{
  return API.get<Post>(`/posts/${slug}`);
}

Таке розділення дає кілька властивостей, які проявляються саме в масштабі. По-перше, типи відповідей живуть поруч із функціями, що їх отримують, — Post і getPosts в одному файлі, і коли бекенд змінює форму поста, є одне очевидне місце, де це відобразити. По-друге, компоненти звільняються від транспортної механіки: форма реєстрації викликає register(payload), а не конструює fetch із заголовками. По-третє, доменні файли читаються як зміст API — відкривши auth.ts, ви бачите повний перелік операцій авторизації з коментарями про доменні правила, без жодного транспортного шуму.

Окремо варто відзначити, як транспорт розв'язує проблему середовища виконання. Той самий request працює і на сервері, і в браузері, бо визначає контекст під час запуску:

const isServer = typeof window === 'undefined';
const SERVER_API_ORIGIN = (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '');

const base = isServer ? SERVER_API_ORIGIN : API_URL;

У браузері запити йдуть на відносний /api/... і проходять через проксі Next (rewrite у next.config.ts). На сервері, де відносного шляху не існує, той самий код звертається безпосередньо до повного origin бекенду. Компонент, що викликає getPosts, нічого про це не знає — і в цьому вся суть. Деталь «де ми зараз виконуємося» інкапсульована в транспорті й не просочується в кожен виклик. Це дає змогу переносити ту саму доменну функцію між серверними й клієнтськими компонентами без жодних змін — що й підводить нас безпосередньо до наступної теми.

6. Розділення серверних і клієнтських компонентів

App Router приніс на фронтенд розрізнення, якого раніше там явно не було: код, що виконується на сервері під час рендерингу, і код, що виконується в браузері. За замовчуванням в App Router компонент є серверним. Клієнтським він стає лише з директивою 'use client' на початку файлу. Це інверсія звичок — раніше весь React був клієнтським, тепер клієнтськість треба запитувати явно. І від того, де ви проведете цю лінію, залежать продуктивність, SEO та складність застосунку.

Серверні компоненти виконуються на сервері, не потрапляють у клієнтський бандл і можуть напряму звертатися до даних. Клієнтські компоненти потрапляють у бандл, гідруються в браузері, і лише вони можуть тримати стан, ефекти, обробники подій та звертатися до браузерних API. Найпоширеніша помилка — за звичкою робити все клієнтським, втрачаючи всі переваги серверного рендерингу. Протилежна помилка — намагатися зробити серверним те, що принципово потребує браузера, і наштовхуватися на падіння через window.

Правильний патерн — тримати серверний компонент якомога вище і якомога тоншим, а клієнтськість опускати якомога нижче, точно туди, де вона справді потрібна. Подивіться на сторінку блогу:

// src/app/blog/page.tsx
import { getPosts } from "@/api/posts";
import { BlogView } from "./components/BlogView/BlogView";

// ISR: prerender the list as static HTML (so crawlers see real posts/links),
// refreshed periodically.
export const revalidate = 300;

export default async function BlogPage() {
  let initialData: InitialPostsData | undefined;
  try {
    const page = await getPosts({ limit: POSTS_PAGE_SIZE });
    initialData = { items: page.items, nextCursor: page.nextCursor };
  } catch {
    initialData = undefined;
  }

  return <BlogView initialData={initialData} />;
}

Це серверний компонент — зверніть увагу, що він async і напряму робить await даних, без жодного useEffect. Він завантажує першу порцію постів під час рендерингу на сервері; через revalidate = 300 Next перетворює це на ISR — сторінка попередньо рендериться як статичний HTML і оновлюється раз на п'ять хвилин. Це означає, що пошукові краулери отримують справжні пости й справжні посилання прямо в HTML, без виконання JS. Для SEO це принципово.

А далі вниз — те, що потребує браузера, передається в клієнтський компонент BlogView разом із уже завантаженими початковими даними через проп initialData. BlogView керує нескінченним скролом, реагує на події прокручування, тримає стан списку — речі, неможливі без браузера. Межу проведено точно: серверний компонент робить саме те, що найкраще робити на сервері (початкове завантаження даних, статичний HTML), а клієнтський — саме те, що інакше неможливо (інтерактивність).

Зверніть увагу також на обробку помилок у серверному компоненті. Збій запиту не викидається назовні, а поглинається в undefined. Коментар пояснює чому: сторінка попередньо рендериться під час білду, і викинута помилка зламала б увесь білд. Недоступний бекенд не повинен блокувати деплой. Це теж частина мислення про серверні компоненти — у них інша ціна збою, бо вони виконуються під час білду, а не під час перегляду.

Окремий випадок — код, якому не просто потрібен браузер, а який принципово несумісний із сервером. 3D-сцени на three.js звертаються до window і WebGL, яких на сервері не існує. Такий код навіть не можна спробувати відрендерити на сервері, тож його завантажують динамічно з вимкненим SSR:

// CylinderScene завантажується динамічно, щоб ніколи не виконуватися на сервері
const CylinderScene = dynamic(
  () => import('./components/CylinderScene/CylinderScene'),
  { ssr: false }
);

Тут межу між сервером і клієнтом проведено не заради оптимізації, а заради коректності: спроба SSR для цього компонента просто впала б на першому ж зверненні до window.

Загальне правило, що випливає з усього цього: тягніть дані на сервері й передавайте їх униз готовими, а інтерактивність опускайте в клієнтські листки дерева. Серверні й клієнтські компоненти дають свої переваги лише тоді, коли між ними проведено осмислену межу, а не випадкову лінію. Кожен 'use client', поставлений зависоко, затягує в бандл усе піддерево; кожен, поставлений на правильній глибині, ізолює клієнтську вагу там, де без неї не обійтися.

7. Керування залежностями між модулями

Усі попередні теми сходяться в одній точці — напрямку стрілок. У вас можуть бути ідеальні папки, бездоганний спільний шар і чисті API-клієнти, але якщо залежності між модулями течуть хаотично, архітектура однаково перетвориться на тугий вузол, де неможливо змінити щось одне, не зачепивши все.

Накреслімо граф залежностей нашого застосунку. У ньому чіткі шари, і стрілки вказують лише вниз:

    features (src/app/*)
        │
        ├──────────────┐
        ▼              ▼
 shared (UI,      api (domain:
 contexts,        auth, posts,
 const, lib)      profile)
        │              │
        │              ▼
        │         api/index (transport)
        │              │
        └──────┬───────┘
               ▼
        lib (cookies, schemas, site)

Фічі залежать від shared і api. Доменні функції API залежать від транспорту. Транспорт залежить від низькорівневих утиліт. І все. Те, чого тут немає, так само важливе, як те, що є. Немає стрілок угору: shared не залежить від фіч, транспорт не залежить від доменних функцій. Немає бічних стрілок між фічами: blog не залежить від profile, registration не залежить від login. Граф ациклічний і односпрямований.

Чому це критично саме для масштабованості? Бо вартість зміни модуля дорівнює кількості того, що від цього модуля залежить. Коли залежності течуть униз і не утворюють циклів, кожен шар можна змінювати без страху за шари під ним. Можна переписати весь транспорт index.ts — додати кешування, замінити бібліотеку запитів, змінити логіку повторів — і жодна доменна функція цього не помітить, бо вони залежать від інтерфейсу API.get/post, а не від його нутрощів. Можна переписати будь-яку фічу — і більше нічого не зламається, бо від фіч ніхто не залежить.

А тепер уявіть протилежне — що буває, коли дисципліни немає. shared/SiteHeader починає імпортувати щось з app/profile, бо там лежить зручний хелпер. Тепер shared залежить від фічі. app/blog імпортує компонент з app/profile, бо «майже підходить». Тепер дві фічі зв'язані. Транспорт починає знати про конкретну доменну модель. З'являються цикли: A залежить від B, B залежить від A. У такому графі безпечної зміни не існує. Торкнутися будь-якого файлу — це рулетка, бо наслідки розходяться в усі боки одночасно. Це і є та сама «легасі-маса», з якою стикався кожен: справа не в поганій якості окремого коду, а в хаотичному графі залежностей, де всі знають про всіх.

Слабко зв'язана модульна архітектура — це не про естетику. Це про конкретну економічну властивість: вартість змін лишається локальною й передбачуваною, хоч би як ріс проєкт. Протягом статті ми вже бачили кілька механізмів, які підтримують цю властивість, і тепер видно, що всі вони служать одній меті.

Реекспорти (index.ts у модулі) задають вузьку публічну поверхню модуля, тож споживачі залежать від наміру, а не від випадкової внутрішньої структури. Спільний шар приймає односпрямований потік як закон, тож його можна читати й змінювати ізольовано. Транспортний шар ховає механіку запитів за стабільним інтерфейсом, тож доменні функції не прив'язані до реалізації. Папки тримають своє при собі й не тягнуться до сусідів, тож фічу можна видалити одним рухом. Єдине джерело правди для маршрутів і навігації усуває розкидані дублікати рядків, тож зв'язність через «магічні константи» взагалі не виникає.

Кожен із цих прийомів окремо виглядає дрібницею, майже справою смаку. Разом вони формують архітектуру, де граф залежностей лишається деревом, а не павутиною. І саме це — а не вибір конкретного фреймворку, бібліотеки стану чи рішення для стилізації — визначає, чи доживе проєкт до другого року в стані, коли вносити зміни все ще приємно.

Висновок

Якби все довелося звести до однієї думки, вона була б такою: масштабованість фронтенду — це властивість зв'язків між шматками коду, а не самого коду. Папка components стає смітником не через ліниву команду, а через те, що структура не питає «хто це використовує?». Feature-based перемагає не тому, що це модно, а тому, що зміни приходять за фічами. Спільний шар рятує рівно доти, доки його залежності течуть в одному напрямку. API-клієнти тримаються, коли транспорт відокремлено від домену. Серверні й клієнтські компоненти дають свої переваги лише тоді, коли між ними проведено осмислену межу, а не випадкову лінію.

Архітектура — це не набір жорстких правил, яких дотримуються заради самого процесу. Це інструмент для зменшення когнітивного навантаження на розробника. Коли кожен у команді точно знає, куди покласти новий файл, де шукати баг і які наслідки матиме видалення конкретної папки, проєкт перестає бути джерелом стресу.

Масштабованість фронтенду вимірюється не рядками коду і навіть не швидкістю білду в CI. Вона вимірюється часом, за який вдається занурити в проєкт нового колегу, і легкістю, з якою система поглинає неминучі продуктові зміни через рік-два після запуску. Будуйте застосунок як набір незалежних, передбачуваних і типізованих модулів — і ваш фронтенд ніколи не перетвориться на цифровий смітник.