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() лише тому, що на нього немає посилання в інтерфейсі.
Гідратація - процес, коли 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 дає змогу збирати ці помилки в моніторинг на продакшені.
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 виконується окремо від рендеру, не повинен покладатися на спільні модулі чи глобальний стан; повна перевірка прав з запитами до бази тут дорога.
Рекомендований підхід:
- proxy - оптимістичне перенаправлення неавтентифікованих користувачів (UX і зменшення навантаження);
- шар доступу до даних з перевіркою сесії й прав - у кожному серверному компоненті, дії, обробнику маршруту.
Аналог у Laravel - різниця між глобальним middleware і authorize() у контролері чи політиках: перший фільтрує, другий справді захищає ресурс.
За замовчуванням кожен перехід в 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: часткові перезавантаження