Next.js Middleware: Autenticação, A/B Testing e Geolocalização na Edge

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O que o Middleware do Next.js resolve (e onde ele roda)
Middleware no Next.js intercepta toda requisição antes que ela chegue a uma rota, API route ou asset estático. Ele roda no Edge Runtime da Vercel (ou em qualquer runtime compatível com a Web API Request/Response), não no Node.js tradicional. Essa distinção importa: você não tem acesso a fs, child_process, nem a bibliotecas que dependem de APIs nativas do Node.js.
O ganho concreto: latência baixa para decisões que precisam acontecer antes do render. Verificar se o usuário tem token válido, decidir qual variante de A/B testing servir, redirecionar por país. Tudo isso sem tocar no SSR, sem round-trip extra ao servidor de aplicação.
O custo: o Edge Runtime tem limites reais. Sem conexão direta a banco de dados (a menos que o driver suporte HTTP, como o Neon serverless driver ou o Planetscale serverless). Sem bibliotecas pesadas. O bundle do middleware tem limite de 1 MB na Vercel. Se a lógica que você precisa executar depende de queries complexas ou processamento pesado, middleware é o lugar errado.
| Característica | Middleware (Edge Runtime) | API Route (Node.js Runtime) | Server Component |
|---|---|---|---|
| Executa antes do render | Sim | Não | Não |
Acesso a fs, APIs nativas | Não | Sim | Sim |
| Latência típica | < 5ms | 20-100ms | Depende do SSR |
| Limite de bundle | ~1 MB (Vercel) | Sem limite prático | Sem limite prático |
| Acesso a banco direto | Apenas drivers HTTP | Sim | Sim |
| Rewrite/redirect sem render | Sim | Parcial | Não |
Se você já trabalha com API Gateway patterns, o middleware do Next.js cumpre um papel similar para a camada de apresentação: interceptar, decidir e rotear.
Estrutura base do middleware
O middleware vive em middleware.ts na raiz do projeto (ou dentro de src/, se você usa essa convenção). Um único arquivo para toda a aplicação. Não existe middleware por rota como no Express. Você controla o escopo com o matcher no config.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
return NextResponse.next();
}
// matcher evita que o middleware rode para assets estáticos e favicon
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};O matcher usa regex negativa para excluir rotas que não precisam de interceptação. Sem isso, toda requisição de imagem, CSS e JS passaria pelo middleware, adicionando latência desnecessária.
Autenticação: verificação de JWT na edge
O caso mais comum. Verificar se o usuário tem um token válido antes de servir rotas protegidas. A lógica é simples: leia o cookie ou header, valide a assinatura do JWT, redirecione se inválido.
Para validação de JWT no Edge Runtime, você não pode usar jsonwebtoken (depende de APIs do Node.js). Use jose, que é compatível com Web Crypto API.
// lib/auth-edge.ts
import { jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET!
);
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return { valid: true, payload };
} catch {
// jwtVerify lança erro para tokens expirados, malformados ou com assinatura inválida
return { valid: false, payload: null };
}
}// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyToken } from "./lib/auth-edge";
const PROTECTED_PATHS = ["/dashboard", "/settings", "/api/user"];
function isProtectedPath(pathname: string): boolean {
return PROTECTED_PATHS.some((path) => pathname.startsWith(path));
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (!isProtectedPath(pathname)) {
return NextResponse.next();
}
const token = request.cookies.get("auth-token")?.value;
if (!token) {
const loginUrl = new URL("/login", request.url);
// preserva a rota original para redirect pós-login
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
const { valid, payload } = await verifyToken(token);
if (!valid) {
const response = NextResponse.redirect(
new URL("/login", request.url)
);
// limpa cookie inválido para evitar loop de redirect
response.cookies.delete("auth-token");
return response;
}
// injeta dados do usuário no header para consumo em Server Components
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", String(payload!.sub));
requestHeaders.set("x-user-role", String(payload!.role));
return NextResponse.next({
request: { headers: requestHeaders },
});
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};O padrão de injetar dados via headers (x-user-id, x-user-role) evita uma segunda chamada de verificação dentro do Server Component. A página lê headers() e já tem o contexto do usuário. Se você usa Supabase como backend, o middleware é onde você valida a session do Supabase Auth antes de servir rotas protegidas.
A/B testing: decisão na edge com cookie persistente
A/B testing no middleware funciona assim: na primeira visita, o middleware sorteia uma variante, grava em cookie e faz rewrite para a rota correspondente. Nas visitas seguintes, lê o cookie e mantém a consistência.
A vantagem sobre A/B testing no client-side: zero flicker. O usuário já recebe a variante correta no primeiro byte. Sem useEffect que troca o conteúdo depois do render.
// lib/ab-testing.ts
export interface Experiment {
name: string;
variants: string[];
// peso de cada variante (deve somar 1.0)
weights: number[];
}
export const EXPERIMENTS: Record<string, Experiment> = {
"pricing-page": {
name: "pricing-page",
variants: ["control", "variant-a", "variant-b"],
weights: [0.5, 0.25, 0.25],
},
};
export function pickVariant(experiment: Experiment): string {
const random = Math.random();
let cumulative = 0;
for (let i = 0; i < experiment.variants.length; i++) {
cumulative += experiment.weights[i];
if (random <= cumulative) {
return experiment.variants[i];
}
}
// fallback para o último (cobertura de arredondamento de ponto flutuante)
return experiment.variants[experiment.variants.length - 1];
}// middleware.ts (seção de A/B testing integrada)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { EXPERIMENTS, pickVariant } from "./lib/ab-testing";
function handleAbTesting(
request: NextRequest,
response: NextResponse
): NextResponse {
const { pathname } = request.nextUrl;
if (pathname !== "/pricing") {
return response;
}
const experiment = EXPERIMENTS["pricing-page"];
const cookieName = `ab-${experiment.name}`;
const existingVariant = request.cookies.get(cookieName)?.value;
const variant = existingVariant ?? pickVariant(experiment);
if (variant === "control") {
// control serve a página original, sem rewrite
if (!existingVariant) {
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30, // 30 dias: tempo suficiente para o experimento rodar
httpOnly: true,
sameSite: "lax",
});
}
return response;
}
// rewrite transparente: a URL não muda para o usuário
const rewriteUrl = new URL(`/pricing/${variant}`, request.url);
const rewriteResponse = NextResponse.rewrite(rewriteUrl);
if (!existingVariant) {
rewriteResponse.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
});
}
return rewriteResponse;
}Para esse padrão funcionar, você precisa das páginas de variante no filesystem:
app/
pricing/
page.tsx ← control
variant-a/
page.tsx ← variante A
variant-b/
page.tsx ← variante BSe o número de variantes cresce, considere usar estratégias de deploy como canary para testar variantes em infraestrutura separada, em vez de empilhar tudo no mesmo build.
Geolocalização: redirecionamento por país
A Vercel injeta headers de geolocalização automaticamente (x-vercel-ip-country, x-vercel-ip-city, x-vercel-ip-region). Em outros provedores, você precisa de um serviço externo ou do header cf-ipcountry da Cloudflare.
// lib/geo.ts
export interface GeoConfig {
countryCode: string;
redirectPath: string;
// idioma padrão para o país
locale: string;
}
export const GEO_REDIRECTS: GeoConfig[] = [
{ countryCode: "BR", redirectPath: "/pt-br", locale: "pt-BR" },
{ countryCode: "PT", redirectPath: "/pt-pt", locale: "pt-PT" },
{ countryCode: "ES", redirectPath: "/es", locale: "es" },
// demais países caem no inglês (default)
];
export function findGeoRedirect(
countryCode: string | null
): GeoConfig | undefined {
if (!countryCode) return undefined;
return GEO_REDIRECTS.find(
(config) => config.countryCode === countryCode.toUpperCase()
);
}// middleware.ts (seção de geolocalização)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { findGeoRedirect } from "./lib/geo";
function handleGeoRedirect(request: NextRequest): NextResponse | null {
const { pathname } = request.nextUrl;
// evita loop: se já está em um path localizado, não redireciona de novo
if (pathname.startsWith("/pt-br") || pathname.startsWith("/pt-pt") || pathname.startsWith("/es")) {
return null;
}
// cookie de override permite que o usuário escolha idioma manualmente
const localeOverride = request.cookies.get("locale-override")?.value;
if (localeOverride) {
return null;
}
// header da Vercel; troque por 'cf-ipcountry' na Cloudflare
const country = request.headers.get("x-vercel-ip-country");
const geoConfig = findGeoRedirect(country);
if (!geoConfig) {
return null;
}
const redirectUrl = new URL(geoConfig.redirectPath + pathname, request.url);
const response = NextResponse.redirect(redirectUrl, 307);
// 307 (temporary) em vez de 308: permite que o usuário mude de país/VPN sem cache permanente
response.cookies.set("detected-locale", geoConfig.locale, {
maxAge: 60 * 60 * 24 * 7,
sameSite: "lax",
});
return response;
}O 307 é intencional. Um 308 (permanent redirect) seria cacheado pelo browser, e se o usuário trocar de VPN ou viajar, ficaria preso no redirect antigo. Para conteúdo localizado com SSR, o middleware garante que o Server Component já receba a requisição na rota correta.
Composição: middleware unificado
Na prática, você precisa dos três comportamentos no mesmo arquivo. A composição funciona como uma pipeline: cada handler decide se atua ou passa adiante.
// middleware.ts (versão completa)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyToken } from "./lib/auth-edge";
import { EXPERIMENTS, pickVariant } from "./lib/ab-testing";
import { findGeoRedirect } from "./lib/geo";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Geolocalização (roda primeiro: redireciona antes de qualquer processamento)
const geoResponse = handleGeoRedirect(request);
if (geoResponse) return geoResponse;
// 2. Autenticação (rotas protegidas)
if (isProtectedPath(pathname)) {
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;
}
// 3. A/B testing (rotas com experimento ativo)
const response = NextResponse.next();
return handleAbTesting(request, response);
}
// ... funções handleGeoRedirect, handleAuth, handleAbTesting
// como definidas nas seções anteriores
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};A ordem importa. Geo primeiro, porque um redirect por país torna desnecessário verificar auth ou A/B na rota original. Auth segundo, porque um usuário não autenticado não deveria participar de experimentos em rotas protegidas.
O que NÃO fazer
Erro 1: fazer fetch a APIs externas lentas dentro do middleware.
// ERRADO: chamada de rede que adiciona latência a TODA requisição
export async function middleware(request: NextRequest) {
const userData = await fetch("https://api.example.com/user/profile", {
headers: { Authorization: `Bearer ${getToken(request)}` },
});
// se a API demora 200ms, toda página do site demora +200ms
const user = await userData.json();
// ...
}O middleware roda em toda requisição que casa com o matcher. Uma chamada de 200ms aqui significa 200ms adicionais em cada navegação. Valide o JWT localmente (verificação de assinatura é operação de CPU pura, < 1ms) e deixe chamadas de rede para Server Components ou API routes.
// CORRETO: validação local do token, sem round-trip de rede
export async function middleware(request: NextRequest) {
const token = request.cookies.get("auth-token")?.value;
if (!token) return NextResponse.redirect(new URL("/login", request.url));
// jwtVerify usa Web Crypto API, roda localmente no edge
const { valid } = await verifyToken(token);
if (!valid) return NextResponse.redirect(new URL("/login", request.url));
return NextResponse.next();
}Erro 2: não limitar o matcher e processar assets estáticos.
// ERRADO: sem matcher, o middleware roda para /favicon.ico, /_next/static/*, imagens...
export function middleware(request: NextRequest) {
// lógica de auth rodando para cada chunk de JS carregado
}
// sem export const configO impacto é multiplicado: uma página típica do Next.js carrega 15-30 assets estáticos. Sem matcher, são 15-30 execuções desnecessárias do middleware por page load.
Erro 3: A/B testing sem cookie persistente.
// ERRADO: sorteia variante em toda requisição
export function middleware(request: NextRequest) {
const variant = Math.random() > 0.5 ? "a" : "b";
// usuário vê variante A, recarrega, vê variante B
return NextResponse.rewrite(new URL(`/pricing/${variant}`, request.url));
}Sem persistência em cookie, o mesmo usuário oscila entre variantes. Os dados do experimento ficam inutilizáveis porque não há consistência por sessão.
Testando middleware localmente
O next dev emula o Edge Runtime, mas os headers de geolocalização não existem em localhost. Para testar geo, injete headers manualmente:
# simula requisição do Brasil
curl -H "x-vercel-ip-country: BR" http://localhost:3000/
# simula requisição da Espanha com cookie de auth
curl -H "x-vercel-ip-country: ES" \
-b "auth-token=eyJhbGciOiJIUzI1NiJ9..." \
http://localhost:3000/dashboardPara testes automatizados, o Playwright permite setar headers customizados por contexto, o que facilita testar cenários de geo e auth em conjunto.
Quando middleware é a escolha errada
Middleware não substitui autorização no backend. Ele opera na camada de roteamento. Um token válido no middleware não garante que o usuário tem permissão para acessar um recurso específico. A segurança da API continua sendo responsabilidade da camada de backend.
Se a lógica de decisão precisa de dados do banco (verificar se o usuário é dono de um recurso, checar limites de plano), faça isso no Server Component ou na API route. O middleware cuida do gate binário: autenticado ou não, país A ou país B, variante X ou variante Y. Decisões que dependem de estado complexo pertencem a camadas com acesso completo ao runtime Node.js.
Para rate limiting pesado, um API Gateway dedicado com Redis é mais adequado. O middleware do Next.js não tem acesso nativo a stores de contagem distribuída (a menos que você use um KV store via HTTP, o que adiciona latência).
FAQ
O middleware do Next.js roda em toda requisição, mesmo para páginas estáticas (ISR/SSG)?
Sim. O middleware intercepta a requisição antes do cache da CDN na Vercel. Para páginas estáticas que não precisam de lógica de middleware, exclua-as no matcher para evitar execuções desnecessárias.
Posso usar variáveis de ambiente no middleware?
Sim, mas apenas variáveis prefixadas com NEXT_PUBLIC_ são acessíveis no Edge Runtime por padrão. Para variáveis privadas (como JWT_SECRET), elas ficam disponíveis via process.env no middleware porque ele roda no servidor, não no browser. A confusão surge porque o Edge Runtime não é o Node.js, mas o Next.js injeta as env vars corretamente.
Como debugar middleware em produção?
Na Vercel, os logs do middleware aparecem em "Functions" > "Edge". Use console.log com moderação: cada log gera custo em plataformas serverless. Para diagnóstico estruturado, injete um header de debug (x-middleware-debug) que o frontend pode ler em headers() dentro de Server Components.
Middleware substitui o NextAuth/Auth.js?
Não substitui, complementa. O NextAuth gerencia sessões, provedores OAuth e callbacks. O middleware consome o token que o NextAuth gerou para decidir se a requisição passa ou é redirecionada. A documentação do Auth.js inclui um exemplo oficial de integração com middleware.
Posso ter mais de um arquivo de middleware?
Não. O Next.js suporta apenas um middleware.ts por projeto. A composição de múltiplas responsabilidades (auth, A/B, geo) precisa acontecer dentro desse arquivo único, como mostrado na seção de composição. Se o arquivo cresce demais, extraia a lógica para módulos separados (lib/auth-edge.ts, lib/ab-testing.ts) e importe no middleware.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


