Ir para o conteúdo
·11 min de leitura

Estruturando Projetos Next.js em Escala: O Que Eu Aprendi da Pior Forma

Lições duramente conquistadas sobre como organizar codebases Next.js que não desmoronam sob o proprio peso. Arquitetura baseada em features, route groups, limites server/client, armadilhas de barrel files e uma estrutura de pastas real.

Compartilhar:X / TwitterLinkedIn

Todo projeto Next.js comeca limpo. Algumas paginas, uma pasta de componentes, talvez um lib/utils.ts. Você se sente produtivo. Você se sente organizado.

Então seis meses passam. Três desenvolvedores entram. A pasta de componentes tem 140 arquivos. Alguém criou utils2.ts porque utils.ts tinha 800 linhas. Ninguém sabe qual layout envolve qual rota. Seu build leva quatro minutos e você não sabe por que.

Ja passei por esse ciclo varias vezes. O que segue e o que eu realmente faco agora, depois de aprender a maioria dessas lições entregando a coisa errada primeiro.

A Estrutura Padrão Desmorona Rápido#

A documentação do Next.js sugere uma estrutura de projeto. E razoavel. Funciona para um blog pessoal ou uma ferramenta pequena. Mas no momento em que você tem mais de dois desenvolvedores mexendo no mesmo codebase, a abordagem "baseada em tipo" comeca a rachar:

src/
  components/
    Button.tsx
    Modal.tsx
    UserCard.tsx
    DashboardSidebar.tsx
    InvoiceTable.tsx
    InvoiceRow.tsx
    InvoiceActions.tsx
    ...137 more files
  hooks/
    useAuth.ts
    useInvoice.ts
    useDebounce.ts
  utils/
    format.ts
    validate.ts
    misc.ts     <- o comeco do fim
  app/
    page.tsx
    dashboard/
      page.tsx

O problema não e que isso esta "errado". E que escala linearmente com as features, mas não com a compreensao. Quando um novo desenvolvedor precisa trabalhar em faturas, ele tem que abrir components/, hooks/, utils/, types/ e app/ simultaneamente. A feature de faturamento esta espalhada por cinco diretorios. Não ha um único lugar que diga "e assim que o faturamento funciona."

Vi uma equipe de quatro desenvolvedores gastar uma sprint inteira refatorando para exatamente essa estrutura, convencidos de que era a forma "limpa". Em três meses estavam de volta a confusao, so que com mais pastas.

Colocacao Baseada em Features Vence. Sempre.#

O padrão que realmente sobreviveu ao contato com uma equipe real e a colocacao baseada em features. A ideia e simples: tudo relacionado a uma feature fica junto.

Não "todos os componentes em uma pasta e todos os hooks em outra." O oposto. Todos os componentes, hooks, tipos e utilitarios para faturamento ficam dentro de features/invoicing/. Tudo de autenticação fica dentro de features/auth/.

typescript
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
 
export function useInvoiceActions(invoice: Invoice) {
  const markAsPaid = useCallback(async () => {
    await invoiceApi.updateStatus(invoice.id, "paid");
  }, [invoice.id]);
 
  const duplicate = useCallback(async () => {
    const newNumber = formatInvoiceNumber(Date.now());
    await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
  }, [invoice]);
 
  return { markAsPaid, duplicate };
}

Observe os imports. São todos relativos, todos dentro da feature. Esse hook não busca em alguma pasta global hooks/ ou em um utils/format.ts compartilhado. Ele usa a propria lógica de formatacao da feature de faturamento porque formatacao de número de fatura e uma responsabilidade do faturamento, não uma responsabilidade global.

A parte contraintuitiva: isso significa que você tera alguma duplicacao. Sua feature de faturamento pode ter um format.ts e sua feature de cobranca também pode ter um format.ts. Tudo bem. Isso e na verdade melhor do que um formatters.ts compartilhado do qual 14 features dependem e ninguém consegue alterar com seguranca.

Eu resisti a isso por muito tempo. DRY era evangelho. Mas ja vi arquivos de utilitarios compartilhados se tornarem os arquivos mais perigosos em um codebase, arquivos onde uma "correcao pequena" para uma feature quebra três outras. Colocacao com duplicacao controlada e mais sustentavel do que compartilhamento forcado.

A Estrutura de Pastas Que Eu Realmente Uso#

Aqui esta a arvore completa. Isso foi testado em batalha em duas aplicacoes de produção com equipes de 4 a 8 pessoas:

src/
├── app/
│   ├── (public)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── pricing/
│   │   │   └── page.tsx
│   │   └── blog/
│   │       ├── page.tsx
│   │       └── [slug]/
│   │           └── page.tsx
│   ├── (auth)/
│   │   ├── layout.tsx
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── invoices/
│   │   │   ├── page.tsx
│   │   │   ├── [id]/
│   │   │   │   └── page.tsx
│   │   │   └── new/
│   │   │       └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/
│   │   └── webhooks/
│   │       └── stripe/
│   │           └── route.ts
│   ├── layout.tsx
│   └── not-found.tsx
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── login-form.tsx
│   │   │   ├── register-form.tsx
│   │   │   └── oauth-buttons.tsx
│   │   ├── actions/
│   │   │   ├── login.ts
│   │   │   └── register.ts
│   │   ├── hooks/
│   │   │   └── use-session.ts
│   │   ├── types.ts
│   │   └── constants.ts
│   ├── invoicing/
│   │   ├── components/
│   │   │   ├── invoice-table.tsx
│   │   │   ├── invoice-row.tsx
│   │   │   ├── invoice-form.tsx
│   │   │   └── invoice-pdf.tsx
│   │   ├── actions/
│   │   │   ├── create-invoice.ts
│   │   │   └── update-status.ts
│   │   ├── hooks/
│   │   │   └── use-invoice-actions.ts
│   │   ├── api/
│   │   │   └── client.ts
│   │   ├── utils/
│   │   │   └── format.ts
│   │   └── types.ts
│   └── billing/
│       ├── components/
│       ├── actions/
│       ├── hooks/
│       └── types.ts
├── shared/
│   ├── components/
│   │   ├── ui/
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── modal.tsx
│   │   │   └── toast.tsx
│   │   └── layout/
│   │       ├── header.tsx
│   │       ├── footer.tsx
│   │       └── sidebar.tsx
│   ├── hooks/
│   │   ├── use-debounce.ts
│   │   └── use-media-query.ts
│   ├── lib/
│   │   ├── db.ts
│   │   ├── auth.ts
│   │   ├── stripe.ts
│   │   └── email.ts
│   └── types/
│       └── global.ts
└── config/
    ├── site.ts
    └── navigation.ts

Algumas coisas para notar:

  • app/ contem apenas lógica de roteamento. Os arquivos de pagina são finos. Eles importam de features/ e compoem. Um arquivo de pagina deve ter 20-40 linhas, no maximo.
  • features/ e onde o código real vive. Cada feature e autocontida. Você pode deletar features/invoicing/ e nada mais quebra (exceto as paginas que importam dela, que e exatamente o acoplamento que você quer).
  • shared/ não e uma lixeira. Tem subcategorias rigidas. Mais sobre isso abaixo.

Route Groups: Mais Do Que Apenas Organização#

Route groups, as pastas com (parenteses), são uma das features mais subutilizadas do App Router. Elas não afetam a URL de forma alguma. (dashboard)/invoices/page.tsx renderiza em /invoices, não em /dashboard/invoices.

Mas o verdadeiro poder esta no isolamento de layout. Cada route group tem seu proprio layout.tsx:

typescript
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <div className="flex min-h-screen">
      <Sidebar user={session.user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}
typescript
// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
 
export default function PublicLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
}

A verificação de autenticação acontece uma vez no nível do layout, não em cada pagina. Paginas publicas recebem um shell completamente diferente. Paginas de autenticação não recebem layout algum, apenas um card centralizado. Isso e limpo, explícito e difícil de errar.

Ja vi equipes colocando verificacoes de autenticação dentro do middleware, dentro de paginas individuais, dentro de componentes, tudo ao mesmo tempo. Escolha um lugar. Para a maioria dos apps, o layout do route group e esse lugar.

A Pasta shared/ Não E utils/#

A maneira mais rápida de criar um pesadelo de manutenção e um arquivo lib/utils.ts. Comeca pequeno. Então se torna a gaveta de tralha do codebase, onde toda função que não tem um lugar obvio acaba.

Aqui esta minha regra: a pasta shared/ requer justificativa. Algo vai para shared/ apenas se:

  1. E usado por 3+ features, e
  2. E genuinamente generico (não e lógica de negocios disfarçada de generico)

Uma função de formatacao de data que formata datas de vencimento de faturas? Vai em features/invoicing/utils/. Uma função de formatacao de data que recebe um Date e retorna uma string de locale? Pode ir em shared/.

A subpasta shared/lib/ e especificamente para integracoes com terceiros e infraestrutura: clientes de banco de dados, bibliotecas de autenticação, provedores de pagamento. São coisas das quais toda a aplicação depende, mas nenhuma feature e dona.

typescript
// shared/lib/db.ts — isso pertence ao shared
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
 
// features/invoicing/utils/format.ts — isso NAO pertence ao shared
export function formatInvoiceNumber(timestamp: number): string {
  const date = new Date(timestamp);
  const year = date.getFullYear();
  const seq = Math.floor(Math.random() * 9000) + 1000;
  return `INV-${year}-${seq}`;
}

Se você esta recorrendo a shared/utils/ e não consegue dar ao arquivo um nome mais específico do que helpers.ts, pare. Esse código provavelmente pertence a uma feature.

Server vs. Client Components: Trace a Linha Deliberadamente#

Aqui esta um padrão que vejo constantemente em codebases com problemas de performance:

typescript
// Isso parece razoavel mas e um erro
"use client";
 
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
 
export default function InvoicesPage() {
  const { data } = useInvoiceActions();
  return (
    <div>
      <InvoiceFilters />
      <InvoiceTable invoices={data} />
    </div>
  );
}

A pagina esta marcada com "use client". Agora tudo que ela importa, InvoiceTable, InvoiceFilters, o hook, cada dependência de cada dependência, esta no bundle do client. Sua tabela de 200 linhas que poderia ter sido renderizada no servidor com zero JavaScript agora esta sendo enviada para o navegador.

A solução e empurrar o "use client" para os componentes folha que realmente precisam:

typescript
// app/(dashboard)/invoices/page.tsx — Server Component (sem diretiva)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
 
export default async function InvoicesPage() {
  const invoices = await db.query.invoices.findMany({
    orderBy: (inv, { desc }) => [desc(inv.createdAt)],
  });
 
  return (
    <div>
      <InvoiceFilters />          {/* Client Component — tem filtros interativos */}
      <InvoiceTable data={invoices} /> {/* Server Component — apenas renderiza linhas */}
    </div>
  );
}
typescript
// features/invoicing/components/invoice-filters.tsx
"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
 
export function InvoiceFilters() {
  const router = useRouter();
  const params = useSearchParams();
 
  const setStatus = (status: string) => {
    const next = new URLSearchParams(params.toString());
    next.set("status", status);
    router.push(`?${next.toString()}`);
  };
 
  return (
    <div className="flex gap-2">
      {["all", "draft", "sent", "paid"].map((s) => (
        <button
          key={s}
          onClick={() => setStatus(s)}
          className={params.get("status") === s ? "font-bold" : ""}
        >
          {s}
        </button>
      ))}
    </div>
  );
}

A regra que eu sigo: todo componente e um Server Component ate precisar de useState, useEffect, event handlers ou APIs do navegador. Então, e somente então, adicione "use client", e tente fazer esse componente o menor possível.

O padrão children e seu melhor amigo aqui. Um Client Component pode receber Server Components como children:

typescript
"use client";
 
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}  {/* children podem ser Server Components */}
    </div>
  );
}

E assim que você mantem 90% da sua arvore de componentes no servidor enquanto ainda tem interatividade onde importa.

Barrel Files: O Assassino Silencioso do Build#

Eu costumava colocar um index.ts em cada pasta. Imports limpos, certo?

typescript
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";

Então eu podia importar assim:

typescript
import { InvoiceTable } from "@/features/invoicing/components";

Parece bonito. Aqui esta o problema: quando o bundler ve esse import, ele precisa avaliar o barrel file inteiro para descobrir o que e InvoiceTable. Isso significa que ele carrega InvoiceRow, InvoiceForm e InvoicePdf também, mesmo que você so precisasse da tabela.

Em um projeto, remover barrel files internos reduziu a contagem de modulos de 11.000 para 3.500 durante o desenvolvimento. O dev server passou de 5-10 segundos de carregamento de pagina para menos de 2 segundos. O JavaScript do primeiro carregamento caiu de mais de um megabyte para cerca de 200 KB.

A Vercel criou optimizePackageImports no next.config.ts para lidar com isso para pacotes de terceiros do node_modules. Mas não corrige seus proprios barrel files. Para seu proprio código, a resposta e mais simples: não use.

typescript
// Faca isso
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// Nao isso
import { InvoiceTable } from "@/features/invoicing/components";

Sim, o caminho do import e mais longo. Seu build e mais rápido, seus bundles são menores e seu dev server não engasga. Essa e uma troca que eu aceito sempre.

A única exceção: a API pública de uma feature. Se features/invoicing/ expoe um único index.ts no nível raiz da feature com apenas 2-3 coisas que outras features podem importar, tudo bem. Funciona como uma fronteira, não um atalho de conveniencia. Mas barrel files dentro de subpastas como components/index.ts ou hooks/index.ts? Elimine-os.

Paginas Magras, Features Gordas#

A regra arquitetural mais importante que eu sigo e esta: arquivos de pagina são fiacao, não lógica.

Um arquivo de pagina em app/ deve fazer três coisas:

  1. Buscar dados (se for um Server Component)
  2. Importar componentes de features
  3. Compo-los juntos

So isso. Sem lógica de negocios. Sem renderizacao condicional complexa. Sem arvores JSX de 200 linhas. Se um arquivo de pagina esta passando de 40 linhas, você esta colocando código de feature no lugar errado.

typescript
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
 
interface Props {
  params: Promise<{ id: string }>;
}
 
export default async function InvoicePage({ params }: Props) {
  const { id } = await params;
  const invoice = await db.query.invoices.findFirst({
    where: (inv, { eq }) => eq(inv.id, id),
  });
 
  if (!invoice) notFound();
 
  return <InvoiceDetail invoice={invoice} />;
}

Doze linhas. A pagina busca os dados que precisa, trata o caso de não encontrado e delega todo o resto para a feature. Se a UI de detalhe da fatura mudar, você edita features/invoicing/, não app/. Se a rota mudar, você move o arquivo de pagina, e o código da feature não se importa.

E assim que você consegue um codebase onde 15 desenvolvedores podem trabalhar simultaneamente sem pisar uns nos outros.

A Verdade Desconfortavel#

Não existe estrutura de pastas que previna código ruim. Ja vi projetos lindamente organizados cheios de abstracoes terriveis, e estruturas flat baguncadas que eram fáceis de trabalhar porque o código em si era claro.

Estrutura e uma ferramenta, não um objetivo. A abordagem baseada em features que descrevi aqui funciona porque otimiza para o que mais importa em escala: um desenvolvedor que nunca viu esse codebase consegue encontrar o que precisa e mudar sem quebrar outra coisa?

Se sua estrutura atual responde "sim" a essa pergunta, não refatore porque um post de blog mandou, nem mesmo este.

Mas se seus desenvolvedores estao gastando mais tempo navegando no codebase do que escrevendo código, se "onde isso vai?" e uma pergunta recorrente em PRs, se uma mudanca em cobranca de alguma forma quebra o dashboard, então sua estrutura esta lutando contra você. E os padrões acima são como eu corrigi isso, toda vez.

Posts Relacionados