Stack e Engenharia

4 de fevereiro de 2026 · 12 min de leitura

O App Router do Next.js 14 mudou bastante a forma de pensar sobre onde o código roda — e com isso trouxe uma dúvida recorrente em projetos SaaS: o que vai no Server Component, o que vai no Client Component, e o que precisa definitivamente morar no backend? A resposta errada aqui não quebra o projeto no dia um. Ela aparece mais tarde, quando você precisa proteger um endpoint de billing, quando um tenant consegue ver dados de outro porque a lógica ficou no client, ou quando o time começa a duplicar regra de negócio entre o frontend e a API. Este post é sobre onde traçar essas fronteiras — e por quê elas importam mais do que parecem.

O que mudou com o App Router (e o que isso significa para SaaS)

Com o Pages Router, a divisão era relativamente clara: tudo no browser, exceto o que você colocava explicitamente em getServerSideProps ou getStaticProps. Com o App Router, a lógica é invertida — por padrão, tudo é Server Component. Você precisa optar explicitamente pelo client com 'use client'.

Isso é uma mudança de paradigma, não só de sintaxe. E para SaaS, traz implicações concretas.

Server Components podem buscar dados diretamente — de um banco, de uma API interna, de um serviço — sem expor essa lógica ao browser. Não há bundle JavaScript enviado para o client, não há fetch visível no Network tab, não há estado de loading para gerenciar. Para telas de dashboard, listagens, relatórios: isso é exatamente o que você quer.

Mas Server Components não têm estado, não têm interatividade, não têm acesso a APIs do browser. Formulários com validação em tempo real, componentes que reagem a input do usuário, qualquer coisa com useState ou useEffect — tudo isso precisa ser Client Component.

O padrão que funciona na prática é compor: Server Components buscam os dados e passam como props para Client Components que cuidam da interação. Você mantém a maior parte da lógica no servidor sem abrir mão da experiência interativa onde ela é necessária.

Middleware para resolução de tenant: o porteiro da aplicação

Em um SaaS multi-tenant com subdomínio por tenant (empresa-a.seuapp.com.br), o Middleware do Next.js é o lugar certo para resolver o tenant antes que qualquer página ou API route processe a requisição.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyJwt } from '@/lib/auth';

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') ?? '';
  const subdomain = hostname.split('.')[0];

  // Ignora assets, _next e rotas públicas
  const { pathname } = request.nextUrl;
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/api/auth') ||
    pathname === '/login' ||
    pathname === '/'
  ) {
    return NextResponse.next();
  }

  // Valida o token JWT
  const token = request.cookies.get('session')?.value;
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const payload = await verifyJwt(token);
  if (!payload || payload.tenantSlug !== subdomain) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Injeta o tenantId como header para Server Components e API Routes
  const response = NextResponse.next();
  response.headers.set('x-tenant-id', payload.tenantId);
  response.headers.set('x-tenant-plan', payload.plan);
  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

O tenantId injetado no header fica disponível para Server Components via headers() do Next.js — sem precisar passar por props ou por Context:

// app/dashboard/page.tsx
import { headers } from 'next/headers';
import { ProjetosService } from '@/services/projetos';

export default async function DashboardPage() {
  const headersList = headers();
  const tenantId = headersList.get('x-tenant-id')!;

  const projetos = await ProjetosService.listarAtivos(tenantId);

  return <DashboardView projetos={projetos} />;
}

Dois pontos importantes aqui. Primeiro: o tenantId vem do JWT validado no Middleware — não da URL, não de um parâmetro de query. Isso significa que mesmo que alguém manipule a URL, o tenant que vale é o do token. Segundo: o Middleware roda no Edge Runtime, que é mais restrito do que o Node.js padrão. Bibliotecas que dependem de APIs do Node (como alguns clientes de banco de dados) não vão funcionar ali — mantenha o Middleware leve e delegue operações pesadas para as API Routes.

Server Actions vs API Routes: onde colocar o quê

O Next.js 14 trouxe Server Actions como forma de executar código no servidor a partir de um formulário ou de um evento no Client Component — sem precisar criar uma API Route explícita. Para SaaS, a decisão entre usar Server Action ou API Route importa mais do que parece.

Use Server Actions quando:

  • A operação é disparada diretamente por um formulário
  • A lógica é simples e específica de uma feature de UI
  • Você não precisa que outros clientes (apps mobile, integrações externas) consumam esse endpoint
// app/projetos/novo/actions.ts
'use server';

import { headers } from 'next/headers';
import { ProjetosService } from '@/services/projetos';
import { revalidatePath } from 'next/cache';

export async function criarProjeto(formData: FormData) {
  const tenantId = headers().get('x-tenant-id')!;
  const nome = formData.get('nome') as string;

  if (!nome || nome.trim().length < 3) {
    return { error: 'Nome precisa ter pelo menos 3 caracteres' };
  }

  await ProjetosService.criar({ tenantId, nome: nome.trim() });
  revalidatePath('/projetos');
}

Use API Routes quando:

  • O endpoint precisa ser consumido por um app mobile ou por uma integração externa
  • Você precisa de controle fino sobre headers de resposta (cache, content-type)
  • A operação envolve webhooks, callbacks de gateway de pagamento ou qualquer chamada de sistema externo
  • Você quer centralizar a autenticação e autorização de forma explícita e testável

Para a maioria das operações de billing — criar assinatura, cancelar plano, processar webhook do Stripe — API Routes são a escolha certa. Não porque Server Actions não funcionariam, mas porque billing é o tipo de lógica que você vai querer testar isoladamente, documentar e potencialmente reutilizar em outros clients no futuro.

Uma regra prática: se a operação movimenta dinheiro, fica na API Route. Se é CRUD de dados de produto, Server Action é suficiente.

A fronteira com o backend: o que o Next.js não deve fazer

Esse é o ponto onde mais projetos SaaS acumulam dívida técnica sem perceber. O Next.js é muito capaz — dá para conectar no banco diretamente nos Server Components, para chamar Stripe diretamente nas API Routes, para colocar quase toda a lógica de negócio dentro do próprio projeto Next. E no começo, isso parece razoável.

O problema aparece quando:

  • O time precisa criar um app mobile que consome a mesma lógica
  • Um script de migração precisa rodar a mesma regra de negócio
  • Você quer escrever testes unitários da lógica de billing sem subir um ambiente Next.js
  • Um serviço externo precisa disparar uma operação de cobrança

Regras de negócio no Next.js se tornam regras de negócio acopladas ao framework de frontend. Elas não são testáveis de forma independente, não são reutilizáveis por outros clientes, e não são visíveis para um desenvolvedor que trabalha só no backend.

O que fica no Next.js:

  • Apresentação e composição de UI
  • Validação de formulário (client-side e server-side básica)
  • Chamadas para a API do backend via fetch
  • Autenticação de sessão e redirecionamentos
  • Cache de dados de apresentação com revalidatePath e revalidateTag

O que fica no backend (.NET Core, no caso da Logik):

  • Toda a lógica de domínio — criação de projetos, regras de negócio, cálculos
  • Persistência de dados com as regras de multi-tenancy
  • Integração com Stripe — criação de subscriptions, processamento de webhooks
  • Emissão de notas fiscais
  • Envio de e-mails transacionais
  • Geração de relatórios

O Next.js chama o backend. O backend executa e retorna. Essa divisão parece óbvia no início do projeto — e começa a escorregar exatamente quando o prazo aperta e a tentação de colocar 'só essa lógica aqui no Server Component' aparece.

Consumindo a API de billing no frontend: o que observar

A integração do frontend com a API de billing merece atenção especial porque os erros aqui têm consequências financeiras diretas — não são apenas bugs de UX.

Nunca exponha chaves Stripe no client

A Stripe Publishable Key pode aparecer no browser — ela é pública por design e serve apenas para tokenizar o cartão via Stripe.js. A Secret Key nunca sai do servidor. Qualquer operação que use a Secret Key — criar PaymentIntent, criar Subscription, processar reembolso — precisa passar por uma API Route no servidor, que então chama o backend.

// app/api/billing/criar-sessao/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';

export async function POST(request: NextRequest) {
  const tenantId = headers().get('x-tenant-id');
  if (!tenantId) {
    return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
  }

  const body = await request.json();

  // Delega para o backend .NET — nunca chama Stripe diretamente aqui
  const response = await fetch(`${process.env.API_URL}/billing/sessao`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Tenant-Id': tenantId,
      'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}`,
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    return NextResponse.json(
      { error: 'Erro ao criar sessão de billing' },
      { status: response.status }
    );
  }

  const data = await response.json();
  return NextResponse.json(data);
}

Trate estados de loading e erro com seriedade

Operações de billing são lentas — uma chamada para criar uma subscription no Stripe pode levar 800ms a 2s. O usuário precisa de feedback claro durante esse tempo. Um botão que não muda de estado enquanto a operação processa leva a cliques duplos, que levam a cobranças duplicadas.

// Componente de upgrade de plano
'use client';

import { useState } from 'react';

export function BotaoUpgrade({ plano }: { plano: string }) {
  const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');

  async function handleUpgrade() {
    setStatus('loading');

    try {
      const response = await fetch('/api/billing/criar-sessao', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ plano }),
      });

      if (!response.ok) throw new Error('Erro na requisição');

      const { url } = await response.json();
      window.location.href = url; // redireciona para Stripe Checkout
    } catch {
      setStatus('error');
    }
  }

  return (
    <button
      onClick={handleUpgrade}
      disabled={status === 'loading'}
      className="..."
    >
      {status === 'loading' ? 'Aguarde...' : `Fazer upgrade para ${plano}`}
    </button>
  );
}

Desabilitar o botão durante o loading não é detalhe de UX — é prevenção de bug de billing.

Cache no App Router: o que cachear e o que nunca cachear em SaaS

O App Router tem uma camada de cache agressiva por padrão. Em produção, Server Components e fetch calls são cacheados automaticamente — o que é ótimo para performance e péssimo para dados que precisam estar sempre atualizados.

Em SaaS multi-tenant, dois tipos de dados nunca devem ser cacheados de forma global:

Dados por tenant — se o cache armazenar o resultado de uma query sem o tenantId como parte da chave de cache, um tenant pode ver dados de outro. Isso não é hipotético — é um bug que aparece quando a configuração padrão de cache é mantida sem revisão.

Status de assinatura e plano — se o cache guardar que o tenant está no plano Growth, e ele cancela a assinatura, a UI vai continuar mostrando features premium até o cache expirar. O usuário vê uma coisa, o backend autoriza outra.

A solução no App Router é optar explicitamente pelo comportamento de cache:

// Nunca cachear dados do tenant
export const dynamic = 'force-dynamic'; // no topo do arquivo de página

// Ou, na chamada de fetch específica
const response = await fetch(`${process.env.API_URL}/tenant/status`, {
  cache: 'no-store', // sem cache
  headers: { 'X-Tenant-Id': tenantId },
});

// Para dados que podem ser cacheados por um período curto
const planos = await fetch(`${process.env.API_URL}/planos`, {
  next: { revalidate: 3600 }, // revalida a cada hora
});

A regra de bolso: dados que são iguais para todos os tenants (lista de planos disponíveis, conteúdo estático) podem ser cacheados normalmente. Dados que pertencem a um tenant específico ou que dependem do status atual da assinatura — cache: 'no-store', sem exceção.

O que colocar no checklist antes de ir para produção

Antes do primeiro cliente real na aplicação, vale passar por essa lista especificamente para os pontos que o App Router torna mais fácil de errar:

Middleware e autenticação:

  • O matcher do Middleware cobre todas as rotas protegidas — incluindo /api/*
  • Routes públicas (login, callback de OAuth, webhooks do Stripe) estão explicitamente excluídas
  • O tenantId é sempre lido do JWT validado — nunca de parâmetro de URL manipulável

Dados e cache:

  • Páginas que exibem dados por tenant têm force-dynamic ou cache: 'no-store' nas chamadas críticas
  • Status de assinatura e dados financeiros nunca são cacheados
  • Server Components não expõem dados de outros tenants nem em props nem em JSON embutido no HTML

Billing:

  • Stripe Secret Key está apenas em variáveis de ambiente server-side — nunca em NEXT_PUBLIC_*
  • Botões de ação de billing são desabilitados durante o loading
  • API Routes de billing validam o tenantId antes de chamar qualquer serviço externo

Fronteiras de arquitetura:

  • Lógica de domínio e regras de negócio estão no backend — não em Server Components ou Server Actions
  • O Next.js chama o backend via fetch — não conecta diretamente no banco
  • Existe pelo menos um ambiente de staging onde o fluxo completo de billing foi testado de ponta a ponta

Se você está montando a stack de um SaaS com Next.js e .NET Core e quer garantir que as fronteiras de arquitetura estejam certas desde o começo — antes que a dívida técnica apareça como bug de billing — fala com a Logik Digital.

Conversar com a Logik Digital

Tags: next.js 14 saas, Stack e Engenharia, TOFU