Construindo uma Camada Profissional de Comunicação 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 (Você está aqui)
- Parte 3: Gerenciando Estado Remoto com TanStack Query (Em breve)
- Parte 4: Formulários Escaláveis com React Hook Form + Zod (Em breve)
- Parte 5: Search Params, Filtros e Paginação (Em breve)
- Parte 6: UX Moderna: Optimistic Updates e Performance (Em breve)
O Mesmo CRUD, Três Meses Depois
No artigo anterior organizamos nossa aplicação em camadas.
Separarmos responsabilidades entre:
- Components;
- Services;
- Server Components;
- Server Actions;
- Axios.
Até aquele momento tudo parecia bastante simples.
Mas existe um detalhe importante.
Nenhuma aplicação vive apenas de componentes.
Em algum momento ela precisa conversar com uma API.
E é justamente aqui que muitos projetos começam a perder a organização.
Imagine um CRUD recém criado.
Logo nas primeiras telas encontramos algo parecido com isso.
await axios.get("/users");
await axios.post("/users", data);
await axios.put(`/users/${id}`, data);
await axios.delete(`/users/${id}`);
Funciona?
Sem dúvida.
Na verdade, para um projeto pequeno provavelmente continuará funcionando por bastante tempo.
O problema aparece quando o projeto deixa de ser pequeno.
Imagine que, alguns meses depois, surgem novos requisitos.
- autenticação JWT;
- refresh token;
- múltiplos ambientes;
- monitoramento;
- tratamento padronizado de erros;
- novos módulos reutilizando a mesma API.
Agora aquelas poucas chamadas HTTP se transformaram em centenas.
E começam a surgir perguntas.
- Onde alteramos a URL da API?
- Como enviar o token automaticamente?
- Como tratar respostas 401?
- Como adicionar headers para todas as requisições?
- Como registrar logs?
- Como trocar Axios por outra biblioteca no futuro?
Perceba que nenhuma dessas perguntas pertence ao React.
Também não pertencem aos componentes.
Todas pertencem à infraestrutura da aplicação.
E é exatamente por isso que precisamos construir uma camada de comunicação.
A Comunicação Também Precisa de Arquitetura
No artigo anterior vimos que cada camada possui apenas uma responsabilidade.
Na comunicação acontece exatamente a mesma coisa.
┌─────────────────────────┐
│ Components │
└─────────────┬───────────┘
│
▼
┌─────────────────────────┐
│ Server Components │
│ ou Server Actions │
└─────────────┬───────────┘
│
▼
┌─────────────────────────┐
│ Services │
└─────────────┬───────────┘
│
▼
┌─────────────────────────┐
│ Axios │
└─────────────┬───────────┘
│
▼
┌─────────────────────────┐
│ API │
└─────────────────────────┘
Cada camada conhece apenas a responsabilidade da próxima.
Isso significa que um componente React não deveria saber:
- qual endpoint chamar;
- qual header enviar;
- como renovar um token;
- como tratar um erro HTTP;
- qual biblioteca está sendo utilizada para comunicação.
Essas responsabilidades pertencem à infraestrutura.
Quanto menos detalhes vazarem para a interface, mais fácil será evoluir a aplicação.
O Primeiro Passo: Criando um Cliente HTTP
Antes de criar qualquer Service, precisamos resolver um problema.
Quem será responsável por conversar com a API?
Poderíamos importar o Axios diretamente em todos os arquivos.
Mas isso faria cada componente conhecer detalhes da comunicação.
Em vez disso, criaremos um cliente HTTP reutilizável.
Nossa estrutura ficará assim.
src/
├── app/
├── components/
├── services/
└── lib/
└── api.ts
O arquivo api.ts será o único responsável por configurar o Axios.
Todos os Services utilizarão essa instância.
Nunca o Axios diretamente.
Criando o Axios
// src/lib/api.ts
import axios from "axios";
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
À primeira vista parece apenas uma configuração simples.
Mas essa decisão muda completamente a arquitetura.
Agora toda comunicação da aplicação passa por um único lugar.
Se amanhã precisarmos alterar:
- URL da API;
- timeout;
- headers padrão;
- compressão;
- monitoramento;
- proxy;
basta modificar um único arquivo.
Todo o restante da aplicação continua funcionando.
Essa é uma das principais vantagens da separação por camadas.
Um Lugar Para Regras Globais
Conforme a aplicação cresce, esse arquivo também cresce.
É comum adicionarmos funcionalidades como:
- interceptors;
- autenticação;
- renovação de token;
- logs;
- monitoramento;
- métricas;
- retry automático.
Por isso preferimos centralizar tudo aqui.
Em vez de repetir essas regras em dezenas de componentes.
É exatamente esse tipo de organização que mantém um projeto saudável conforme ele evolui.
Interceptando Requisições
Uma das funcionalidades mais úteis do Axios são os interceptors.
Eles permitem executar uma ação antes que qualquer requisição seja enviada.
api.interceptors.request.use((config) => {
// exemplo
config.headers["X-App-Version"] = "1.0.0";
return config;
});
Da mesma forma, também podemos interceptar todas as respostas.
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
console.error("Usuário não autenticado.");
}
return Promise.reject(error);
}
);
Perceba uma coisa importante.
Nenhum componente conhece essas regras.
Nenhum Service precisa saber que existe um interceptor.
Toda essa responsabilidade permanece concentrada na infraestrutura.
E isso nos leva ao próximo passo.
Agora que temos uma forma padronizada de conversar com a API, precisamos criar uma camada que represente as operações da nossa aplicação.
É aqui que entram os Services.
Criando Nossa Primeira Camada de Serviços
Agora que toda comunicação HTTP está centralizada, surge uma pergunta importante.
Quem deve utilizar o Axios?
Uma resposta comum seria:
"Os componentes."
Mas isso nos levaria exatamente ao problema que queremos evitar.
Imagine um componente como este.
export default async function UsersPage() {
const response = await api.get("/users");
return (
<UserTable users={response.data} />
);
}
À primeira vista parece um código perfeitamente aceitável.
Mas esse componente acabou de assumir responsabilidades que não pertencem a ele.
Agora ele conhece:
- o cliente HTTP;
- o endpoint;
- como a API retorna os dados;
- como uma requisição deve ser feita.
Isso cria um acoplamento desnecessário.
O componente deveria apenas solicitar informações.
Não deveria conhecer como elas são obtidas.
É exatamente aqui que entram os Services.
O Papel dos Services
Se o Axios representa a infraestrutura de comunicação, os Services representam as operações da aplicação.
Eles deixam de falar em HTTP.
E passam a falar em negócio.
Veja a diferença.
Em vez de escrevermos isso:
await api.get("/users");
Passamos a escrever:
await getUsersService();
Em vez disso:
await api.post("/users", data);
Temos:
await createUserService(data);
Pode parecer apenas uma troca de nomes.
Mas não é.
Agora quem consome esse código não precisa saber:
- qual URL será chamada;
- qual método HTTP será utilizado;
- quais parâmetros devem ser enviados.
Ele apenas solicita uma operação.
Organizando os Services
Uma organização bastante comum é separar os arquivos por domínio.
src/
└── services/
└── users/
├── get-users.ts
├── get-user.ts
├── create-user.ts
├── update-user.ts
└── delete-user.ts
Conforme novos módulos surgirem, basta repetir o padrão.
services/
├── users/
├── products/
├── customers/
├── categories/
└── orders/
Essa organização facilita bastante a navegação pelo projeto.
Principalmente quando a aplicação cresce.
Nosso Primeiro Service
Vamos criar a operação responsável por listar usuários.
// services/users/get-users.ts
import { api } from "@/lib/api";
import { ApiResponse } from "@/types/api";
import { User } from "@/types/user";
export interface GetUsersParams {
page?: number;
limit?: number;
search?: string;
}
export type GetUsersResponse = ApiResponse<{
items: User[];
}>;
export async function getUsersService(
params: GetUsersParams
): Promise<GetUsersResponse> {
const response = await api.get<GetUsersResponse>(
"/users",
{
params,
}
);
return response.data;
}
Observe uma característica importante.
Esse arquivo não importa absolutamente nada do Next.js.
Não existe:
"use server";cookies();headers();redirect();revalidatePath().
O Service conhece apenas duas coisas.
O cliente HTTP.
E a API.
Nada mais.
Essa independência é justamente o que torna essa camada reutilizável.
Limpando os Parâmetros
Quando começamos a implementar filtros, um problema bastante comum aparece.
Imagine o seguinte objeto.
{
page: 1,
limit: 10,
search: "",
status: undefined
}
Dependendo da configuração do Axios, a URL poderá ficar parecida com esta.
/users?page=1&limit=10&search=&status=undefined
Embora algumas APIs ignorem esses valores, outras acabam interpretando esses parâmetros de forma incorreta.
Uma abordagem simples é remover todos os campos vazios antes da requisição.
export function removeEmptyFields<T extends object>(
object: T
): T {
return Object.fromEntries(
Object.entries(object).filter(([_, value]) => {
return (
value !== undefined &&
value !== null &&
value !== ""
);
})
) as T;
}
Agora basta utilizar.
const response = await api.get("/users", {
params: removeEmptyFields(params),
});
Esse pequeno utilitário acaba sendo reutilizado em praticamente todos os Services de consulta.
Um Padrão que Escala
Depois do primeiro Service, os demais seguem exatamente a mesma ideia.
Buscar um usuário.
export async function getUserService(id: number) {
const response = await api.get(`/users/${id}`);
return response.data;
}
Criar.
export async function createUserService(
data: CreateUserDto
) {
const response = await api.post(
"/users",
data
);
return response.data;
}
Atualizar.
export async function updateUserService(
id: number,
data: UpdateUserDto
) {
const response = await api.put(
`/users/${id}`,
data
);
return response.data;
}
Excluir.
export async function deleteUserService(id: number) {
await api.delete(`/users/${id}`);
}
Depois de algum tempo você percebe que todos seguem exatamente o mesmo padrão.
Isso é um excelente sinal.
Uma arquitetura previsível é muito mais fácil de evoluir do que uma arquitetura onde cada módulo resolve o mesmo problema de uma forma diferente.
Quem Deve Chamar os Services?
Agora que nossa camada de comunicação está pronta, surge uma última pergunta.
Quem deve utilizar esses Services?
A resposta é:
depende do tipo de operação.
No Next.js moderno existem dois fluxos bastante claros.
Para leitura de dados, normalmente utilizamos Server Components.
Para mutações, utilizamos Server Actions.
Mas ambos possuem algo em comum.
Nenhum deles conversa diretamente com o Axios.
Ambos utilizam os Services.
Essa separação garante que toda comunicação HTTP permaneça concentrada em um único lugar.
No próximo trecho vamos conectar essas peças e entender exatamente quando utilizar um Server Component e quando uma Server Action faz sentido.
Quem Consome os Services?
Nossa camada de comunicação está pronta.
Até aqui construímos:
- um cliente HTTP reutilizável;
- uma configuração centralizada do Axios;
- uma coleção de Services organizados por domínio.
Agora surge uma última pergunta.
Quem deve utilizar esses Services?
A resposta depende do fluxo da aplicação.
No Next.js moderno existem dois cenários bastante comuns.
O primeiro acontece quando apenas precisamos carregar informações para renderizar uma página.
O segundo acontece quando precisamos alterar dados da aplicação.
Embora ambos utilizem os mesmos Services, cada um possui responsabilidades diferentes.
Leitura de Dados
Quando uma página precisa apenas carregar informações, normalmente utilizamos um Server Component.
O fluxo fica assim.
┌──────────────────────┐
│ Server Component │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ getUsersService │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Axios │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ API │
└──────────────────────┘
Na prática, o código fica extremamente simples.
// app/users/page.tsx
import { getUsersService } from "@/services/users/get-users";
export default async function UsersPage() {
const response = await getUsersService({
page: 1,
limit: 10,
});
return (
<UserTable
users={response.data.items}
/>
);
}
Perceba que o componente não conhece:
- endpoints;
- headers;
- autenticação;
- interceptors;
- Axios.
Ele apenas solicita uma operação.
Toda comunicação permanece encapsulada dentro do Service.
Escrita de Dados
O cenário muda quando precisamos modificar informações.
Criar um usuário.
Editar um cadastro.
Excluir um registro.
Essas operações normalmente precisam executar regras específicas do Next.js.
Por exemplo:
- validar permissões;
- acessar cookies;
- redirecionar usuários;
- revalidar páginas;
- invalidar cache.
Essas responsabilidades não pertencem ao Service.
Elas pertencem às Server Actions.
O fluxo passa a ser este.
┌──────────────────────┐
│ Formulário │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Server Action │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ createUserService() │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Axios │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ API │
└──────────────────────┘
Observe que o Service continua exatamente o mesmo.
Quem mudou foi apenas a camada responsável por utilizá-lo.
Criando Nossa Primeira Server Action
"use server";
import { revalidatePath } from "next/cache";
import { createUserService } from "@/services/users/create-user";
import { CreateUserDto } from "@/types/user";
export async function createUserAction(
data: CreateUserDto
) {
await createUserService(data);
revalidatePath("/users");
}
Perceba a separação de responsabilidades.
O Service sabe:
- qual endpoint chamar;
- qual método HTTP utilizar;
- como conversar com a API.
A Action sabe:
- que está rodando no servidor;
- quando revalidar o cache;
- quando redirecionar;
- quando validar permissões;
- quando acessar cookies.
Cada camada possui apenas uma responsabilidade.
Foi exatamente esse princípio que vimos no primeiro artigo da série.
O Que Ganhamos Com Essa Separação?
Depois de separar nossa comunicação em camadas, algumas vantagens aparecem naturalmente.
✅ Componentes deixam de conhecer detalhes da API.
✅ Toda configuração HTTP fica centralizada.
✅ Mudanças de infraestrutura acontecem em poucos arquivos.
✅ Os Services podem ser reutilizados por diferentes fluxos.
✅ As Server Actions ficam responsáveis apenas pelas regras específicas do Next.js.
Mais importante do que isso.
Cada camada passa a possuir um único motivo para mudar.
Essa é uma das características mais importantes de uma arquitetura escalável.
O Que Ainda Está Faltando?
Nossa comunicação está muito melhor do que no início do artigo.
Mas ainda existe um problema.
Imagine uma tabela de usuários.
Sempre que um componente cliente precisar buscar dados, teremos que controlar manualmente:
- loading;
- erros;
- cache;
- sincronização;
- refetch;
- atualização após uma mutação.
Funciona.
Mas rapidamente começaremos a repetir muito código.
E repetir código quase sempre é um sinal de que está faltando uma abstração.
É exatamente aqui que entra o TanStack Query.
Conclusão
Ao longo deste artigo construímos a infraestrutura responsável por toda comunicação da aplicação.
Criamos:
- uma instância reutilizável do Axios;
- uma camada de Services organizada por domínio;
- um fluxo claro para leituras utilizando Server Components;
- uma camada de Server Actions responsável pelas mutações.
Perceba que nenhuma dessas decisões foi tomada pensando em uma biblioteca específica.
Todas foram tomadas pensando em responsabilidades.
Essa é a principal diferença entre uma aplicação que apenas funciona e uma aplicação preparada para crescer.
No próximo artigo vamos adicionar uma nova camada à arquitetura.
Sem alterar nossos Services.
Sem alterar nossa API.
Sem alterar nossos componentes.
Vamos apenas resolver um problema que ainda continua em aberto.
Como armazenar, sincronizar e invalidar dados de forma automática?
É exatamente isso que o TanStack Query faz.
E o melhor de tudo.
Nossa arquitetura já está preparada para recebê-lo.