Пошук уроків, статей та іншого контенту
Обмежите доступ до сторінок і даних для неавторизованих користувачів у маршрутах Next.js.
Захищений маршрут — це сторінка або API-ресурс, доступний лише користувачам, які пройшли автентифікацію.
У Next.js потрібно захищати окремо:
сторінки, які бачить користувач;
серверні дані, які повертаються з Route Handler;
серверні дії та інші точки входу до приватної логіки.
Приховати посилання на сторінку недостатньо. Неавторизований користувач усе одно може безпосередньо відкрити URL або виконати HTTP-запит до API.
У App Router перевірку можна виконувати на сервері:
у серверному компоненті сторінки;
у layout.tsx, спільному для групи захищених маршрутів;
у Route Handler;
у серверній функції, яка використовується кількома маршрутами.
Найкраща практика — винести перевірку в одну функцію, наприклад getCurrentUser або requireUser. Тоді всі маршрути використовують однакові правила.
Перевірка в клієнтському компоненті за допомогою useEffect не є достатнім захистом. Такий компонент може приховати інтерфейс, але серверні дані вже могли бути надіслані браузеру.
Зазвичай браузер надсилає ідентифікатор сесії в cookie. Сервер:
читає cookie;
перевіряє сесію в базі даних або у сховищі сесій;
отримує користувача;
дозволяє або забороняє доступ.
У прикладі нижче значення cookie використовується як демонстраційний ідентифікатор користувача. Це спрощена навчальна модель. У реальному застосунку не можна вважати довільне значення cookie валідною сесією — його потрібно перевіряти на сервері.
Створимо файл app/lib/auth.ts:
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export type User = {
id: string;
name: string;
};
export async function getCurrentUser(): Promise<User | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session")?.value;
if (!sessionId) {
return null;
}
// У реальному застосунку тут потрібно перевірити сесію у сховищі.
if (sessionId !== "demo-user") {
return null;
}
return {
id: "user-1",
name: "Demo User",
};
}
export async function requireUser(): Promise<User> {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return user;
}cookies() викликається на сервері та читає cookie поточного HTTP-запиту.
Функція getCurrentUser повертає:
об’єкт користувача, якщо сесія валідна;
null, якщо користувач не авторизований.
Функція requireUser використовує redirect. Якщо користувача немає, Next.js припиняє виконання серверного компонента та перенаправляє його на /login.
Якщо кілька сторінок повинні мати однаковий захист, перевірку зручно розмістити в спільному layout.
Структура файлів може виглядати так:
app/
├── (protected)/
│ ├── layout.tsx
│ └── dashboard/
│ └── page.tsx
├── login/
│ └── page.tsx
└── lib/
└── auth.tsДужки в назві (protected) створюють route group. Назва групи не входить до URL, тому сторінка app/(protected)/dashboard/page.tsx буде доступна за адресою /dashboard.
Файл app/(protected)/layout.tsx:
import type { ReactNode } from "react";
import { requireUser } from "@/app/lib/auth";
type ProtectedLayoutProps = {
children: ReactNode;
};
export default async function ProtectedLayout({
children,
}: ProtectedLayoutProps) {
const user = await requireUser();
return (
<section>
<header>
<p>Ви увійшли як {user.name}</p>
</header>
{children}
</section>
);
}Тепер кожна сторінка всередині (protected) спочатку проходить перевірку. Якщо користувач не має валідної сесії, він не побачить приватну сторінку.
Приклад app/(protected)/dashboard/page.tsx:
import { requireUser } from "@/app/lib/auth";
export default async function DashboardPage() {
const user = await requireUser();
return (
<main>
<h1>Панель користувача</h1>
<p>Вітаємо, {user.name}!</p>
<p>Ідентифікатор: {user.id}</p>
</main>
);
}Перевірка в layout захищає сторінку від звичайного переходу, а перевірка в самій серверній функції або сторінці може бути корисною як додатковий захист і як явна залежність компонента.
Захист сторінки не захищає автоматично API. Якщо сторінка використовує /api/profile, цей Route Handler також повинен перевіряти користувача.
Створимо app/api/profile/route.ts:
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/app/lib/auth";
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: "Потрібна авторизація" },
{ status: 401 }
);
}
return NextResponse.json({
id: user.id,
name: user.name,
email: "user@example.com",
});
}Для API зазвичай не використовують redirect. Клієнту потрібно повернути HTTP-статус:
401 Unauthorized — користувач не автентифікований;
403 Forbidden — користувач автентифікований, але не має потрібних прав.
У цьому уроці перевіряється лише факт авторизації, тому використовується статус 401.
Щоб перевірити приклад локально, можна створити демонстраційні Route Handler для встановлення та видалення cookie.
Цей приклад не реалізує справжню форму входу. Він лише імітує успішну автентифікацію, щоб продемонструвати захищені маршрути.
Файл app/api/login/route.ts:
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const response = NextResponse.redirect(new URL("/dashboard", request.url));
response.cookies.set("session", "demo-user", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60,
});
return response;
}Файл app/api/logout/route.ts:
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.set("session", "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
return response;
}Cookie має важливі параметри:
httpOnly забороняє читати cookie з JavaScript у браузері;
sameSite: "lax" зменшує ризик небажаних міжсайтових запитів;
secure у production дозволяє надсилати cookie лише через HTTPS;
path: "/" робить cookie доступною для всього застосунку;
maxAge визначає час життя cookie в секундах.
Сторінка входу app/login/page.tsx:
export default function LoginPage() {
return (
<main>
<h1>Вхід</h1>
<form action="/api/login" method="post">
<button type="submit">Увійти як демонстраційний користувач</button>
</form>
</main>
);
}До захищеної сторінки можна додати форму виходу:
export default function DashboardPage() {
return (
<main>
<h1>Панель користувача</h1>
<form action="/api/logout" method="post">
<button type="submit">Вийти</button>
</form>
</main>
);
}Після натискання кнопки входу сервер встановить cookie та перенаправить користувача на /dashboard. Після виходу cookie буде видалено, а користувач повернеться на /login.
Якщо дані завантажуються безпосередньо в серверному компоненті, перевірку потрібно виконати до отримання приватних даних.
import { requireUser } from "@/app/lib/auth";
async function getPrivateOrders(userId: string) {
// Тут може бути запит до бази даних.
return [
{ id: "order-1", ownerId: userId, total: 120 },
];
}
export default async function OrdersPage() {
const user = await requireUser();
const orders = await getPrivateOrders(user.id);
return (
<main>
<h1>Мої замовлення</h1>
<ul>
{orders.map((order) => (
<li key={order.id}>
{order.id}: {order.total} грн
</li>
))}
</ul>
</main>
);
}Важливо перевіряти не лише факт входу, а й належність конкретного ресурсу користувачу. Наприклад, отримуючи замовлення за ідентифікатором, сервер повинен перевірити, що це замовлення належить поточному користувачу.
const user = await requireUser();
const order = await getOrder(orderId);
if (!order || order.ownerId !== user.id) {
// Не повідомляємо приватні дані про чужий ресурс.
return new Response("Not found", { status: 404 });
}Клієнтський компонент може використовувати приватний API, але API все одно має виконувати власну перевірку.
"use client";
import { useEffect, useState } from "react";
type Profile = {
id: string;
name: string;
email: string;
};
export default function Profile() {
const [profile, setProfile] = useState<Profile | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadProfile() {
const response = await fetch("/api/profile");
if (!response.ok) {
setError("Не вдалося завантажити профіль");
return;
}
const data: Profile = await response.json();
setProfile(data);
}
loadProfile();
}, []);
if (error) {
return <p>{error}</p>;
}
if (!profile) {
return <p>Завантаження...</p>;
}
return (
<section>
<h1>{profile.name}</h1>
<p>{profile.email}</p>
</section>
);
}Навіть якщо користувач змінить цей компонент або безпосередньо викличе /api/profile, Route Handler не поверне дані без сесії.
Ці поняття пов’язані, але не однакові:
Автентифікація відповідає на питання: «Хто цей користувач?»
Авторизація відповідає на питання: «Що цьому користувачу дозволено?»
Наприклад, requireUser виконує автентифікацію. Для перевірки ролі можна додати окрему функцію:
import { NextResponse } from "next/server";
import { requireUser } from "@/app/lib/auth";
export async function DELETE() {
const user = await requireUser();
if (user.id !== "admin-1") {
return NextResponse.json(
{ error: "Недостатньо прав" },
{ status: 403 }
);
}
return NextResponse.json({ deleted: true });
}У реальному застосунку роль або дозволи повинні надходити з перевіреного джерела, наприклад із бази даних або валідної серверної сесії.
Погано:
"use client";
if (!user) {
return null;
}Такий код лише змінює відображення. Він не захищає Route Handler, серверну сторінку або базу даних.
Перевірка повинна виконуватися на сервері.
Не можна довіряти значенню cookie лише тому, що воно існує. Користувач може змінити cookie вручну.
Сервер повинен:
перевірити підпис токена, якщо використовується підписаний токен;
або знайти сесію за ідентифікатором у сховищі;
перевірити термін дії сесії;
отримати користувача з перевіреного джерела.
Користувач може не бачити сторінку, але все ще викликати API безпосередньо. Кожен приватний Route Handler повинен сам перевіряти сесію.
Для неавторизованого API-запиту використовуйте 401. Якщо користувач увійшов, але не має дозволу, використовуйте 403.
Якщо серверний компонент передає приватні дані клієнтському компоненту, ці дані вже доступні браузеру. Перед передаванням потрібно переконатися, що користувач має право їх отримати.
Група (protected) лише організовує файли та не додає безпеки сама по собі. Захист створює серверна перевірка в layout, сторінці або Route Handler.
Захищайте маршрути на сервері, а не лише в клієнтському інтерфейсі.
Винесіть отримання поточного користувача в спільну функцію.
Використовуйте redirect("/login") для захищених сторінок.
У приватних API повертайте 401, якщо користувач не автентифікований.
Перевіряйте не лише наявність сесії, а й права доступу до конкретних даних.
Не довіряйте cookie без серверної валідації.
Захищайте кожну точку доступу до приватних даних, навіть якщо сторінка вже закрита.