Next.js Middleware: від простого захисту до гнучкої системи доступу
Привіт! Мене звати Костянтин Клюхін, я Frontend-розробник у бізнесі FORMA, що входить до групи компаній Universe Group. За понад три роки у веброзробці я пройшов шлях від класичного фронтенду на React до повноцінного фулстеку, заглибившись у створення складних рішень та оптимізацію продуктивності застосунків.
Окрему увагу приділяв Next.js: будував архітектуру проєктів, проводив лекції для колег і допомагав розв’язувати нетривіальні технічні задачі.
Сьогодні хочу поділитися практикою, яка суттєво спростила роботу як мені, так і команді. Вона дозволяє швидко й гнучко змінювати логіку доступу до роутів, покращує UX і легко адаптується під нестандартні вимоги замовників.
Що таке middleware в Next.js
Давайте розберемо основи middleware в Next.js:
- Middleware — це просто функція.
- Middleware виконується на edge-серверах, ближче до користувача. Тут потрібно зазначити, що це працює при хостингу на vercel і там це може потребувати додаткових грошових витрат, а в інших хостинг-сервісах потребує додаткового налаштування.
- Виконується для вказаних маршрутів — ви вирішуєте, для яких маршрутів виконується middleware та задаєте це за допомогою конфігурації.
- Виконується до завантаження сторінки — middleware виконується до того, як користувач отримує сторінку.
- Приймає запит — він приймає об’єкт GET-запиту на ресурс як параметр.
- Не впливає на спосіб рендерингу — найголовніше, middleware не впливає на вид рендерингу сторінки.
Наступна схема наочно показує, як працює middleware на мережевому рівні та для чого потрібно встановлювати його саме на edge-сервери:
Проблема
Уявіть, що у вас є сторінка «Мій профіль», яка повинна бути доступною лише для автентифікованих користувачів. Якщо хтось не ввійшов у систему, ми хочемо перенаправити його на сторінку входу.
Як ми вирішимо це? Перевірка токена в локальному сховищі або сесії NextAuth на стороні клієнта може здатися простим варіантом, але ось у чому проблема:
- Погіршення досвіду користувача — неавторизованому користувачу потрібно чекати, поки сторінка завантажиться і виконається код на клієнті, перш ніж відбудеться перенаправлення. Під час цієї затримки він бачить стан завантаження, що є неприємним. Навіть авторизовані користувачі можуть бачити цей стан завантаження без причини, що призводить до неприємного досвіду.
- Проблеми з бандлом та ефективністю — використання перевірок на стороні клієнта означає додавання коду на всі приватні сторінки. Цей підхід може працювати в простих застосунках, але стає неефективним, якщо необхідні додаткові бібліотеки, що збільшує розмір бандлу.
Альтернативно ви можете подумати про обробку перевірки сесії на серверній стороні клієнта. Хоча це може спростити процес, це змусить кожну захищену сторінку стати динамічною, тобто змушуватиме сервер рендерити сторінку наново на кожен запит цієї сторінки. Якщо це не ваша мета, це може стати проблемою.
Тепер уявіть, якщо був би спосіб виявити автентифікацію користувача, як тільки він запитує сторінку, і перенаправити його на сторінку входу без впливу на вид рендерингу. Ось де з’являється ефективне рішення!
Реалізація
Як тільки я відкрив для себе можливості middleware, я одразу інтегрував його у свій проєкт. Але перед тим, як зануритися в код, уточнімо кілька важливих моментів. Бібліотека NextAuth зручно поміщає всю релевантну інформацію з токена безпосередньо в об’єкт req, що дозволяє нам без проблем отримати доступ до нього через req.nextauth.token.
Але якщо ви не використовуєте цю бібліотеку, то ви так само можете самі дістати токен з хедерів або кукі і розпарсити його. Це означає, що ми можемо отримати не тільки дані автентифікації, а й ролі користувачів і статуси підписки.
Тепер подивимося на мою першу реалізацію такого middleware:
import { withAuth } from "next-auth/middleware"; const authRoutes = [ AppRoutes.signIn, AppRoutes.signUp, AppRoutes.forgotPassword ]; export default withAuth( async function middleware(req) { const user = req.nextauth.token?.user; const isSigned = Boolean(user); const isResetPasswordRoute = req.nextUrl.pathname.startsWith( AppRoutes.resetPassword("") ); const isAuthRoute = authRoutes.includes(req.nextUrl.pathname) || isResetPasswordRoute; const isAdminRoute = req.nextUrl.pathname.startsWith("/admin"); const isSubscriptionPlansRoute = req.nextUrl.pathname === AppRoutes.subscriptionPlans; const isAdminUser = req.nextauth.token?.user.role === USER_ROLE.ADMIN; const accountStatus = req.nextauth.token?.user.accountStatus ?? ""; const isPaid = accountStatusesWithAppAccess.includes(accountStatus) || isAdminUser; const hasSubscription = accountStatusesThatHasSubscription.includes(accountStatus) || isAdminUser; const isMustGoPlansRoute = !hasSubscription && isSigned; const isRenewSubscriptionRoute = req.nextUrl.pathname === AppRoutes.renewSubscription; const mustGoRenewSubscription = hasSubscription && !isPaid && isSigned; if (mustGoRenewSubscription && !isRenewSubscriptionRoute) { return NextResponse.redirect(new URL(AppRoutes.renewSubscription, req.url)); } if (!mustGoRenewSubscription && isRenewSubscriptionRoute) { const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn; return NextResponse.redirect(new URL(redirectRoute, req.url)); } if (!isSubscriptionPlansRoute && isMustGoPlansRoute) { return NextResponse.redirect(new URL(AppRoutes.subscriptionPlans, req.url)); } if (isSubscriptionPlansRoute && !isMustGoPlansRoute) { const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn; return NextResponse.redirect(new URL(redirectRoute, req.url)); } if (isAuthRoute && isSigned && isPaid) { return NextResponse.redirect(new URL(AppRoutes.main, req.url)); } if (!isSigned && !isAuthRoute) { return NextResponse.redirect(new URL(AppRoutes.signIn, req.url)); } if (isAdminRoute && !isAdminUser) { const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn; return NextResponse.redirect(new URL(redirectRoute, req.url)) } }, { secret: envServer.NEXTAUTH_SECRET, callbacks: { authorized: () => true, }, } )
О, виглядає не дуже...
Я це, звісно, розумію, і бачу, що потрібне поліпшення.
Просунутий підхід
Натхнення для реалізації, яку я збираюсь показати, до мене прийшло з Vue.js, тож вирішив створити щось подібне. Я думав про те, який найбільш зручний спосіб обробити, чи можна давати доступ користувачу до ресурсу, чи ні. Іноді умови для кількох маршрутів однакові, але іноді маршрут має ті самі умови, що й інший, але з якоюсь додатковою умовою. Також деякі умови мають більший пріоритет, тому рішення повинно обробляти й такі випадки.
Було б чудово визначити умови для кожного маршруту окремо. Спробуємо зробити це.
Насамперед створимо бажану структуру. Я віддаю перевагу масиву об’єктів з маршрутом і умовою, щось на кшталт цього.
[ { route: "/onboarding", condition: (token: JWT, url: string) => {} }, { route: "/profile", condition: (token: JWT, url: string) => {} } ]
Добре, це працює, але що ця умова повинна повертати?
Якщо умова потребує редіректу користувача, вона повинна повернути редірект. В іншому випадку просто повернемо null.
[ { route: "/onboarding", condition: (token: JWT, url: string) => { if (!token?.user) return null; return NextResponse.redirect(new URL("/sign-in", url)); } }, { route: "/profile", condition: (token: JWT, url: string) => { if (!token?.user) return null; return NextResponse.redirect(new URL("/sign-in", url)); } }, ]
Чудово! Це працює, але маємо дві проблеми:
- Як ми будемо це все запускати?
- Умови дублюються.
Почнемо з першої:
import type { NextRequestWithAuth } from "next-auth/middleware"; export type RouteConfig = { condition: (token: JWT) => NextResponse<any> | null; url: string; }; export function runRoutesMiddleware( req: NextRequestWithAuth, config: RouteConfig[] ): NextResponse<any> | null { const currentRouteConfig = config.find( (route) => matchPath(route?.url, props?.nextUrl) ); if (!currentRouteConfig) return null; return currentRouteConfig.condition(req.token); } export default withAuth( async function middleware(req) { return runRoutesMiddleware(req, routesRulesConfig); }, { secret: env.NEXTAUTH_SECRET, callbacks: { authorized: () => true, }, } );
Тут я використовую невеликий хак під назвою «matchPath». Це така ж функція, яку ми маємо в React Router, але вона повертає boolean, якщо URL збігається з шаблоном.
Розв’язання проблеми з дублюванням умов
Давайте розглянемо, як уникнути дублювання умов у нашій конфігурації. Замість того, щоб кожного разу прописувати однакові умови для кожного маршруту, ми можемо створити набір маленьких функцій, що виконують перевірки (назвемо їх «rules»), які можна комбінувати для кожного маршруту.
1. Створимо rules (умови), що перевіряють доступ до маршруту, наприклад, перевірка аутентифікації.
type Rule = (token: JWT) => NextResponse<any> | null; export const isAuthenticatedRule: Rule = (token, url) => { if (!token?.user) return NextResponse.redirect(new URL("/sign-in", url)); // Якщо користувач автентифікований, то нічого не робимо return null; }
2. Тепер використаємо ці правила у конфігурації маршруту:
const routesRulesConfig = [ { route: "/onboarding", rules: [isAuthenticatedRule] }, { route: "/profile", rules: [isAuthenticatedRule] }, ];
3. Тепер, щоб застосувати всі правила, створимо функцію, яка виконує їх по черзі, поки не отримає відповідь. Якщо жодне правило не повернуло відповідь, ми не виконуватимемо редірект, тобто дозволяємо доступ до ресурсу.
export function executeRules( rules: Rule[], url: string, token: JWT, ruleIndex: number = 0 ): ReturnType<Rule> | void { if (ruleIndex > (rules?.length || 0) - 1) return; const result = rules?.[ruleIndex]?.(token, url); if (!result) { return executeRules(rules, url, token, ruleIndex + 1); } else { return result; } }
4. Тепер налаштуємо основну функцію для виконання middleware, яка використовує цю логіку. Функція runRoutesMiddleware знаходить відповідний маршрут і виконує умови для цього маршруту:
export function runRoutesMiddleware( req: NextRequestWithAuth, config: RouteConfig[] ): NextResponse<any> | void { const currentRouteConfig = config.find( (route) => matchPath(route?.url, req.nextUrl) ); if (!currentRouteConfig) return; return executeRules( currentRouteConfig?.rules, req.url, req.nextauth.token ); }
5. І в кінці інтегруємо цей middleware у основний файл:
export default withAuth( async function middleware(req) { return runRoutesMiddleware(req, routesRulesConfig); }, { secret: env.NEXTAUTH_SECRET, callbacks: { authorized: () => true, }, } );
Підсумок
Тепер ми маємо чистіший, більш модульний підхід до обробки доступу до маршрутів. Кожен маршрут має набір умов, що перевіряють доступ, і ви можете комбінувати ці умови для будь-якого маршруту, зберігаючи код чистим і підтримуваним.
Замість того, щоб дублювати логіку перевірки доступу на кожній сторінці, ми визначаємо загальні правила, які потім застосовуємо до кількох маршрутів. Це дозволяє легко змінювати або додавати нові правила без необхідності змінювати кожен маршрут окремо.
Якщо ви хочете зробити цю систему ще більш гнучкою, можете додавати складніші правила, використовувати пріоритети для різних умов, або навіть використовувати динамічні правила для конкретних ситуацій.
Цей підхід допоможе вам уникнути великої кількості коду, спростити логіку маршрутизації та зробити ваш проєкт більш масштабованим і легким для підтримки.
Хоча це може виглядати складно на перший погляд, після деякого часу ви побачите, як легко керувати правилами доступу до маршрутів і як багато переваг ви отримуєте завдяки такій абстракції.
Якщо хочете, можете ознайомитись з більш «покращеною» версією цього рішення у моєму репозиторії, де ви знайдете всі моменти, які я пропустив у статті, щоб спростити сприйняття.
Бонус
Вже після написання статті мені показали, що десь у світі знайшлись мої однодумцій створили бібліотеку, яка дуже схожа за концепцією. Залишаю посилання тут: www.npmjs.com/package/@rescale/nemo. Мені моє кастомне рішення подобається більше, бо для мене воно має більшу гнучкість, але я впевнений, що ця бібліотека так саме може вирішити перераховані в статті проблеми. Тож рішення, що використовувати, залишаю за вами.
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів