8 de maio de 202613 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:

HTTP
GET /products?category=books&status=active

E 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:

HTTP
GET /products?minPrice=abc

No backend, você pode acabar fazendo:

TypeScript
Number('abc')

O resultado será:

TXT
NaN

Dependendo de como você monta a consulta, isso pode quebrar o endpoint ou gerar um comportamento estranho.

Uma validação manual poderia ser assim:

TypeScript
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:

TypeScript
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:

TypeScript
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:

HTTP
GET /products?featured=true

Mas query param sempre chega como string.

Ou seja:

TXT
req.query.featured

vem assim:

TXT
"true"

E não assim:

TXT
true

Você vai precisar converter:

TypeScript
const featured = req.query.featured === 'true';

Ou então utilizar Zod:

TypeScript
featured: z.coerce.boolean().optional()

Mas cuidado. Isso tem uma pegadinha:

TypeScript
"true"  // true
"false" // true também

Por quê?

Porque uma string preenchida é truthy em JavaScript. Então 'false' pode virar true .

Uma forma mais segura é transformar manualmente:

TypeScript
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:

HTTP
GET /products?status=banana

Se banana não é um status válido, o ideal é retornar erro:

TXT
400 Bad Request
JSON
{
  "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:

HTTP
GET /products?color=red

Se a API não suporta color, você tem duas opções.

Opção 1: ignorar

JSON
{
  "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

TXT
400 Bad Request
JSON
{
  "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:

HTTP
GET /users?role=admin

Talvez apenas um super admin possa listar usuários por role.

Outro exemplo:

HTTP
GET /orders?userId=10

Se 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:

TypeScript
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:

HTTP
GET /orders?tenantId=empresa2

Se 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:

TypeScript
const tenantId = req.user.tenantId;

E usar assim:

TypeScript
where.tenantId = req.user.tenantId;

Um exemplo:

TypeScript
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:

HTTP
GET /products?name=' OR 1=1 --

Se você monta a query assim:

TypeScript
const sql = `
  SELECT * FROM products
  WHERE name = '${req.query.name}'
`;

A query final pode virar:

SQL
SELECT * FROM products
WHERE name = '' OR 1=1 --'

O que isso significa?

TXT
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:

TypeScript
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:

SQL
SELECT * FROM products WHERE name = $1

e depois substituir:

TXT
$1 = "' OR 1=1 --"

Ou seja, ele pega o valor passado pelo usuário, e busca de forma literal no banco:

TXT
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:

TypeScript
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:

HTTP
GET /orders?startDate=2026-01-01&endDate=2026-01-31

Parece simples. Mas a API precisa responder algumas perguntas:

TXT
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:

HTTP
GET /orders?createdFrom=2026-01-01T00:00:00Z&createdUntil=2026-02-01T00:00:00Z

No SQL, a lógica ficaria assim:

SQL
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:

TXT
createdFrom
createdUntil

Eles 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:

HTTP
GET /orders?status=paid

Se esse filtro é usado com frequência e a tabela é grande, talvez você precise de um índice:

SQL
CREATE INDEX idx_orders_status ON orders(status);

Outro exemplo:

HTTP
GET /orders?tenantId=1&status=paid&createdFrom=...

Você pode precisar de um índice composto:

SQL
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:

TXT
é 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:

TXT
tenantId
userId
email
status
createdAt
category
slug

Não é regra absoluta, mas é um bom ponto de partida.

9. LIKE '%iphone%': simples, bonito e potencialmente caro

Uma busca assim é comum:

SQL
WHERE name ILIKE '%iphone%'

Ela encontra valores como:

TXT
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:

TXT
Usuário digita: iphnoe
Sistema encontra: iPhone

10. 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:

HTTP
GET /products?category=books
GET /products?category=electronics

Logo, a chave de cache precisa considerar os filtros.

Um primeiro pensamento seria:

TypeScript
const cacheKey = `products:${JSON.stringify(filters)}`;

Mas existe um detalhe importante: a ordem dos parâmetros pode mudar.

Estas URLs são equivalentes:

HTTP
GET /products?status=active&category=books
GET /products?category=books&status=active

Se você montar a chave diretamente da URL, pode gerar cache duplicado.

O ideal é normalizar os filtros antes de montar a chave:

TypeScript
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:

TypeScript
const key1 = createCacheKey('products', {
  status: 'active',
  category: 'books',
});
const key2 = createCacheKey('products', {
  category: 'books',
  status: 'active',
});

Ambas geram:

TypeScript
products:category=books:status=active

Com arrays, você também pode normalizar a ordem:

TypeScript
function normalizeValue(value: unknown) {
  if (Array.isArray(value)) {
    return value.map(String).sort().join(',');
  }
  return String(value);
}

Assim:

TypeScript
/products?tags=tech&tags=book
/products?tags=book&tags=tech

vão ter a mesma chave:

TypeScript
products:tags=book,tech

11. A página vazia que não significa ausência de dados

Imagine este cenário:

HTTP
GET /products?page=3&category=books

Agora o usuário muda o filtro para:

TypeScript
category=electronics

Se 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:

TypeScript
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:

HTTP
GET /products?status=active,pending

Existem alguns padrões comuns.

Com vírgula

HTTP
GET /products?status=active,pending

No backend:

TypeScript
const statuses = String(req.query.status).split(',');

Repetindo o query param

HTTP
GET /products?status=active&status=pending

Dependendo do framework, isso pode chegar como array:

TXT
['active', 'pending']

Com colchetes

HTTP
GET /products?status[]=active&status[]=pending

Nã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:

HTTP
GET /products?status=active,pending

Ele é 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:

HTTP
GET /products?minPrice=100&maxPrice=500

Mas 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:

TXT
minPrice
maxPrice
minStock
maxStock
minRating
maxRating
startDate
endDate
minTotal
maxTotal

Uma alternativa é usar operadores:

HTTP
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]=1000

Tradução:

TXT
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 1000

Esse 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:

JSON
{
  "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:

HTTP
POST /products/search

Isso foge um pouco do REST mais "puro", mas é comum quando filtros são muito complexos.

Para filtros simples, continue com:

HTTP
GET /products?status=active&category=books

Para 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:

TXT
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:

TXT
/products?category=books&status=active

Isso é 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:

HTTP
GET /users/10/orders

E também aplicar filtros:

HTTP
GET /users/10/orders?status=paid

No banco, isso vira algo como:

SQL
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:

HTTP
GET /products?status=active
GET /products?category=books

Você continua buscando o mesmo recurso (produtos). Só está pedindo uma visão filtrada dele.

18. Erros comuns ao implementar filtros

Filtrar tudo no frontend

TypeScript
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:

HTTP
GET /products?status=active

Não limitar busca textual

HTTP
GET /products?search=a

Isso pode retornar dados demais. Você pode exigir um mínimo de caracteres:

TypeScript
search: z.string().min(3).optional()

Esquecer tenant e autorização

HTTP
GET /orders?userId=123

Usuá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

HTTP
GET /users?isAdmin=true

Esse filtro nem deveria existir para usuários comuns.

Um exemplo mais seguro:

TypeScript
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:

TXT
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:

TXT
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.