A maioria das listas de "dicas de performance" é um monte de API sem contexto. Esta aqui está ordenada pelo que realmente move os números que o usuário sente: LCP (quão rápido o conteúdo principal aparece), INP (quão rápido a página responde à interação) e CLS (quanto o layout pula na tela).
Tudo abaixo é focado no App Router do Next.js 16.
0. Meça antes de otimizar
Otimizar sem baseline é chute. Três fontes baratas de verdade:
A saída do build. O next build mostra, por rota, se ela é estática ou dinâmica e quanto de JS ela custa. O número de First Load JS é o que importa — é o que o navegador precisa baixar e executar antes da página ficar interativa.
Métricas de usuários reais. Score sintético mente; dado de campo não. Reporte as Web Vitals a partir de um Client Component montado no root layout:
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
});
return null;
}
Um mapa do bundle, quando o First Load JS parecer errado:
ANALYZE=true bun run build
Corrija o que os dados apontarem. O resto é cosmético.
1. Envie menos JavaScript
O código mais rápido é o que você nunca envia. No App Router, componentes são Server Components por padrão — renderizam para HTML no servidor, e as dependências deles nunca chegam ao navegador.
O erro clássico é colocar "use client" no topo da página porque um botão lá no fundo precisa de onClick. Isso marca a árvore inteira como código de cliente. Empurre a fronteira para a folha que realmente precisa dela:
// app/products/page.tsx — Server Component, zero JS enviado
import { AddToCartButton } from "./add-to-cart-button";
export default async function Page() {
const products = await getProducts();
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<h2>{product.name}</h2>
<p>{product.description}</p>
<AddToCartButton id={product.id} />
</li>
))}
</ul>
);
}
// app/products/add-to-cart-button.tsx — o único código de cliente da página
"use client";
export function AddToCartButton({ id }: { id: string }) {
return <button onClick={() => addToCart(id)}>Adicionar ao carrinho</button>;
}
A mesma ideia vale para bibliotecas que só transformam dados em UI — parsers de markdown, syntax highlighters, formatadores de data, bibliotecas de gráfico. Se elas não precisam de API do navegador nem de interação, rode no Server Component e envie o HTML em vez da biblioteca.
Para widgets que são genuinamente de cliente, pesados e desnecessários no primeiro paint (um editor rich text, um mapa, um player de vídeo), adie:
import dynamic from "next/dynamic";
const Map = dynamic(() => import("./map"), {
loading: () => <div className="h-96 animate-pulse rounded bg-neutral-800" />,
ssr: false,
});
Dois detalhes que as pessoas pulam: dê ao loading um skeleton com as mesmas dimensões do componente real, senão você troca tamanho de bundle por layout shift; e só use ssr: false quando o componente realmente não puder renderizar no servidor, porque isso remove o conteúdo do HTML inicial.
2. Mate os waterfalls
Normalmente é o maior ganho isolado em uma página renderizada no servidor — e é invisível no bundle analyzer.
// Lento: 300ms + 250ms + 180ms = 730ms
const user = await getUser(id);
const posts = await getPosts(id);
const settings = await getSettings(id);
// Rápido: max(300ms, 250ms, 180ms) = 300ms
const [user, posts, settings] = await Promise.all([
getUser(id),
getPosts(id),
getSettings(id),
]);
Só coloque em sequência o que de fato depende do resultado anterior. E quando dá para responder sem buscar nada, não pague por isso adiantado:
// O caminho lento cobra pedágio do caminho rápido
async function handle(id: string, skip: boolean) {
const data = await fetchData(id);
if (skip) return { skipped: true };
return process(data);
}
// Guard primeiro, await depois
async function handle(id: string, skip: boolean) {
if (skip) return { skipped: true };
return process(await fetchData(id));
}
Waterfalls também se escondem entre componentes: um layout que dá await, envolvendo uma página que dá await, envolvendo um componente que dá await, são três idas ao servidor empilhadas em série. O que nos leva à solução.
3. Faça streaming em vez de bloquear
Uma página é tão rápida quanto o seu await mais lento. O Suspense quebra esse acoplamento: a casca (header, nav, títulos, skeletons) é enviada na hora, e as partes lentas chegam por streaming conforme resolvem.
import { Suspense } from "react";
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Recommendations />
</Suspense>
</>
);
}
async function Feed() {
const posts = await getPosts();
return <PostList posts={posts} />;
}
Feed e Recommendations agora buscam dados em paralelo, e nenhum dos dois bloqueia o header. Um arquivo loading.tsx faz o mesmo para um segmento de rota inteiro.
Coloque as fronteiras onde o conteúdo é genuinamente lento — uma fronteira em volta da região lenta de verdade vale mais que uma dúzia espalhada, que só produz uma página piscando em dez etapas.
4. Faça cache do que não muda a cada request
O Next.js 16 com Cache Components te dá uma diretiva explícita no lugar do cache implícito de fetch:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
Aí você faz cache no nível dos dados, no nível da UI, ou nos dois:
import { cacheLife, cacheTag } from "next/cache";
export async function getProducts(category: string) {
"use cache";
cacheLife("hours");
cacheTag(`products-${category}`);
return db.query("SELECT * FROM products WHERE category = $1", [category]);
}
Argumentos e valores capturados do escopo pai entram na chave do cache, então entradas diferentes geram registros diferentes. Sempre combine a diretiva de cache com um perfil cacheLife — o default implícito raramente é o que você quer.
Quando o dado muda, invalide por tag em vez de esperar expirar:
"use server";
import { revalidateTag } from "next/cache";
export async function createProduct(input: ProductInput) {
await db.insert(input);
revalidateTag(`products-${input.category}`);
}
A regra prática: faça cache de tudo que não é por usuário nem por request. O que é — um feed personalizado, um preço ao vivo — fica sem cache e vai atrás de um Suspense, para chegar por streaming depois da casca cacheada em vez de bloqueá-la.
5. Imagens: seu LCP provavelmente é uma delas
Na maioria das páginas de conteúdo o elemento de LCP é uma imagem, então é aqui que a métrica é ganha ou perdida.
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Visão geral do produto"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 1200px"
placeholder="blur"
/>;
prioritysó na imagem de LCP — geralmente exatamente uma por página. Marcar tudo como priority faz preload de tudo, que é o mesmo que não priorizar nada.sizessempre que a imagem for responsiva. Sem isso o navegador assume a largura total da viewport e baixa um arquivo desnecessariamente grande, mesmo que o layout renderize a 400px.widtheheight(oufillcom um pai dimensionado) reservam o espaço antes do arquivo chegar. É a sua correção de CLS.- Tudo abaixo da dobra continua lazy — esse é o padrão, então só não sobrescreva.
6. Fontes: self-hosted e sem layout shift
O next/font baixa os arquivos de fonte em build time, hospeda no seu próprio domínio e gera um fallback ajustado para o texto não pular quando a fonte real entra. Sem request bloqueante para o Google, sem FOUT.
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" className={inter.variable}>
<body>{children}</body>
</html>
);
}
Carregue só os subsets e pesos que você usa, e prefira uma fonte variável a cinco pesos estáticos.
7. Faça a navegação parecer instantânea
O <Link> faz prefetch das rotas que estão na viewport em produção, então o clique não tem mais nada para baixar. Isso é de graça — até você renderizar uma lista com 500 links e fazer prefetch de todos:
<Link href={`/posts/${post.slug}`} prefetch={false}>
{post.title}
</Link>
Em listas longas, desligue o prefetch e reative no hover, para pagar só pelas rotas para onde o usuário está realmente indo.
8. Scripts de terceiros, por último
Analytics, widgets de chat e tag managers costumam ser a maior coisa da página e nunca aparecem no relatório do seu bundle. Tire-os do caminho crítico:
import Script from "next/script";
<Script src="https://example.com/widget.js" strategy="lazyOnload" />;
afterInteractive para scripts que precisam rodar cedo, lazyOnload para tudo que pode esperar a página ficar ociosa. Se uma tag de fornecedor deixa a página mensuravelmente mais lenta, isso é uma decisão de produto para escalar — não algo para contornar com otimização.
9. Uma nota sobre metadata
Metadata não é um recurso de performance, mas um generateMetadata lento vira um. Para navegadores, o Next.js envia a metadata por streaming junto com a página. Para crawlers que só leem HTML estático, ela bloqueia a resposta até resolver — então um generateMetadata que bate numa API lenta atrasa o documento inteiro para esses clientes.
Mantenha barato: reaproveite os dados que a página já busca (a deduplicação por request faz a segunda leitura sair de graça) ou coloque em cache.
10. Impeça a regressão
Toda otimização daqui apodrece no momento em que alguém adiciona uma dependência. Coloque um orçamento no CI:
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
}
}
}
Um build que quebra quando há regressão vale mais que qualquer otimização isolada deste artigo.
Conclusão
Se você fizer só três coisas: elimine waterfalls com Promise.all, mantenha "use client" nas folhas e faça streaming do conteúdo lento atrás de Suspense. Essas três cobrem a maior parte da distância entre um app Next.js lento e um rápido. O resto é refinamento — e um orçamento no CI para continuar assim.