Se você chegou até aqui, provavelmente já conhece o básico: o next/image é incrível, o next build já otimiza bastante coisa e o lazy loading ajuda a não travar a página. Mas, sejamos honestos, o que separa um site "ok" de um site que realmente performa são os detalhes.
Nos últimos anos, especialmente trabalhando com clientes do MeuBoi e Astriia, dois projetos que tive a honra de estar a frente do desenvolvimento, percebi que as otimizações de superfície resolvem apenas parte do problema. A verdadeira mágica acontece quando você vai além do óbvio e começa a tratar performance como uma feature do produto, não como um checklist.
Neste guia, vou compartilhar 7 estratégias práticas que usei para reduzir drasticamente o LCP (Largest Contentful Paint) e melhorar o Core Web Vitals de aplicações Next.js em produção. O foco aqui não é teoria, é código funcionando.
1. next/image: Você está usando errado (e eu também já usei)
O que é o next/image?
É um componente nativo do Next.js que substitui a tag <img> tradicional. Ele faz automaticamente várias otimizações: redimensiona imagens para o tamanho correto, converte para formatos modernos (como WebP) e carrega apenas quando o usuário está prestes a ver a imagem.
Problema comum: Muita gente usa o next/image sem configurar corretamente dois parâmetros essenciais: priority e sizes. Isso faz com que o navegador baixe imagens maiores do que o necessário ou carregue imagens que não são prioritárias, piorando o tempo de carregamento.
Solução
Erro #1: priority nas imagens erradas.
Apenas imagens acima da dobra (a parte da tela que o usuário vê sem rolar) merecem priority. Colocar priority numa imagem que só aparecerá depois de scrollar é desperdício de recurso.
Erro #2: Esquecer o sizes em layouts responsivos.
Se você tem uma imagem que ocupa 100% da largura no mobile, mas apenas 50% no desktop, o atributo sizes diz ao navegador qual tamanho esperar. Sem isso, o navegador pode baixar uma imagem gigante desnecessariamente.
Exemplo de uso otimizado:
import Image from 'next/image'
export function HeroImage() {
return (
<Image
src="/hero.jpg"
width={1200}
height={600}
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 1200px"
className="rounded-lg"
alt="Hero banner"
/>
)
}
Bônus: Se você está servindo imagens de um CMS externo (WordPress, Contentful, etc.), use loader personalizado para aproveitar as otimizações de entrega deles:
const customLoader = ({ src, width, quality }) => {
return `https://meucms.com/${src}?w=${width}&q=${quality || 75}`
}
<Image loader={customLoader} src="meu-arquivo.jpg" width={800} height={600} alt="..." />
2. Cache Inteligente: Não deixe o navegador trabalhar sozinho
O que é cache e por que ele importa?
Cache é uma técnica de armazenar cópias de arquivos (como imagens, CSS, JavaScript) para que o navegador não precise baixá-los novamente em visitas futuras. Pense como uma "memória temporária".
Problema: Por padrão, o Next.js já aplica algumas regras de cache, mas elas são genéricas. Você pode melhorar drasticamente a performance configurando diferentes "tempos de validade" para diferentes tipos de arquivo. Por exemplo: uma foto de perfil pode ficar armazenada por meses, enquanto uma página de preços deve ser revalidada a cada hora.
Solução
Cache no Middleware
O middleware.ts é um arquivo especial do Next.js que intercepta cada requisição antes de ela chegar na página. Use-o para adicionar cabeçalhos HTTP específicos.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Para imagens e fontes: cache agressivo (1 ano)
if (request.nextUrl.pathname.match(/\.(jpg|jpeg|gif|png|webp|woff2)$/)) {
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable')
}
// Para a página inicial: 1 hora no CDN, 1 dia no navegador
if (request.nextUrl.pathname === '/') {
response.headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400')
}
return response
}
Aviso de Depreciação do Middleware
No Next.js 16, você pode ver este aviso no console:
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
Correção
Apenas renomeie middleware.ts (ou middleware.js) para proxy.ts (ou proxy.js). Nenhuma alteração no código interno é necessária.
# No terminal
mv middleware.ts proxy.ts
Explicação dos termos:
public: qualquer intermediário (CDN, proxy) pode armazenar em cache.max-age=31536000: o arquivo é considerado "fresco" por 1 ano.immutable: o arquivo nunca muda, então o navegador nunca precisa verificar novamente.s-maxage=3600: armazena em CDN por 1 hora.stale-while-revalidate=86400: se o cache expirar, serve o conteúdo antigo enquanto busca o novo em segundo plano (por 1 dia).
Cache de dados com React Cache e SWR
Para dados que vêm de uma API (ex: lista de produtos), você pode armazenar em cache no servidor:
// app/api/posts/route.ts
export async function GET() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 } // ISR: revalida a cada 60 segundos
})
return res.json()
}
E no front-end, use SWR (Stale-While-Revalidate) para exibir dados antigos enquanto busca novos:
import useSWR from 'swr'
function Posts() {
const { data } = useSWR('/api/posts', fetcher, {
revalidateOnFocus: false, // não recarrega ao focar na aba
dedupingInterval: 60000 // se a mesma requisição for chamada várias vezes em 60s, usa o cache
})
// ...
}
3. Core Web Vitals: O que o Lighthouse não te conta
O que são Core Web Vitals?
São métricas criadas pelo Google para medir a experiência real de um usuário em um site. Elas afetam diretamente o ranqueamento no Google. As três mais importantes são:
- LCP (Largest Contentful Paint): tempo que o maior elemento visível da página (uma imagem, um bloco de texto) leva para aparecer. Ideal: até 2,5 segundos.
- CLS (Cumulative Layout Shift): mede se os elementos da página "pulam" de lugar enquanto carregam (ex: um botão move tudo para baixo). Ideal: próximo de 0.
- FID (First Input Delay): tempo que o site leva para responder ao primeiro clique ou toque do usuário. Ideal: menos de 100 milissegundos.
Problema: O Lighthouse (ferramenta do Google DevTools) simula um ambiente ideal. Na vida real, com internet lenta ou dispositivos fracos, os números podem ser muito piores.
Solução
Monitoramento real com a API web-vitals
Instale a biblioteca oficial do Google:
npm install web-vitals
No arquivo app/layout.tsx (ou pages/_app.tsx), adicione:
import { useReportWebVitals } from 'next/web-vitals'
export function ReportWebVitals() {
useReportWebVitals((metric) => {
if (metric.label === 'web-vital') {
// Envia para seu analytics (Google Analytics, Plausible, etc.)
console.log(metric.name, metric.value, metric.rating)
}
})
}
Isso captura os dados reais de cada visita e permite que você veja onde sua aplicação está sofrendo.
Melhorando o CLS (Cumulative Layout Shift)
O CLS acontece quando um elemento é carregado depois que a página já foi exibida, empurrando o conteúdo para baixo. A solução é reservar espaço antecipadamente.
Exemplo: um vídeo que carrega assincronamente:
<div className="relative aspect-video bg-gray-100">
{isLoading ? (
<div className="animate-pulse w-full h-full" />
) : (
<video src={src} controls className="absolute inset-0 w-full h-full" />
)}
</div>
O truque está no aspect-video (ou uma altura fixa) – isso impede que o conteúdo pule quando o vídeo carregar.
4. Fontes: O elo mais fraco (e mais fácil de otimizar)
O problema com fontes externas
Fontes do Google Fonts, Typekit, etc. são bloqueantes de renderização por padrão. Ou seja, o navegador só mostra o texto depois que baixa a fonte. Isso atrasa o LCP.
Solução: usar next/font, que já aplica display: swap (mostra uma fonte de fallback imediatamente e substitui depois que a fonte real carregar).
Solução
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // fallback imediato
preload: true, // carrega antes
})
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
{/* ... */}
</html>
)
}
5. Scripts de terceiros: O câncer silencioso da web
O que são scripts de terceiros?
Google Tag Manager, Hotjar (gravação de sessões), Intercom (chat), Facebook Pixel – todos eles adicionam código JavaScript externo ao seu site. Cada um faz requisições, baixa arquivos e pode bloquear a interatividade da página.
Problema: muitos devs colocam esses scripts no <head> ou carregam todos de uma vez, sem critério. Isso mata a performance.
Solução
Next.js 13+ oferece o componente Script com estratégias inteligentes:
import Script from 'next/script'
export default function Analytics() {
return (
<>
{/* Essencial – carrega imediatamente antes de qualquer interação */}
<Script src="/essential.js" strategy="beforeInteractive" />
{/* Pode esperar – carrega depois da página estar interativa */}
<Script src="https://www.googletagmanager.com/gtag/js" strategy="afterInteractive" />
{/* Totalmente preguiçoso – só carrega quando o usuário interage ou o navegador está ocioso */}
<Script src="https://js.hotjar.com/..." strategy="lazyOnload" />
</>
)
}
Dica extra: Use Partytown (do Builder.io) para isolar scripts de terceiros em web workers. Isso evita que eles bloqueiem a thread principal (onde o JavaScript roda). Requer configuração adicional, mas vale muito a pena para projetos com muitos scripts.
6. Server Components: Nem tudo precisa ser cliente
O que são Server Components?
No Next.js App Router (a versão mais nova), os componentes são servidores por padrão. Isso significa que eles rodam no servidor, geram HTML e enviam pronto para o navegador. Zero JavaScript vai para o cliente.
Problema: muita gente ainda força todos os componentes a serem "Client Components" (colocando 'use client' no topo), o que faz o navegador baixar e executar código desnecessário.
Solução
Regra prática:
- Server Component sempre que possível (busca de dados, layout, conteúdo estático, listagem)
- Client Component apenas quando precisar de interatividade (useState, useEffect, eventos de clique, formulários)
// Server Component (padrão no App Router)
async function ProductList() {
const products = await db.products.findMany() // consulta direta ao banco
return (
<div>
{products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
)
}
// Client Component apenas para interatividade
'use client'
function AddToCartButton({ productId }) {
const [isAdded, setIsAdded] = useState(false)
return (
<button onClick={() => {
addToCart(productId)
setIsAdded(true)
}}>
{isAdded ? 'Adicionado' : 'Comprar'}
</button>
)
}
O resultado? Menos JavaScript no cliente, menos hidratação (o processo de "ativar" a página), mais performance. E o melhor: você não precisa sacrificar interatividade.
7. Bundle Analysis e o poder do next build --profile
O que é bundle e por que analisá-lo?
"Bundle" é o arquivo final que o navegador baixa – a união de todo o seu código JavaScript, bibliotecas e dependências. Quanto maior o bundle, mais lento o site.
Problema: você pode estar incluindo bibliotecas gigantes sem saber (ex: moment.js tem 28KB gzip, lodash inteiro tem mais de 20KB). Às vezes, só uma função de uma biblioteca já vale o custo, mas você pode substituir por uma alternativa menor.
Solução
Instale a ferramenta de análise:
npm install @next/bundle-analyzer
Configure no next.config.js:
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// suas configurações
})
Execute a análise:
ANALYZE=true npm run build
Isso abrirá duas páginas HTML no navegador: client.html (código que roda no navegador) e server.html (código que roda no servidor). Você verá visualmente quais bibliotecas estão ocupando mais espaço.
Exemplo real (projeto Astriia): encontramos que moment.js (28KB gzip) podia ser substituído por dayjs (4KB gzip), e que lodash inteiro podia ser substituído por imports individuais (import debounce from 'lodash/debounce'). Isso reduziu o bundle inicial em 40%.
Nenhuma dessas técnicas é complicada isoladamente. O desafio é tornar a verificação de performance um hábito: configure o bundle analyzer, monitore os Core Web Vitals reais, e sempre questione "esse script de terceiro é realmente necessário?".
Comece aplicando uma estratégia por semana. Daqui a um mês, seu Next.js app vai estar não apenas mais rápido, mas mais robusto – e seus usuários vão perceber a diferença antes mesmo de você falar sobre ela.
Se você aplicou alguma dessas dicas e teve um resultado interessante, me conta. Vou adorar saber o que funcionou para você.