8 de maio de 2026•13 min de leitura
Você provavelmente está implementando filtros errado na sua API REST
Por que filtros de API vão muito além de query params e exigem validação, segurança, autorização e consistência.
Filtros parecem apenas query params, mas envolvem validação, segurança, autorização, performance, paginação, cache e consistência de resposta.
Quando estamos começando no backend, filtros parecem uma coisa simples.
Você vê esta rota:
GET /products?category=books&status=activeE pensa:
"Beleza, é só pegar os parâmetros da URL e filtrar no banco."
Só que é exatamente aí que muita API começa a ficar perigosa, lenta ou inconsistente.
O problema é que query params vêm do usuário. E tudo que vem do usuário pode estar inválido, malicioso ou simplesmente errado.
Filtros não são apenas uma utilidade para o frontend. Eles fazem parte do contrato da API. E quando esse contrato é mal definido, os problemas aparecem em produção: consultas quebrando, páginas vazias, usuários vendo dados que não deveriam, cache duplicado e endpoints lentos.
Neste artigo, quero mostrar como pensar em filtros de uma forma mais profissional.
1. Nunca confie em query params
Esse é um dos pontos mais importantes.
Query params vêm do usuário. E tudo que vem do usuário precisa ser tratado como dado não confiável.
Um exemplo simples:
GET /products?minPrice=abcNo backend, você pode acabar fazendo:
Number('abc')O resultado será:
NaNDependendo de como você monta a consulta, isso pode quebrar o endpoint ou gerar um comportamento estranho.
Uma validação manual poderia ser assim:
const minPrice = req.query.minPrice
? Number(req.query.minPrice)
: undefined;
if (minPrice !== undefined && Number.isNaN(minPrice)) {
return res.status(400).json({
error: 'minPrice must be a number',
});
}Funciona. Mas conforme os filtros crescem, validação manual começa a ficar repetitiva.
Uma alternativa melhor é usar um schema de validação, como Zod:
import { z } from 'zod';
const productFiltersSchema = z.object({
category: z.string().optional(),
status: z.enum(['active', 'inactive', 'draft']).optional(),
minPrice: z.coerce.number().min(0).optional(),
maxPrice: z.coerce.number().min(0).optional(),
search: z.string().min(1).max(100).optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
});E usar assim:
app.get('/products', async (req, res) => {
const parsed = productFiltersSchema.safeParse(req.query);
if (!parsed.success) {
return res.status(400).json({
error: 'Invalid filters',
details: parsed.error.flatten(),
});
}
const filters = parsed.data;
res.json({ filters });
});A API fica mais previsível. O frontend sabe o que errou. E o backend não precisa adivinhar o que fazer com parâmetros quebrados.
2. O bug mais traiçoeiro: quando "false" vira true
Este filtro parece simples:
GET /products?featured=trueMas query param sempre chega como string.
Ou seja:
req.query.featuredvem assim:
"true"E não assim:
trueVocê vai precisar converter:
const featured = req.query.featured === 'true';Ou então utilizar Zod:
featured: z.coerce.boolean().optional()Mas cuidado. Isso tem uma pegadinha:
"true" // true
"false" // true tambémPor quê?
Porque uma string preenchida é truthy em JavaScript. Então 'false' pode virar true .
Uma forma mais segura é transformar manualmente:
const schema = z.object({
featured: z
.string()
.transform((val) => {
if (val === 'true') return true;
if (val === 'false') return false;
throw new Error('Invalid boolean');
})
.optional(),
});Esse é o tipo de detalhe pequeno que evita dor de cabeça em produção.
3. Ignorar filtro inválido é deixar o bug quieto em produção
Imagine esta requisição:
GET /products?status=bananaSe banana não é um status válido, o ideal é retornar erro:
400 Bad Request{
"error": "Invalid filter",
"message": "status must be one of: active, inactive, draft"
}Por quê?
Porque se você simplesmente ignorar o filtro, o usuário pode achar que está filtrando quando não está.
Isso é perigoso. A API parece funcionar, mas entrega um resultado enganoso.
O mesmo vale para filtros desconhecidos:
GET /products?color=redSe a API não suporta color, você tem duas opções.
Opção 1: ignorar
{
"data": []
}Isso pode ser aceitável em APIs públicas muito flexíveis, onde o contrato permite ignorar parâmetros não suportados.
Opção 2: rejeitar
400 Bad Request{
"error": "Unsupported filter: color"
}Em APIs internas, sistemas críticos ou endpoints administrativos, rejeitar costuma ser melhor. Você evita comportamento silencioso e força o cliente da API a corrigir o erro.
4. Nem todo filtro que existe deveria ser permitido
Aqui entra um ponto de segurança e autorização.
Nem todo filtro que faz sentido tecnicamente deve estar disponível para qualquer usuário.
Exemplo:
GET /users?role=adminTalvez apenas um super admin possa listar usuários por role.
Outro exemplo:
GET /orders?userId=10Se sou um usuário comum, eu não deveria conseguir buscar pedidos de outros usuários mudando o userId na URL.
O backend precisa aplicar essa regra:
function applyUserFilter(req, where) {
if (req.user.role === 'admin') {
if (req.query.userId) {
where.userId = Number(req.query.userId);
}
return;
}
where.userId = req.user.id;
}Repare que, para usuários admin, permitimos que o filtro userId venha de req.query.userId, caso ele seja informado. Já para usuários que não são admin, ignoramos qualquer userId da query e usamos sempre req.user.id, que vem do contexto de autenticação.
O ponto é simples: o usuário comum não escolhe qual userId será usado. O backend decide com base no contexto autenticado.
5. O filtro que pode vazar dados de outra empresa
Este é um exemplo perigoso:
GET /orders?tenantId=empresa2Se o backend confiar nesse valor, um usuário pode tentar trocar o tenantId e acessar dados de outra empresa.
O correto é pegar o tenant do contexto autenticado:
const tenantId = req.user.tenantId;E usar assim:
where.tenantId = req.user.tenantId;Um exemplo:
app.get('/orders', authMiddleware, async (req, res) => {
const where: any = {
tenantId: req.user.tenantId,
};
if (req.query.status) {
where.status = String(req.query.status);
}
const orders = await db.order.findMany({ where });
res.json({ data: orders });
});Mesmo que o usuário envie outro tenant na URL, o backend ignora. O filtro de segurança não depende do frontend.
6. Quando um simples filtro vira uma brecha de SQL Injection
Nunca misture string passada pelo usuário diretamente com SQL.
SQL Injection acontece quando dados fornecidos pelo usuário são interpretados como parte da query SQL.
Exemplo perigoso:
GET /products?name=' OR 1=1 --Se você monta a query assim:
const sql = `
SELECT * FROM products
WHERE name = '${req.query.name}'
`;A query final pode virar:
SELECT * FROM products
WHERE name = '' OR 1=1 --'O que isso significa?
name = '' normalmente seria falso;
OR 1=1 é sempre verdadeiro;
-- comenta o restante da query.Resultado: a condição vira "retorne tudo". E então, o user terá todos os registros do banco.
A forma correta é usar query parametrizada:
await db.query(
'SELECT * FROM products WHERE name = $1',
[req.query.name]
);O banco não vai misturar SQL com dados. Ele vai interpretar assim:
SELECT * FROM products WHERE name = $1e depois substituir:
$1 = "' OR 1=1 --"Ou seja, ele pega o valor passado pelo usuário, e busca de forma literal no banco:
name = "' OR 1=1 --"Só vai retornar se existe um name com esse valor.
Assim, o banco entende que o valor passado é apenas um dado literal, não um pedaço de SQL.
Com ORM, como Prisma, você normalmente escreve assim:
await db.product.findMany({
where: {
name: String(req.query.name),
},
});O ORM gera uma query parametrizada por baixo dos panos. Isso ajuda a proteger contra SQL Injection, desde que você não fuja para SQL manual concatenando string.
7. Datas em filtros: o bug que quase sempre aparece tarde demais
Datas dão muito bug.
Pense neste exemplo:
GET /orders?startDate=2026-01-01&endDate=2026-01-31Parece simples. Mas a API precisa responder algumas perguntas:
aceita apenas YYYY-MM-DD?
aceita ISO completo?
qual timezone será usado?
endDate é inclusivo ou exclusivo?
o último dia inclui até 23:59:59?Um padrão melhor é trabalhar com ISO completo e intervalo semiaberto:
GET /orders?createdFrom=2026-01-01T00:00:00Z&createdUntil=2026-02-01T00:00:00ZNo SQL, a lógica ficaria assim:
WHERE created_at >= '2026-01-01T00:00:00Z'
AND created_at < '2026-02-01T00:00:00Z'Repare no uso de < no final, e não <=.
Isso evita problemas com horários no último dia. Você não precisa inventar 2026-01-31T23:59:59.999Z. Basta dizer que quer tudo a partir de 1º de janeiro e antes de 1º de fevereiro.
Também prefiro nomes como:
createdFrom
createdUntilEles são mais claros do que createdAfter, porque after dá a entender que o valor passado não deveria ser incluído. Já from comunica melhor a ideia de "a partir de".
8. O filtro que funciona em dev e derruba sua API em produção
Filtros podem matar performance.
Exemplo:
GET /orders?status=paidSe esse filtro é usado com frequência e a tabela é grande, talvez você precise de um índice:
CREATE INDEX idx_orders_status ON orders(status);Outro exemplo:
GET /orders?tenantId=1&status=paid&createdFrom=...Você pode precisar de um índice composto:
CREATE INDEX idx_orders_tenant_status_created
ON orders(tenant_id, status, created_at);Regra prática: comece a prestar atenção quando o campo:
é filtrado frequentemente;
está em uma tabela grande;
faz parte de um endpoint crítico;
participa de ordenação ou paginação.Campos que muitas vezes precisam de índice:
tenantId
userId
email
status
createdAt
category
slugNão é regra absoluta, mas é um bom ponto de partida.
9. LIKE '%iphone%': simples, bonito e potencialmente caro
Uma busca assim é comum:
WHERE name ILIKE '%iphone%'Ela encontra valores como:
iphone 15;
capa iphone;
smartphone iphone usado;
meu produto iphone vermelho.O problema é que esse tipo de busca pode ser pesado em tabelas grandes.
O % no começo quer dizer: "pode ter qualquer coisa antes". Então o banco pode precisar varrer muitos registros para encontrar resultados.
Para sistemas maiores, talvez você precise de motores de busca específicos.
Elasticsearch
Faz sentido quando você precisa de busca textual avançada, logs, observabilidade, ranking por relevância, filtros complexos, autocomplete, busca geográfica ou busca vetorial.
OpenSearch
É uma alternativa open source muito usada para busca, analytics, observabilidade e aplicações com muitos dados, especialmente em ambientes AWS.
Meilisearch
É interessante quando você quer uma busca boa rapidamente, com typo tolerance, prefix matching e uma experiência amigável para o usuário.
Exemplo:
Usuário digita: iphnoe
Sistema encontra: iPhone10. O cache duplicado que nasce da ordem errada dos query params
Filtros mudam a resposta da API.
Estas duas chamadas não retornam a mesma coisa:
GET /products?category=books
GET /products?category=electronicsLogo, a chave de cache precisa considerar os filtros.
Um primeiro pensamento seria:
const cacheKey = `products:${JSON.stringify(filters)}`;Mas existe um detalhe importante: a ordem dos parâmetros pode mudar.
Estas URLs são equivalentes:
GET /products?status=active&category=books
GET /products?category=books&status=activeSe você montar a chave diretamente da URL, pode gerar cache duplicado.
O ideal é normalizar os filtros antes de montar a chave:
function createCacheKey(baseKey: string, query: Record<string, unknown>) {
const normalizedQuery = Object.entries(query)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => `${key}=${String(value)}`)
.join(':');
return normalizedQuery
? `${baseKey}:${normalizedQuery}`
: baseKey;
}Uso:
const key1 = createCacheKey('products', {
status: 'active',
category: 'books',
});
const key2 = createCacheKey('products', {
category: 'books',
status: 'active',
});Ambas geram:
products:category=books:status=activeCom arrays, você também pode normalizar a ordem:
function normalizeValue(value: unknown) {
if (Array.isArray(value)) {
return value.map(String).sort().join(',');
}
return String(value);
}Assim:
/products?tags=tech&tags=book
/products?tags=book&tags=techvão ter a mesma chave:
products:tags=book,tech11. A página vazia que não significa ausência de dados
Imagine este cenário:
GET /products?page=3&category=booksAgora o usuário muda o filtro para:
category=electronicsSe o frontend continuar na página 3, pode acontecer de essa página não existir para electronics.
Resultado: lista vazia.
E o usuário pode achar que não existem produtos daquela categoria.
Por isso, quando o filtro muda, normalmente o frontend deve resetar a página:
setFilters(newFilters);
setPage(1);12. status=active,pending ou status=active&status=pending? Escolha um padrão
Em algum momento, você vai querer filtrar por mais de um valor:
GET /products?status=active,pendingExistem alguns padrões comuns.
Com vírgula
GET /products?status=active,pendingNo backend:
const statuses = String(req.query.status).split(',');Repetindo o query param
GET /products?status=active&status=pendingDependendo do framework, isso pode chegar como array:
['active', 'pending']Com colchetes
GET /products?status[]=active&status[]=pendingNão existe uma única resposta perfeita. O mais importante é escolher um padrão e documentar.
Pessoalmente, gosto do formato com vírgula para casos simples:
GET /products?status=active,pendingEle é fácil de ler, fácil de compartilhar e simples de entender.
13. Quando minPrice e maxPrice começam a virar bagunça
Em APIs simples, isso é suficiente:
GET /products?minPrice=100&maxPrice=500Mas em APIs maiores, com muitos filtros numéricos, datas e ranges, esse padrão começa a escalar mal.
Você acaba criando muitos parâmetros:
minPrice
maxPrice
minStock
maxStock
minRating
maxRating
startDate
endDate
minTotal
maxTotalUma alternativa é usar operadores:
GET /products?price[gte]=100&price[lte]=500
GET /products?stock[gt]=0
GET /products?rating[gte]=4
GET /orders?createdAt[gte]=2026-01-01
GET /orders?total[lte]=1000Tradução:
price[gte]=100 // preço maior ou igual a 100
price[lte]=500 // preço menor ou igual a 500
stock[gt]=0 // estoque maior que 0
createdAt[gte]=data // criado a partir daquela data
total[lte]=1000 // total menor ou igual a 1000Esse padrão traz padronização e flexibilidade.
Mas também aumenta a complexidade do backend. Você precisa validar campos permitidos, operadores permitidos, tipos de dados e permissões.
Para CRUD básico, mantenha simples.
Para APIs públicas, filtros avançados ou sistemas com muitas buscas flexíveis, operadores podem valer a pena.
14. GET nem sempre é a melhor escolha para filtros complexos
Às vezes, filtros ficam grandes demais para query params.
Exemplo:
{
"filters": {
"status": ["active", "pending"],
"price": {
"gte": 100,
"lte": 500
},
"tags": ["promo", "black-friday"],
"createdAt": {
"gte": "2026-01-01",
"lte": "2026-02-01"
}
}
}Nesse caso, faz sentido criar algo como:
POST /products/searchIsso foge um pouco do REST mais "puro", mas é comum quando filtros são muito complexos.
Para filtros simples, continue com:
GET /products?status=active&category=booksPara filtros muito complexos, POST /search pode ser uma escolha mais prática.
15. Por que filtros costumam ficar na URL?
Filtros na URL têm várias vantagens:
o usuário pode compartilhar um link filtrado;
pode favoritar uma página filtrada;
o navegador consegue usar back/forward;
frameworks como Next.js conseguem ler filtros da URL;
caches podem diferenciar respostas por URL;
o estado da tela fica mais previsível.Exemplo:
/products?category=books&status=activeIsso é melhor do que esconder todos os filtros apenas em estado local do frontend.
16. Path define o escopo, query param refina a busca
Você pode buscar pedidos de um usuário assim:
GET /users/10/ordersE também aplicar filtros:
GET /users/10/orders?status=paidNo banco, isso vira algo como:
WHERE user_id = 10
AND status = 'paid'Ou seja, nested resource e query params não competem. Eles podem trabalhar juntos.
O path define o escopo principal do recurso. A query string define a visão filtrada dentro desse escopo.
17. O papel real dos query params em uma API REST
Em REST, filtros normalmente ficam na query string porque o recurso continua sendo o mesmo. O que muda é a forma como você quer visualizar aquele conjunto de dados.
Exemplos:
GET /products?status=active
GET /products?category=booksVocê continua buscando o mesmo recurso (produtos). Só está pedindo uma visão filtrada dele.
18. Erros comuns ao implementar filtros
Filtrar tudo no frontend
const allProducts = await getAllProducts();
const filtered = allProducts.filter(product => product.status === 'active');Isso pode até funcionar com poucos dados. Mas você terá problemas em produção se tiver muitos registros.
O correto é filtrar no backend e no banco:
GET /products?status=activeNão limitar busca textual
GET /products?search=aIsso pode retornar dados demais. Você pode exigir um mínimo de caracteres:
search: z.string().min(3).optional()Esquecer tenant e autorização
GET /orders?userId=123Usuário comum não deveria escolher qualquer userId. O backend precisa aplicar regras de acesso.
Criar filtro sem pensar em índice
Vai funcionar em desenvolvimento. Mas pode morrer em produção.
Misturar regra de negócio com filtro de forma perigosa
GET /users?isAdmin=trueEsse filtro nem deveria existir para usuários comuns.
Um exemplo mais seguro:
if (req.user.role === 'admin' && req.query.isAdmin) {
where.isAdmin = req.query.isAdmin === 'true';
}O backend precisa decidir o que o usuário pode ou não filtrar.
19. Checklist antes de criar qualquer filtro
Quando você cria filtros, pense nesta ordem:
1. Quais filtros a API aceita?
2. Eles são válidos?
3. O usuário pode usar esses filtros?
4. Como isso vira query no banco?
5. Tem índice/performance?
6. Como combina com paginação?
7. Como fica a resposta?Filtro bem feito não é só pegar query param e jogar no banco.
Filtro bem feito envolve:
modelagem da API;
validação;
segurança;
autorização;
performance;
paginação;
ordenação;
consistência de resposta.No fim, query params parecem pequenos. Mas eles dizem muito sobre a maturidade do seu backend.