Dominando Filtros, Paginação e URL Search Params no Next.js

Arquitetura Moderna para CRUDs no Next.js
- Parte 1: Arquitetando um CRUD Escalável
- Parte 2: Construindo uma Camada Profissional de Comunicação
- Parte 3: Gerenciando Estado Remoto com TanStack Query
- Parte 4: Formulários Escaláveis com React Hook Form + Zod
- 👉 Parte 5: Search Params, Filtros e Paginação (Você está aqui)
- Parte 6: UX Moderna: Optimistic Updates e Performance (Em breve)
Até aqui resolvemos dois grandes problemas da nossa arquitetura.
O TanStack Query passou a administrar o estado remoto.
O React Hook Form e o Zod passaram a administrar o estado dos formulários.
Agora surge um terceiro tipo de estado, igualmente importante: o estado da navegação.
Em qual página estamos? Qual filtro está ativo? Qual ordenação foi escolhida?
Essas informações também precisam ter uma fonte única de verdade.
A Armadilha do Estado Local
Nosso CRUD está ganhando uma forma madura. A tabela de usuários já possui cache inteligente, formulários validados e comunicação bem estruturada. Mas agora surge um requisito bastante comum:
"Precisamos de uma barra de busca e paginação."
O instinto natural de quase todo desenvolvedor React é criar estados locais para capturar essas interações.
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
Na sua máquina isso funciona perfeitamente.
Mas, no mundo real, você acabou de introduzir alguns problemas importantes de experiência do usuário.
-
Link quebrado: o usuário encontra um registro na página 3, copia a URL e envia para um colega. Quando ele abre o link, a aplicação volta para a página 1.
-
Botão Voltar perde o contexto: ao abrir um registro e retornar para a listagem, toda a pesquisa desaparece.
-
F5 destrutivo: ao recarregar a página, filtros e paginação são perdidos.
O erro aqui é conceitual.
Filtros, paginação, ordenação e abas principais não são estado local.
Eles representam o estado da própria navegação.
A URL como Fonte da Verdade
Em aplicações web profissionais, a URL deve ser a única fonte da verdade para o que está sendo exibido na tela principal.
Tecnicamente, utilizamos os Search Params — também conhecidos como Query Strings —, ou seja, os parâmetros presentes após o caractere ? da URL.
Observe como a URL passa a representar completamente o estado da tela:
/users
↓
/users?page=3
↓
/users?page=3&search=joao
↓
/users?page=3&search=joao&role=ADMIN
Quando o estado passa a viver na URL, vários problemas desaparecem naturalmente.
O usuário pode compartilhar exatamente a tela que está visualizando.
Os botões Voltar e Avançar do navegador passam a funcionar como esperado, sem qualquer lógica adicional no React.
Além disso, ao atualizar a página, todo o contexto da navegação é preservado.
Outro benefício importante do ecossistema Next.js é que Server Components também conseguem ler os Search Params.
Isso permite renderizar a página diretamente no servidor já com os filtros corretos, melhorando a primeira renderização e permitindo estratégias mais eficientes de SEO.
Abstraindo a Manipulação da URL
Ler parâmetros da URL é simples.
const searchParams = useSearchParams();
const page = Number(searchParams.get("page")) || 1;
const search = searchParams.get("search") || "";
O problema começa quando precisamos alterar esses parâmetros sem perder os demais.
Se o usuário mudar apenas a página, queremos manter todos os filtros existentes.
Para evitar repetir essa lógica pela aplicação inteira, criamos um hook utilitário.
"use client";
import { useCallback } from "react";
import {
usePathname,
useRouter,
useSearchParams,
} from "next/navigation";
export function useUrlFilters() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const page = Number(searchParams.get("page")) || 1;
const search = searchParams.get("search") || "";
const setFilter = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(
searchParams.toString()
);
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
// Se a busca mudou, voltamos para a primeira página.
if (name === "search") {
params.set("page", "1");
}
router.push(`${pathname}?${params.toString()}`);
},
[pathname, router, searchParams]
);
return {
page,
search,
setFilter,
};
}
Apesar de utilizarmos router.push, nenhuma navegação completa acontece.
O App Router atualiza apenas os segmentos necessários da interface, preservando o restante da aplicação e proporcionando uma navegação extremamente fluida.
A Mágica dos Caches Independentes
É aqui que a arquitetura construída na Parte 3 mostra seu verdadeiro valor.
Lembre-se de que o TanStack Query identifica cada consulta através da queryKey.
Quando colocamos os filtros da URL dentro dessa chave, cada combinação passa a possuir um cache completamente independente.
["users", { page: 1, search: "" }]
↓
Cache A
["users", { page: 2, search: "" }]
↓
Cache B
["users", { page: 1, search: "joao" }]
↓
Cache C
Isso significa que o TanStack Query trata cada combinação como uma consulta diferente.
Se o usuário navegar até a página 2 e depois voltar para a página 1, muitas vezes nenhuma nova requisição HTTP será necessária.
Os dados daquela combinação já estarão armazenados em memória.
Integrando Search Params com TanStack Query
Agora basta utilizar os filtros da URL para compor a queryKey.
"use client";
import { useQuery } from "@tanstack/react-query";
import { getUsersAction } from "@/actions/users/get-users-action";
import { useUrlFilters } from "@/hooks/useUrlFilters";
export function UserTable() {
const {
page,
search,
setFilter,
} = useUrlFilters();
const { data, isLoading } = useQuery({
queryKey: ["users", { page, search }],
queryFn: () =>
getUsersAction({
Pagina: page,
Busca: search,
}),
placeholderData: (previousData) => previousData,
});
return (
<div>
{/* Exemplo simplificado */}
<input
type="text"
defaultValue={search}
onChange={(e) =>
setFilter("search", e.target.value)
}
placeholder="Buscar usuários..."
/>
{/* Renderização da tabela */}
</div>
);
}
Observe que não existe nenhum useEffect responsável por sincronizar estados.
A própria URL torna-se a fonte da verdade.
Quando ela muda:
- O
useSearchParams()detecta a alteração. - O componente é renderizado novamente.
- A
queryKeymuda. - O TanStack Query executa automaticamente uma nova consulta.
Cada camada continua responsável apenas pelo seu próprio problema.
Protegendo a Infraestrutura (Debounce)
O exemplo anterior possui uma limitação importante.
Se atualizarmos a URL a cada tecla digitada, a queryKey mudará na mesma velocidade.
Como consequência, o TanStack Query poderá executar dezenas de consultas enquanto o usuário ainda está escrevendo.
A solução é utilizar Debounce.
Em vez de sincronizar imediatamente a URL, aguardamos alguns milissegundos de inatividade antes de atualizar os Search Params.
Conceitualmente, a implementação fica assim:
const [inputValue, setInputValue] = useState(search);
const debouncedValue = useDebounce(
inputValue,
500
);
useEffect(() => {
setFilter("search", debouncedValue);
}, [debouncedValue, setFilter]);
Dessa forma, a URL — e consequentemente a consulta ao servidor — só é atualizada quando o usuário realmente termina de digitar.
Quando NÃO usar Search Params
Nem todo estado deve ser representado pela URL.
Estados temporários da interface continuam pertencendo ao componente.
Estados que normalmente pertencem à URL
- Busca
- Filtros
- Paginação
- Ordenação
- Abas principais
Estados que normalmente pertencem ao componente
- Modais abertos
- Campo em foco
- Estado de animações
- Menus expandidos
- Estado temporário de preenchimento dos inputs
Uma boa regra prática é simples:
Se aquele estado define qual conteúdo está sendo exibido, provavelmente ele pertence à URL.
A URL Agora Faz Parte da Arquitetura
Nos artigos anteriores adicionamos responsabilidades muito bem definidas ao sistema.
- Services para comunicação com a API.
- Server Actions para execução segura no servidor.
- TanStack Query para estado remoto.
- React Hook Form para estado do formulário.
- Zod para contratos e validações.
Agora adicionamos mais uma camada.
A URL.
Ela deixa de ser apenas um endereço e passa a representar o estado da navegação da aplicação.
Sempre que a URL muda, a interface muda junto.
Sempre que a interface precisa mudar de contexto, ela altera a URL.
O TanStack Query apenas reage automaticamente a essas mudanças.
Essa sincronização é justamente o que torna uma aplicação verdadeiramente navegável, compartilhável e previsível.
O Fluxo Arquitetural Completo
Observe como as responsabilidades da aplicação ficaram perfeitamente divididas.
Estado Remoto
↓
TanStack Query
Estado do Formulário
↓
React Hook Form
Contrato dos Dados
↓
Zod
Estado da Navegação
↓
URL Search Params
Cada camada resolve exatamente um problema.
Nenhuma invade a responsabilidade da outra.
É essa separação que permite que a arquitetura continue crescendo sem aumentar a complexidade do código.
Conclusão
Observe como nossa aplicação continua evoluindo sem que precisemos modificar as camadas já construídas.
Transformamos paginação, filtros e ordenação — que normalmente resultariam em diversos useState, useEffect e lógica manual — em um fluxo elegante onde a URL representa o estado da navegação e o restante da arquitetura reage automaticamente.
Agora temos um CRUD com:
- comunicação desacoplada;
- estado remoto centralizado;
- formulários escaláveis;
- validação compartilhada entre cliente e servidor;
- navegação sincronizada pela URL.
Mas ainda falta o último detalhe para entregar uma experiência realmente moderna.
Hoje, sempre que o usuário cria, edita ou remove um registro, ele precisa esperar a resposta do servidor para enxergar a mudança na tela.
E se pudéssemos atualizar a interface imediatamente, antes mesmo da API responder, criando a sensação de um sistema com latência praticamente imperceptível?
É exatamente isso que construiremos no último artigo da série, utilizando Optimistic Updates para tornar nossa interface ainda mais rápida e fluida.