React nextjs

Escopo: TypeScript. Guia baseado em React 19.2 com Next.js 16 (App Router).

React é uma biblioteca de UI (User Interface · Interface do Usuário). Next.js é o framework: roteamento por arquivo, Server Components, Server Actions e otimizações de build integradas. Este guia mostra como implementar os contratos de operation-flow.md e frontend-flow.md com React e Next.js.

Conceitos fundamentais

ConceitoO que é
RSC (React Server Component · Componente de Servidor)Componente renderizado no servidor: acessa banco e APIs diretamente, sem enviar código ao cliente
RCC (React Client Component · Componente de Cliente)Componente com "use client": acessa estado, eventos e APIs do navegador
Server Action (Ação de Servidor)Função assíncrona com "use server": executa no servidor, invocável em formulários e event handlers
App Router (Roteador de Aplicação)Sistema de roteamento do Next.js baseado em hierarquia de pastas em app/
Proxy (proxy de rede)Arquivo proxy.ts executado antes do handler da rota: guards de autenticação, redirecionamentos
Hydration (hidratação)Processo de sincronizar o HTML renderizado no servidor com o estado do cliente
HMAC (Hash-based Message Authentication Code, Código de Autenticação de Mensagem Baseado em Hash)Mecanismo que valida a origem e integridade de um webhook comparando assinaturas com segredo compartilhado

Fluxo de Operação

Next.js funciona como frontend puro (chamando qualquer backend) ou como fullstack (executando backend no mesmo projeto). O slice features/ é o mesmo nos dois cenários; o que muda é o que cada artefato faz dentro dele.

Next.js como frontend

Next.js cuida de UI e roteamento. Todo acesso a dados passa pelo apiClient, que aponta para qualquer backend (C#, Java, Node, etc.).

Render: URL → proxy.ts → page.tsx → Service → apiClient → API

Interação: User Action → RCC → Hook → Service → apiClient → API

PassoO que fazDomínio
URLNavegação inicia a resolução da rota no App Routernavegador
proxy.tsGuard de autenticação executado antes de qualquer renderapp/
page.tsxOrquestrador RSC: chama Service, monta componentes com dadosapp/
RCCComponente com "use client": captura estado e eventos do browserfeatures/
HookGerencia estado de UI (data, error, isLoading)features/
ServiceChama apiClient e transforma para view typefeatures/
apiClientÚnico caller de rede: retorna Result<T>lib/
ZodValida os dados no limite entre a API e o sistemafeatures/
APIBackend externo (C#, Java, Node, etc.)backend
app/
└── orders/[id]/page.tsxRSC: chama Service, monta componentes
features/orders/
├── components/
│   ├── OrderDetail.tsxRSC: renderiza dados
│   └── CreateOrderForm.tsxRCC "use client": captura estado e eventos
├── hooks/use-orders.ts       → estado de UI: data, error, isLoading
├── services/order.ts         → chama apiClient, transforma para view type
└── schemas/order.tsZod: valida no limite da API
lib/
└── api-client.ts             → único caller de rede: retorna Result<T>
proxy.ts                      → guard: executa antes de qualquer render

Next.js fullstack

Next.js é frontend e backend. page.tsx acessa o banco diretamente via Repository, sem passar pela própria API (Application Programming Interface · Interface de Programação de Aplicações) Route. API Routes existem para clientes externos (mobile, integrações B2B). RCC e hooks funcionam igual ao cenário anterior para interações client-side.

Leitura: URL → proxy.ts → page.tsx → Repository → render

Escrita: Submit → Server Action → Zod → Repository → updateTag → result

Webhook: POST → route.ts → HMAC → idempotência → 200 OK → enqueue → Worker

PassoO que fazDomínio
URLNavegação inicia a resolução da rota no App Routernavegador
proxy.tsGuard de autenticação executado antes de qualquer renderapp/
page.tsxOrquestrador RSC: acessa Repository diretamente, monta componentesapp/
Server ActionExecuta no servidor: valida, persiste e invalida cachefeatures/
route.tsNamed export por método HTTP: entry point para clientes externosapp/api/
ZodSchema compartilhado: valida input no cliente e no servidorfeatures/
ServiceRegras de negócio; transforma dados para persistência ou respostafeatures/
RepositoryAcesso direto ao banco: queries e mutaçõesfeatures/
updateTagInvalida o cache após escrita: garante read-your-writesfeatures/
HMACValida a assinatura do webhook sobre o raw bodyapp/api/
enqueueAdiciona job à fila para processamento assíncronolib/
WorkerConsome e executa o job de forma independenteexterno
ResponseNextResponse.json() tipado com status explícitoapp/api/
app/
├── orders/[id]/page.tsxRSC: acessa Repository diretamente
└── api/
    ├── orders/route.tsAPI Route: para clientes externos
    └── webhooks/[provider]/route.tsWebhook Handler: HMAC, idempotência, enqueue
features/orders/
├── components/
│   ├── OrderDetail.tsxRSC: renderiza dados
│   └── CreateOrderForm.tsxRCC "use client": captura estado e eventos
├── hooks/use-orders.ts               → estado de UI: data, error, isLoading
├── actions/order.tsServer Action: valida, persiste, invalida cache
├── services/order.ts                 → regras de negócio, transforma para view type
├── repositories/order.ts             → acesso direto ao banco
├── queries/orders.ts"use cache": leitura cacheada com tag
└── schemas/order.tsZod: contrato cliente/servidor
lib/
├── api-client.ts                     → chama APIs externas (quando necessário)
└── queue.ts                          → enfileira jobs para processamento assíncrono
proxy.ts                              → guard: executa antes de qualquer render

O componente começa no servidor e só vai para o cliente quando precisa

Todo componente nasce como RSC, e ganha "use client" apenas quando precisa de algo que só existe no navegador: estado, efeito ou evento do usuário. Marcar como cliente o que não precisa disso manda código para o navegador à toa, e o texto estático da página passa a viajar junto com o JavaScript que o renderiza.

O RSC cumpre o papel do Loader de frontend-flow.md: ele busca os dados antes de renderizar, o que dispensa o estado de carregamento e evita a cascata de requisições que aparece quando cada componente busca o que precisa depois de já estar na tela.

❌ Ruim: o componente vira cliente sem ter nenhuma interatividade
"use client";

import { useEffect, useState } from "react";

export function ProductDetail({ id }: { id: string }) {
  const [product, setProduct] = useState<Product | null>(null);

  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then((res) => res.json())
      .then(setProduct);
  }, [id]);

  if (!product) return <Skeleton />;

  return <h1>{product.name}</h1>;
}
✅ Bom: o componente de servidor busca os dados e renderiza já preenchido
import { productRepository } from "@/lib/repositories/product";
import { NotFound } from "@/components/ui/NotFound";

interface ProductDetailProps {
  id: string;
}

export async function ProductDetail({ id }: ProductDetailProps) {
  const product = await productRepository.findById(id);
  if (!product) return <NotFound entity="produto" />;

  return <h1>{product.name}</h1>;
}

O page.tsx é o orquestrador da rota. Ele chama quem busca os dados e monta os componentes que os exibem, e a regra de negócio fica com o Service ou o Repository. Um page.tsx que calcula desconto e monta query é onde a lógica se esconde de quem a procura.

❌ Ruim: o page.tsx busca dados e ainda aplica regra de negócio
export default async function OrderPage({ params }: { params: { id: string } }) {
  const order = await db.orders.findUnique({
    where: { id: params.id },
    include: { items: true, customer: true },
  });

  if (!order) return <div>Pedido não encontrado</div>;

  const total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0);

  return (
    <div>
      {order.customer.name}: R$ {total}
    </div>
  );
}
✅ Bom: page.tsx como orquestrador
// app/orders/[id]/page.tsx
import { orderRepository } from "@/lib/repositories/order";
import { OrderDetail } from "@/components/orders/OrderDetail";
import { NotFound } from "@/components/ui/NotFound";

interface OrderPageProps {
  params: Promise<{ id: string }>;
}

export default async function OrderPage({ params }: OrderPageProps) {
  const { id } = await params;
  const order = await orderRepository.findWithDetails(id);
  if (!order) return <NotFound entity="pedido" />;

  return <OrderDetail order={order} />;
}

As props do componente ganham uma interface com sufixo Props

As props seguem a regra das funções TypeScript: a partir de três campos, o tipo sai da assinatura e vira uma interface própria, com o sufixo Props. O prefixo I continua fora, e o tipo escrito dentro dos parênteses também: ele empurra a lista de campos para o meio da assinatura, e o leitor atravessa cinco linhas antes de ver o que o componente recebe.

❌ Ruim: o tipo escrito dentro dos parênteses do componente
export function OrderCard({
  id,
  status,
  total,
  customerName,
}: {
  id: string;
  status: OrderStatus;
  total: number;
  customerName: string;
}) {
  return (
    <div>
      {customerName}: {total}
    </div>
  );
}
✅ Bom: interface separada com sufixo Props
interface OrderCardProps {
  id: string;
  status: OrderStatus;
  total: number;
  customerName: string;
}

export function OrderCard({ id, status, total, customerName }: OrderCardProps) {
  return (
    <div>
      {customerName}: {total}
    </div>
  );
}

O caminho é componente, hook, service, apiClient

Nos componentes de cliente, o fluxo de operation-flow.md aparece em três peças. O hook guarda o estado da tela (data, error, isLoading) e não sabe de rede. O Service aplica a regra e transforma o dado no formato que a tela usa. O apiClient é o único lugar que fala com a rede.

Quando tudo isso acontece dentro do componente, com fetch e useState juntos, a chamada de rede não é reaproveitável, o tratamento de erro se repete a cada tela, e testar a regra exige montar o componente.

O retorno do hook vira interface quando tem três valores ou mais.

❌ Ruim: fetch, estado e regra dentro do componente
"use client";

export function OrderList() {
  const [orders, setOrders] = useState<Order[]>([]);

  useEffect(() => {
    fetch("/api/orders")
      .then((res) => res.json())
      .then(setOrders);
  }, []);

  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id}>{o.id}</li>
      ))}
    </ul>
  );
}
✅ Bom: hook encapsula estado; service encapsula chamada de rede
// lib/services/order.ts
import { apiClient } from "@/lib/api-client";
import type { OrderView } from "@/lib/types/views";

export async function listOrders(): Promise<OrderView[]> {
  const result = await apiClient.get<Order[]>("/orders");
  if (!result.success) return [];

  const orderViews = result.data.map(toOrderView);
  return orderViews;
}
// hooks/use-orders.ts
interface UseOrdersResult {
  orders: OrderView[];
  isLoading: boolean;
  error: string | null;
}

export function useOrders(): UseOrdersResult {
  const [orders, setOrders] = useState<OrderView[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    listOrders()
      .then(setOrders)
      .catch(() => setError("Não foi possível carregar os pedidos."))
      .finally(() => setIsLoading(false));
  }, []);

  const ordersResult = { orders, isLoading, error };
  return ordersResult;
}
// components/orders/OrderList.tsx
"use client";

export function OrderList() {
  const { orders, isLoading, error } = useOrders();

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage message={error} />;

  return (
    <ul>
      {orders.map((order) => (
        <li key={order.id}>{order.customerName}</li>
      ))}
    </ul>
  );
}

A checagem de acesso mora no Proxy, antes de qualquer render

A verificação de autenticação e de permissão fica no proxy.ts, que roda antes de a página começar a renderizar, como manda o frontend-flow.md.

Feita dentro do componente, ela chega tarde. O componente renderiza, o useEffect roda depois, e o redirecionamento acontece por último. Nesse intervalo o conteúdo restrito já foi pintado na tela, e o usuário sem permissão chega a vê-lo antes de ser mandado embora.

❌ Ruim: a checagem no componente deixa o conteúdo aparecer antes do redirecionamento
"use client";

export default function DashboardPage() {
  const { user } = useAuth();

  useEffect(() => {
    if (!user) router.push("/login");
  }, [user]);

  return <Dashboard />;
}
✅ Bom: guard no Proxy, antes de qualquer render
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySession } from "@/lib/auth/session";

export async function proxy(request: NextRequest): Promise<NextResponse> {
  const session = await verifySession(request);

  if (!session) {
    const loginUrl = new URL("/login", request.url);
    const redirectResponse = NextResponse.redirect(loginUrl);
    return redirectResponse;
  }

  const nextResponse = NextResponse.next();
  return nextResponse;
}

export const config = {
  matcher: ["/dashboard/:path*", "/orders/:path*"],
};

O formulário valida com o mesmo schema no cliente e no servidor

O fluxo de frontend-flow.md vale aqui inteiro. O schema Zod é a única declaração das regras do formulário, e os dois lados o usam: o cliente para avisar o usuário na hora, o servidor para não confiar no que chegou. A Server Action executa a escrita na ordem de sempre: valida, aplica a regra, persiste, devolve o resultado.

O erro volta estruturado, campo a campo, e é isso que permite à tela mostrar a mensagem embaixo do input que a causou. Um ok: false sozinho obriga o formulário a exibir um aviso genérico, e o usuário fica procurando qual campo errou.

❌ Ruim: validação escrita à mão, sem schema, e o erro volta sem estrutura
// app/actions/order.ts
"use server";

export async function createOrder(formData: FormData) {
  const productId = formData.get("productId") as string;
  const quantity = parseInt(formData.get("quantity") as string);

  if (!productId || quantity < 1) return { ok: false };

  await db.orders.create({ data: { productId, quantity } });

  return { ok: true };
}
✅ Bom: um schema para os dois lados, e o erro volta campo a campo
// lib/schemas/order.ts
import { z } from "zod";

export const createOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().min(1),
});

export type CreateOrderInput = z.infer<typeof createOrderSchema>;
// app/actions/order.ts
"use server";

import { createOrderSchema } from "@/lib/schemas/order";
import { orderRepository } from "@/lib/repositories/order";

interface CreateOrderResult {
  ok: boolean;
  orderId?: string;
  fieldErrors?: Partial<Record<keyof CreateOrderInput, string[]>>;
  formError?: string;
}

export async function createOrder(
  _prev: CreateOrderResult | null,
  formData: FormData,
): Promise<CreateOrderResult> {
  const parsed = createOrderSchema.safeParse({
    productId: formData.get("productId"),
    quantity: Number(formData.get("quantity")),
  });

  if (!parsed.success) {
    const fieldErrors = parsed.error.flatten().fieldErrors;
    const failResult = { ok: false, fieldErrors };
    return failResult;
  }

  const order = await orderRepository.create(parsed.data);
  const orderResult = { ok: true, orderId: order.id };
  return orderResult;
}
// components/orders/CreateOrderForm.tsx
"use client";

import { useActionState } from "react";
import { createOrder } from "@/app/actions/order";

export function CreateOrderForm() {
  const [state, action, isPending] = useActionState(createOrder, null);

  return (
    <form action={action}>
      <fieldset disabled={isPending}>
        <input name="productId" type="text" aria-describedby="productId-error" />
        {state?.fieldErrors?.productId && (
          <span id="productId-error">{state.fieldErrors.productId[0]}</span>
        )}

        <input name="quantity" type="number" min="1" aria-describedby="quantity-error" />
        {state?.fieldErrors?.quantity && (
          <span id="quantity-error">{state.fieldErrors.quantity[0]}</span>
        )}

        {state?.formError && <p role="alert">{state.formError}</p>}

        <button type="submit">{isPending ? "Enviando..." : "Criar pedido"}</button>
      </fieldset>
    </form>
  );
}

<fieldset disabled> cobre todos os campos durante a requisição: previne double-submit (envio duplicado) sem desabilitar cada input individualmente.

As rotas de API

As rotas ficam em app/api/[recurso]/route.ts, e cada método HTTP (HyperText Transfer Protocol · Protocolo de Transferência de Hipertexto) vira uma função exportada com o nome dele. O caminho dentro da rota é o mesmo do operation-flow.md: valida a entrada, aplica a regra, persiste, devolve a resposta.

❌ Ruim: regra de negócio dentro da rota, sem schema, e o status escrito na mão
// app/api/orders/route.ts
export async function POST(request: NextRequest) {
  const body = await request.json();

  if (!body.productId || !body.quantity) {
    return new Response("Invalid", { status: 400 });
  }

  const order = await db.orders.create({ data: body });

  return Response.json(order);
}
✅ Bom: schema Zod, repository, resposta estruturada
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createOrderSchema } from "@/lib/schemas/order";
import { orderRepository } from "@/lib/repositories/order";

export async function GET(): Promise<NextResponse> {
  const orders = await orderRepository.listAll();
  const response = NextResponse.json(orders);
  return response;
}

export async function POST(request: NextRequest): Promise<NextResponse> {
  const body = await request.json();
  const parsed = createOrderSchema.safeParse(body);

  if (!parsed.success) {
    const fieldErrors = parsed.error.flatten().fieldErrors;
    const errorResponse = NextResponse.json({ fieldErrors }, { status: 422 });
    return errorResponse;
  }

  const order = await orderRepository.create(parsed.data);
  const createdResponse = NextResponse.json(order, { status: 201 });
  return createdResponse;
}

O handler de webhook

O handler segue o fluxo de backend-flow.md: pega o corpo cru da requisição antes de qualquer conversão, confere a assinatura HMAC, verifica se aquele evento já foi recebido, responde 200 e só então processa.

Duas regras não abrem exceção. A primeira é responder 200 antes de processar: provedores como Stripe e GitHub reenviam o evento se a resposta demorar mais de 30 segundos, e um processamento lento vira evento duplicado. A segunda é calcular o HMAC sobre o corpo cru. Converter o JSON (JavaScript Object Notation · Notação de Objetos JavaScript) primeiro reordena campos e muda espaços, e a assinatura calculada sobre esse texto não bate mais com a que o provedor enviou.

POST /api/webhooks/[provider] → captura raw body → valida HMAC → checa idempotência → 200 OK → enfileira → processa
❌ Ruim: assina o JSON já convertido, compara com ===, e processa antes de responder
// app/api/webhooks/[provider]/route.ts
export async function POST(request: NextRequest) {
  const body = await request.json();
  const signature = request.headers.get("x-signature");

  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(JSON.stringify(body))
    .digest("hex");

  if (expected !== signature) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  await processWebhookPayload(body);

  return NextResponse.json({ ok: true });
}
✅ Bom: raw body, timingSafeEqual, idempotência, 200 antes de enfileirar
// app/api/webhooks/[provider]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "crypto";
import { webhookRepository } from "@/lib/repositories/webhook";
import { jobQueue } from "@/lib/queue";

export async function POST(request: NextRequest): Promise<NextResponse> {
  const rawBody = await request.text();
  const receivedSignature = request.headers.get("x-signature") ?? "";
  const eventId = request.headers.get("x-event-id") ?? "";

  const expectedSignature = createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(rawBody)
    .digest("hex");

  const signaturesMatch =
    expectedSignature.length === receivedSignature.length &&
    timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(receivedSignature));

  if (!signaturesMatch) {
    const unauthorizedResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    return unauthorizedResponse;
  }

  const alreadyReceived = await webhookRepository.findByEventId(eventId);

  if (alreadyReceived) {
    const duplicateResponse = NextResponse.json({ ok: true });
    return duplicateResponse;
  }

  await webhookRepository.save({ eventId, rawBody });
  await jobQueue.enqueue("webhook.process", { eventId });

  const acceptedResponse = NextResponse.json({ ok: true });
  return acceptedResponse;
}

A checagem de comprimento antes do timingSafeEqual é obrigatória: a função lança exceção quando os dois buffers têm tamanhos diferentes. Devolver 200 para um evento repetido é o contrato certo, e não é omissão: o provedor precisa saber que a entrega chegou, e o que ele faz com essa informação é parar de reenviar.

Cache com use cache

No Next.js 16, nada é guardado em cache sem que alguém peça. Todo código dinâmico roda a cada requisição, e a diretiva "use cache" é o que marca uma função ou um componente como cacheável.

As três peças se dividem assim: cacheLife() define quanto tempo o dado vale, cacheTag() dá um nome ao dado para poder invalidá-lo depois, e updateTag(), chamado dentro de uma Server Action, descarta o cache daquele nome logo após a escrita. É esta última que garante que o usuário veja a própria alteração na hora, em vez de continuar olhando a versão antiga da tela.

✅ Bom: função cacheada com perfil e tag
// lib/queries/orders.ts
"use cache";

import { cacheLife, cacheTag } from "next/cache";
import { orderRepository } from "@/lib/repositories/order";

export async function getCachedOrders(): Promise<Order[]> {
  cacheLife("hours");
  cacheTag("orders");

  const orders = await orderRepository.listAll();
  return orders;
}
✅ Bom: Server Action invalida o cache após escrita
// app/actions/order.ts
"use server";

import { updateTag } from "next/cache";
import { createOrderSchema } from "@/lib/schemas/order";
import { orderRepository } from "@/lib/repositories/order";

export async function createOrder(
  _prev: CreateOrderResult | null,
  formData: FormData,
): Promise<CreateOrderResult> {
  const parsed = createOrderSchema.safeParse({
    productId: formData.get("productId"),
    quantity: Number(formData.get("quantity")),
  });

  if (!parsed.success) {
    const fieldErrors = parsed.error.flatten().fieldErrors;
    const failResult = { ok: false, fieldErrors };
    return failResult;
  }

  const order = await orderRepository.create(parsed.data);

  updateTag("orders");

  const orderResult = { ok: true, orderId: order.id };
  return orderResult;
}

updateTag() só está disponível em Server Actions. Para invalidação externa (webhook, cron), use revalidateTag(tag, "max") com perfil cacheLife.

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.