Урок 09

Middleware и matcher

Middleware выполняется до рендера страницы. Подходит для авторизации, редиректов, A/B, геолокации.

Создание

middleware.ts в корне (рядом с app/):

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
    return NextResponse.next();
}

По умолчанию применяется ко всем роутам.

Matcher

Ограничить:

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

:path* — любая вложенность.

Исключить:

export const config = {
    matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Redirect

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

Rewrite

Меняет URL без редиректа:

return NextResponse.rewrite(new URL('/dashboard', request.url));

Заголовки

const response = NextResponse.next();
response.headers.set('X-Custom', 'value');
return response;

Cookies

const token = request.cookies.get('token')?.value;

const response = NextResponse.next();
response.cookies.set('visited', 'true', {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
});
return response;

Авторизация

import { verifyToken } from '@/lib/auth';

export async function middleware(request: NextRequest) {
    const token = request.cookies.get('token')?.value;

    if (!token) {
        const loginUrl = new URL('/login', request.url);
        loginUrl.searchParams.set('from', request.nextUrl.pathname);
        return NextResponse.redirect(loginUrl);
    }

    const user = await verifyToken(token);
    if (!user) {
        return NextResponse.redirect(new URL('/login', request.url));
    }

    const response = NextResponse.next();
    response.headers.set('X-User-Id', String(user.id));
    return response;
}

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

Роли

export function middleware(request: NextRequest) {
    const role = request.cookies.get('role')?.value;

    if (request.nextUrl.pathname.startsWith('/admin') && role !== 'admin') {
        return NextResponse.redirect(new URL('/forbidden', request.url));
    }

    return NextResponse.next();
}

A/B-тесты

export function middleware(request: NextRequest) {
    const bucket = request.cookies.get('bucket')?.value ?? (Math.random() < 0.5 ? 'a' : 'b');

    const response = NextResponse.rewrite(new URL(`/${bucket}${request.nextUrl.pathname}`, request.url));
    response.cookies.set('bucket', bucket);
    return response;
}

Геолокация

export function middleware(request: NextRequest) {
    const country = request.geo?.country ?? 'US';

    if (country === 'RU' && !request.nextUrl.pathname.startsWith('/ru')) {
        return NextResponse.redirect(new URL(`/ru${request.nextUrl.pathname}`, request.url));
    }

    return NextResponse.next();
}

geo доступен на Vercel. В других средах — через заголовки.

Ограничения

Middleware работает в Edge Runtime:

  • Нет Node.js API
  • Ограниченный размер
  • Быстрый запуск

Нельзя:

  • Работать с БД напрямую
  • Использовать тяжёлые библиотеки

Для БД — вызывай API или используй fetch.

Middleware не защищает API

Middleware защищает страницы, но API-роуты и Server Actions нужно проверять отдельно:

'use server';

export async function deletePost(id: number) {
    const session = await auth();
    if (!session) throw new Error('Unauthorized');
    // ...
}

Matcher с параметрами

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

Итоги

  • middleware.ts в корне
  • matcher для ограничения
  • redirect, rewrite, next
  • Cookies и заголовки
  • Edge Runtime — ограничения
  • Не защищает API — проверяй отдельно