Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Senior: питання на співбесіді з теми «Next.js, Inertia й маршрутизація»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Серверна функція з 'use server' виглядає як звичайна функція, яку викликає компонент. Насправді Next.js створює для неї ендпойнт, доступний прямим POST-запитом - не лише з вашого інтерфейсу.

'use server';

export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } });   // будь-хто видалить будь-який пост
}

Що Next.js робить для захисту сам:

  • зашифровані недетерміновані ідентифікатори дій, що змінюються між збірками;
  • видалення невикористаних дій з клієнтського бандла;
  • шифрування змінних із замикань (значень, захоплених функцією, оголошеною всередині компонента) - вони ходять клієнтом і назад, але в зашифрованому вигляді. Ключ генерується на кожну збірку (для кількох серверів - спільний NEXT_SERVER_ACTIONS_ENCRYPTION_KEY);
  • перевірка джерела: заголовок Origin порівнюється з Host - захист від CSRF; для проксі-конфігурацій - serverActions.allowedOrigins.

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

Правильно:

'use server';

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  const post = await db.post.findUnique({ where: { id: postId } });
  if (!post || post.authorId !== session.user.id) throw new Error('Forbidden');   // захист від IDOR

  await db.post.delete({ where: { id: postId } });
  revalidatePath('/posts');
}

Типові помилки:

  • перевірка лише на сторінці: сторінка доступна лише адміну, тож «дії на ній безпечні». Ні - перевірка доступу до сторінки не поширюється на дії, оголошені в ній;
  • довіра аргументам: postId, ціна, роль приходять від клієнта й можуть бути будь-якими. Валідація (Zod) обов'язкова, як для будь-якого API;
  • повернення зайвого: повернене значення потрапляє в браузер - лише потрібні поля;
  • покладатися на шифрування замикань для секретів - документація не радить; секрети не повинні опинитися в замиканні взагалі.

Рекомендований підхід - шар доступу до даних: модуль з import 'server-only', де зібрано перевірки прав і запити до бази. Серверні дії лишаються тонкими й викликають його. Так перевірки не залежать від того, звідки викликано код.

Аналогія з Laravel: серверна дія - це маршрут контролера. Ніхто не робить контролер без authorize() лише тому, що на нього немає посилання в інтерфейсі.

Докладніше в документації: Next.js: безпека даних

Гідратація - процес, коли React у браузері «підхоплює» HTML, згенерований на сервері: прив'язує обробники подій і стан до вже наявних елементів. React очікує, що перший рендер у браузері дасть точно такий самий результат, що й на сервері. Розбіжність - помилка гідратації.

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

Найчастіші причини:

1. Значення, різні на сервері й клієнті:

<p>Згенеровано: {new Date().toLocaleTimeString()}</p>   // інший час
<p>{Math.random()}</p>
<p>{price.toLocaleString()}</p>                           // інша локаль чи часовий пояс

2. Перевірки середовища в рендері:

{typeof window !== 'undefined' && <MobileMenu />}   // на сервері немає, на клієнті є

3. Дані з браузера: localStorage, розмір вікна, тема з налаштувань системи - на сервері їх немає.

4. Некоректна вкладеність HTML: <p> всередині <p>, <div> у <p>, <a> у <a> - браузер «виправляє» розмітку при розборі, і дерево відрізняється від очікуваного.

5. Розширення браузера, що змінюють DOM до гідратації (перекладачі, менеджери паролів).

Як виправляти:

  • значення лише для клієнта - після монтування:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);

return <p>Згенеровано: {mounted ? new Date().toLocaleTimeString() : '...'}</p>;

Перший рендер збігається з сервером, а одразу після гідратації - другий з клієнтськими даними. У Next.js - також dynamic(() => import(...), { ssr: false }) для компонентів, яким сервер узагалі не потрібен;

  • дати й числа - форматувати з явною локаллю й часовим поясом однаково на обох боках (Intl.DateTimeFormat('uk', { timeZone: 'Europe/Kyiv' })) або передавати вже відформатований рядок із сервера;
  • ідентифікатори - useId, а не лічильник чи Math.random();
  • неминуча розбіжність одного елемента (позначка часу) - suppressHydrationWarning на ньому. Діє лише на один рівень і не виправляє вміст - це запасний вихід, а не рішення;
  • тема без мигання - встановлювати клас на <html> вбудованим скриптом до гідратації і suppressHydrationWarning на <html>.

Діагностика: у режимі розробки React показує, у якому елементі й чим відрізнялися дерева; onRecoverableError у hydrateRoot дає змогу збирати ці помилки в моніторинг на продакшені.

Докладніше в документації: hydrateRoot

proxy.ts (до Next.js 16 - middleware.ts) - код, що виконується до обробки запиту маршрутом: може переписати URL, перенаправити, змінити заголовки чи cookies або відповісти одразу.

// proxy.ts у корені проєкту
import { NextResponse, type NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  if (!request.cookies.has('session')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

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

Чому перейменували. Назва «middleware» асоціювалася з middleware Express чи Laravel - ланцюжком обробників навколо запиту, на який кладуть усе підряд. Next.js прямо радить не покладатися на цей механізм, якщо є інші варіанти, і перейменував його на «proxy», щоб підкреслити роль - мережевий рубіж перед застосунком (може навіть виконуватися на CDN, окремо від рендеру). Перехід - кодмод middleware-to-proxy; у Next.js 16 proxy за замовчуванням працює на Node.js runtime.

Для чого proxy доречний:

  • швидкі перенаправлення й переписування (локалізація за мовою, A/B-тести, старі URL);
  • оптимістичні перевірки: чи є взагалі cookie сесії - перенаправити на вхід, не рендерячи сторінку;
  • заголовки (безпека, CORS для обробників маршрутів), логування.

Чому не будувати на ньому авторизацію:

  • proxy не замінює перевірок у даних. Сторінка, серверна дія чи обробник маршруту можуть бути викликані способами, які matcher не охоплює (зміна шляху, нові маршрути, прямий виклик серверної дії). Перевірка має бути там, де читаються й змінюються дані;
  • у 2025 році була вразливість (CVE-2025-29927), що дозволяла обійти middleware спеціальним заголовком у деяких самостійно розгорнутих версіях. Застосунки, де авторизація була лише в middleware, виявилися відкритими. Багаторівневий захист таку помилку пережив би;
  • обмежений доступ до контексту: proxy виконується окремо від рендеру, не повинен покладатися на спільні модулі чи глобальний стан; повна перевірка прав з запитами до бази тут дорога.

Рекомендований підхід:

  1. proxy - оптимістичне перенаправлення неавтентифікованих користувачів (UX і зменшення навантаження);
  2. шар доступу до даних з перевіркою сесії й прав - у кожному серверному компоненті, дії, обробнику маршруту.

Аналог у Laravel - різниця між глобальним middleware і authorize() у контролері чи політиках: перший фільтрує, другий справді захищає ресурс.

Докладніше в документації: Next.js: proxy.js

За замовчуванням кожен перехід в Inertia - це запит до контролера Laravel, який обчислює всі props сторінки. Для сторінки зі списком і фільтрами зміна одного фільтра означала б заново обчислити й довідники, і статистику, і все інше.

Часткове перезавантаження - запит лише потрібних props для поточної сторінки:

import { router } from '@inertiajs/react';

function CompanyFilter({ companies }) {
  return (
    <select
      onChange={(e) =>
        router.reload({
          data: { company: e.target.value },
          only: ['users'],          // повернути лише список користувачів
        })
      }
    >
      {companies.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
    </select>
  );
}
  • only: [...] - які props повернути;
  • except: [...] - які пропустити;
  • router.reload() - скорочення для візиту на поточну URL; часткові перезавантаження працюють лише для тієї самої сторінки (того самого компонента).

Чому замикання на сервері обов'язкові. Сервер повертає лише запитані props, але щоб їх не обчислювати, значення мають бути ледачими:

return Inertia::render('Users/Index', [
    'users' => fn () => User::query()->filter(request()->only('company'))->paginate(20),
    'companies' => fn () => Company::orderBy('name')->get(['id', 'name']),
    'stats' => fn () => $statsService->heavyCalculation(),
]);

Без fn () => запит до companies і важкий stats виконуються на кожному запиті, навіть якщо їх потім викинуть з відповіді.

Інші типи props Inertia (v3):

  • Inertia::optional(fn () => ...) - не надсилається при звичайних переходах, лише коли явно запитаний через only (раніше Inertia::lazy, видалено у v3);
  • Inertia::defer(fn () => ...) - сторінка рендериться без цього prop, а він довантажується окремим запитом одразу після показу (повільна статистика);
  • Inertia::merge(...) - нові дані дописуються до наявних, а не замінюють їх (нескінченний скрол, «Завантажити ще»);
  • Inertia::once(...) - обчислюється один раз і запам'ятовується клієнтом між переходами.

Пастки:

  • назва prop в only має точно збігатися з ключем у контролері - помилка в назві мовчки дає «нічого не оновилося»;
  • спільні дані (з HandleInertiaRequests) теж підпадають під only/except - для них правило замикань те саме;
  • стан сторінки: часткове перезавантаження за замовчуванням зберігає стан компонента (preserveState) - фільтри й введені значення не скидаються.

Докладніше в документації: Inertia: часткові перезавантаження