Prefetch parcial no Next.js 16.4: navegação instantânea sem prefetchar a página inteira

O Next.js 16.4 liga Cache Components e Partial Prefetching por padrão. Como decidir o que prefetchar, o que guardar em cache e o que deixar para depois.

Igor Calabraro

· 7 min de leitura

Neste post

Durante anos, o prefetch no Next.js foi quase uma escolha binária: ou você buscava a página de destino inteira antes do clique, inclusive a parte dinâmica, ou desligava o prefetch e jogava todo o trabalho para depois do clique. Com o Next.js 16.4, lançado em 6 de outubro, isso mudou de vez. Minha tese: a pergunta deixou de ser "prefetchar ou não" e virou "qual pedaço de cada página precisa estar pronto antes do clique". Neste post eu mostro como tomar essa decisão, com código, a partir do estudo de caso que a Vercel publicou em 16 de outubro sobre o v0.

#O que mudou no 16.4

O Partial Prefetching chegou no 16.3, e o 16.4 deu o passo que importa para projetos novos: o create-next-app agora já vem com Cache Components e Partial Prefetching ligados, como modelo recomendado para todo app Next.js.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;

Na prática, o framework passa a prefetchar só as partes reutilizáveis da rota. O resto carrega durante a navegação. Conteúdo em cache, inclusive conteúdo personalizado guardado no navegador, pode entrar no prefetch e ser reaproveitado quando o usuário volta à página. Em apps existentes, dá para adotar rota por rota com export const prefetch = "partial" no arquivo da página.

#As três decisões por trecho de página

O ponto central do estudo de caso é separar cada página em pedaços e decidir o destino de cada um. Eu resumiria assim:

Tipo de conteúdoOnde fica o cacheAPIComo invalidar
Público, igual para todos (catálogo, template, post)Cache compartilhado do servidor"use cache: remote" + cacheTag() + cacheLife()revalidateTag() quando o dado muda
Personalizado (configurações, painel do usuário)Navegador do usuário"use cache: private" + cacheLife()updateTag() na Server Action que altera o dado
Baixa prioridade (comentários, histórico, "recentes")Cache, mas fora do prefetchawait navigation() antes da buscaIgual ao bloco em que está

#1. Dado público: cache compartilhado e invalidação por tag

Um exemplo com Supabase: a página de produto de uma loja. O detalhe do produto é igual para todo mundo, então pode ir para o cache compartilhado. Repare que o cliente do Supabase aqui não lê cookies: ele usa a chave pública, então o RLS só devolve o que o papel anônimo pode ver. Isso evita colocar dado de um usuário num cache que todos leem.

// lib/produtos.ts
import { createClient } from "@supabase/supabase-js";
import { cacheLife, cacheTag } from "next/cache";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
);

export async function getProdutoPublico(id: string) {
  "use cache: remote";
  cacheTag(`produto:${id}`);
  cacheLife("hours");

  const { data, error } = await supabase
    .from("produtos")
    .select("id, nome, descricao, preco")
    .eq("id", id)
    .single();

  if (error) throw error;
  return data;
}

Quando o produto é editado no painel administrativo, a Server Action marca o cache como velho:

// app/admin/produtos/actions.ts
"use server";
import { revalidateTag } from "next/cache";

export async function publicarProduto(id: string) {
  // ...grava no banco
  revalidateTag(`produto:${id}`, "max");
}

#2. Dado personalizado: cache no navegador

Telas de configuração e painéis por usuário não podem ir para um cache compartilhado. O "use cache: private" mantém o resultado no navegador. No caso do v0, as páginas de configurações recebem o escopo da conta como argumento, o que coloca esse valor na chave do cache e separa os dados de cada conta. Para dados que o próprio usuário altera, o par é sempre leitura em cache + updateTag() na Server Action, que expira o cache na hora e mostra o valor novo na mesma ida ao servidor.

Esse é o cenário de sistemas como o CRM sob medida do meu portfólio, que tem acesso por permissão: o que cada pessoa vê depende de quem ela é, então esse conteúdo é candidato a cache privado, nunca ao compartilhado.

#3. Baixa prioridade: em cache, mas fora do prefetch

A API nova do 16.4 é navigation(), importada de next/cache. Todo trabalho em cache que vem depois dela espera a página abrir, mas continua disponível quando o usuário volta. Serve para blocos úteis, porém não urgentes:

// app/produtos/[id]/avaliacoes.tsx
import { navigation } from "next/cache";

export async function Avaliacoes({ produtoId }: { produtoId: string }) {
  await navigation();
  const avaliacoes = await getAvaliacoes(produtoId);
  return <ListaDeAvaliacoes itens={avaliacoes} />;
}

Na página, esse componente fica dentro de um Suspense com skeleton próprio. O título, o preço e o botão de compra chegam com o prefetch, e as avaliações carregam logo depois.

#Cuidado com grades grandes: prefetch por intenção

Aqui está o detalhe que mais me interessou no estudo de caso. Na primeira versão, o time colocou prefetch={true} em todos os cards da página de templates do v0. O conteúdo ficou pronto antes do clique, mas as requisições de prefetch para a rota de detalhe subiram 2,9×, porque cada card visível disparava um prefetch completo.

A correção foi esperar um sinal de intenção: hover, foco ou toque. Um componente reutilizável para isso:

// components/intent-link.tsx
"use client";
import Link from "next/link";
import { type ReactNode, useState } from "react";

interface IntentLinkProps {
  href: string;
  children: ReactNode;
  className?: string;
}

export function IntentLink({ href, children, className }: IntentLinkProps) {
  const [intent, setIntent] = useState(false);
  const marcarIntencao = () => setIntent(true);

  return (
    <Link
      href={href}
      className={className}
      prefetch={intent ? true : "auto"}
      onMouseEnter={marcarIntencao}
      onFocus={marcarIntencao}
      onTouchStart={marcarIntencao}
    >
      {children}
    </Link>
  );
}

Minha regra prática: grupos pequenos e fixos (menu, abas, três ou quatro links principais) podem prefetchar quando aparecem na tela. Listas e grades grandes usam intenção.

#Transforme o objetivo em teste

O estudo de caso usa o helper instant() do pacote @next/playwright. Ele segura o conteúdo que só chegaria durante a navegação, então o teste consegue afirmar o que já estava pronto antes do clique. Se alguém remover o cache de um bloco no futuro, o teste quebra no CI. É o tipo de garantia que eu prefiro a "parece rápido na minha máquina".

#Por que isso importa para quem contrata

Navegação instantânea não é enfeite: é o que faz um painel interno parecer um app e uma loja não perder o cliente entre a listagem e o produto. Mas prefetch sem critério custa requisição, CPU e banda. O que eu quero mostrar com esse tipo de análise é o raciocínio que uso em projeto: separar dado público de dado do usuário, decidir o que precisa estar pronto antes do clique e proteger essa decisão com teste.

#Conclusão

  • Pense por trecho de página, não por rota: público vai para "use cache: remote", personalizado para "use cache: private", e o que pode esperar fica depois de navigation().
  • Prefetch por intenção em listas grandes. No v0, prefetchar todo card visível multiplicou as requisições por 2,9.
  • Trave o comportamento com instant() no CI e confira onde o cache remoto mora se o app não roda na Vercel.

#Fontes

Quer esse cuidado no seu projeto?

Diagnóstico gratuito de 30 min: olho a sua stack e digo o que precisa de atenção.

Quero meu diagnóstico

Continue lendo

Todos os posts

Prefere que eu cuide disso no seu projeto?

O mesmo cuidado dos posts, aplicado ao seu produto. Diagnóstico gratuito de 30 min, sem compromisso.