Meta-framework (Next.js, Nuxt) como ponto de partida padrão
1. Visão geral
Há alguns anos, um novo projeto frontend geralmente começava com React ou Vue puro, depois você mesmo adicionava router, bundler, forma de fazer fetch de dados e renderização no servidor. Agora a primeira pergunta é geralmente: “Vou usar Next.js ou Nuxt?”. Esses meta-frameworks estão se tornando o ponto de partida padrão.
Este artigo explica o que é um meta-framework e por que ele é a escolha padrão. Você verá como construir uma página de produto usando Next.js e Nuxt, chamando um backend Spring Boot. No final do artigo estão os erros mais comuns e os casos em que não devem ser usados.
2. O que é um meta-framework?
React e Vue são bibliotecas de UI. Eles lidam com renderizar componentes e gerenciar estado. Eles não decidem qual URL corresponde a qual página, onde os dados são carregados ou se o HTML é gerado no servidor ou no navegador.
Um meta-framework é uma camada acima da biblioteca de UI. Ele toma essas decisões antecipadamente:
- Next.js é construído sobre React.
- Nuxt é construído sobre Vue.
- No mesmo grupo há também SvelteKit, Remix/React Router, Astro, SolidStart…
2.1. O que é fornecido pronto
Um meta-framework típico fornece:
- File-based routing: a estrutura de pastas determina a URL. Você não precisa declarar rotas manualmente.
- Múltiplos modos de renderização: SSR (render no servidor a cada request), SSG (render antecipado durante o build) e modos híbridos como revalidação periódica ou renderização apenas no lado do cliente.
- Data fetching acoplado à rota: dados são carregados no servidor antes de enviar o HTML de volta.
- Server routes: escreva pequenos endpoints de API dentro do projeto frontend, frequentemente usados como BFF (Backend for Frontend).
- Build e otimização: code splitting, otimização de imagens, fontes e assets vêm pré-configurados.
Pode-se comparar com Spring Boot. Spring Framework oferece todos os componentes. Spring Boot adiciona convenção e auto-configuração para que você possa começar imediatamente. Meta-frameworks desempenham um papel similar com React e Vue.
3. Por que se tornou a escolha padrão?
Há algumas razões fundamentais.
Reduz decisões iniciais. Montar router, SSR, configurar bundler e fazer code splitting manualmente consome muito tempo. Cada equipe faz de uma forma diferente. Um meta-framework fornece uma estrutura comum, então novos membros da equipe encontram código mais fácil de ler.
SEO e velocidade de exibição. Um aplicativo SPA puro envia um arquivo HTML quase vazio e espera o JavaScript carregar para ter conteúdo. Com SSR ou SSG, o navegador e bots recebem HTML com conteúdo imediatamente. Isso é importante para páginas de vendas, blogs e landing pages.
Renderização flexível por rota. A página inicial pode ser pré-renderizada durante o build. A página do produto é armazenada em cache e atualizada periodicamente. A página de administração é renderizada apenas no lado do cliente. Tudo fica no mesmo projeto.
Limite mais claro entre servidor e cliente. Código executado no servidor pode ler variáveis de ambiente secretas e chamar APIs internas sem expor ao navegador. Você também não precisa abrir CORS no backend.
Ecossistema e documentação apontam nessa direção. A documentação oficial do React já sugere começar com um framework. Muitas plataformas de hospedagem também suportam deployment de Next.js e Nuxt.
4. Aplicando com Next.js
Suponha que você tem um backend Spring Boot fornecendo GET /api/products/{id}. Você precisa de uma página /products/{id} que exiba o produto, tenha bom SEO e não chame a API a cada request.
4.1. Inicializando o projeto
npx create-next-app@latest shop-web --typescript --app
cd shop-web
npm run dev
O comando acima cria um projeto usando o App Router. A pasta app/ contém as rotas. A aplicação roda em http://localhost:3000.
Declare o endereço do backend em .env.local:
API_URL=http://localhost:8080
Esta variável não tem o prefixo NEXT_PUBLIC_. Portanto, ela existe apenas no servidor e não é incluída no JavaScript enviado ao navegador.
4.2. Página de produto usando Server Component
// app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import AddToCartButton from "./AddToCartButton";
type Product = { id: number; name: string; price: number };
async function getProduct(id: string): Promise<Product | null> {
const res = await fetch(`${process.env.API_URL}/api/products/${id}`, {
next: { revalidate: 60 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error("Não foi possível carregar o produto");
return res.json();
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
if (!product) notFound();
return (
<main>
<h1>{product.name}</h1>
<p>{product.price.toLocaleString("pt-BR")} ₫</p>
<AddToCartButton productId={product.id} />
</main>
);
}
Este código faz o seguinte:
- O caminho
app/products/[id]/page.tsxgera automaticamente a rota/products/:id.[id]é um parâmetro dinâmico. - O componente é
asynce roda no servidor. Você pode fazerawait fetchdiretamente, sem precisar deuseEffectou estado de carregamento. next: { revalidate: 60 }armazena em cache o resultado e o atualiza no máximo a cada 60 segundos. O backend não é chamado em cada acesso.notFound()retorna uma página 404 padrão quando o backend indica que o produto não existe.
Abra http://localhost:3000/products/1 e veja o source do HTML. Você verá que o conteúdo já está no HTML:
<main><h1>Nón lá Huế</h1><p>150.000 ₫</p>...
4.3. Parte interativa usando Client Component
O botão “Adicionar ao carrinho” precisa manipular eventos de clique, então ele deve rodar no navegador:
// app/products/[id]/AddToCartButton.tsx
"use client";
import { useState } from "react";
export default function AddToCartButton({ productId }: { productId: number }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)} disabled={added}>
{added ? "Adicionado" : `Adicionar produto #${productId} ao carrinho`}
</button>
);
}
A diretiva "use client" marca o limite. Apenas este componente e o que ele importa são enviados ao navegador como JavaScript. O resto da página é apenas HTML.
5. A mesma ideia com Nuxt
Nuxt resolve o mesmo problema no estilo Vue. Desta vez adicionaremos uma server route como BFF. O navegador só fala com Nuxt, e Nuxt chama o Spring Boot.
Inicializando o projeto:
npm create nuxt@latest shop-nuxt
cd shop-nuxt
npm run dev
Configuração em nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiUrl: "http://localhost:8080", // apenas no servidor
},
routeRules: {
"/": { prerender: true }, // pré-renderizar durante o build
"/products/**": { swr: 60 }, // cache, atualizar em background a cada 60s
"/admin/**": { ssr: false }, // renderizar apenas no lado do cliente
},
});
routeRules demonstra claramente o benefício de “renderizar por rota”. Três estratégias diferentes em poucas linhas de configuração. A chave apiUrl fica fora de public então não é exposta ao navegador. Você pode sobrescrever este valor com a variável de ambiente NUXT_API_URL ao fazer deploy.
Server route como BFF:
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const config = useRuntimeConfig(event);
return await $fetch(`${config.apiUrl}/api/products/${id}`);
});
O nome de arquivo [id].get.ts gera o endpoint GET /api/products/:id dentro da aplicação Nuxt.
Página exibindo o produto:
<!-- pages/products/[id].vue -->
<script setup lang="ts">
type Product = { id: number; name: string; price: number };
const route = useRoute();
const { data: product, error } = await useFetch<Product>(
`/api/products/${route.params.id}`
);
</script>
<template>
<main>
<p v-if="error">Não foi possível encontrar o produto.</p>
<template v-else-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.price.toLocaleString("pt-BR") }} ₫</p>
</template>
</main>
</template>
useFetch roda no servidor na primeira carga. Nuxt então transfere os dados para o cliente, portanto o navegador não chama a API novamente ao fazer hydrate. Quando o usuário muda de página no lado do cliente, a requisição vai para /api/products/:id na mesma origem, então ainda não precisa de CORS.
6. Lado do backend Spring Boot
Ambos os frontends chamam a mesma API:
// Product.java
public record Product(long id, String name, long price) {}
// ProductCatalog.java
@Component
public class ProductCatalog {
private final Map<Long, Product> products = Map.of(
1L, new Product(1, "Nón lá Huế", 150_000),
2L, new Product(2, "Áo dài lụa", 1_200_000));
public Optional<Product> findById(long id) {
return Optional.ofNullable(products.get(id));
}
}
// ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductCatalog catalog;
public ProductController(ProductCatalog catalog) {
this.catalog = catalog;
}
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable long id) {
return ResponseEntity.of(catalog.findById(id));
}
}
ResponseEntity.of retorna 200 com o corpo quando há um produto, e retorna 404 quando Optional está vazio. A página Next.js na seção 4.2 depende exatamente deste código 404 para chamar notFound().
Observe que o controller não tem @CrossOrigin. Todas as requisições vêm do servidor do meta-framework, não do navegador. Portanto, o backend pode estar em uma rede interna.
Este contrato de API é o que ambos os frontends dependem, portanto deve ter testes:
@WebMvcTest(ProductController.class)
@Import(ProductCatalog.class)
class ProductControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void returnsProductWhenFound() throws Exception {
MvcResult result = mockMvc.perform(get("/api/products/1")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8))
.contains("\"name\":\"Nón lá Huế\"")
.contains("\"price\":150000");
}
@Test
void returns404WhenMissing() throws Exception {
MvcResult result = mockMvc.perform(get("/api/products/99")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(404);
}
}
O primeiro teste verifica a estrutura JSON que a página frontend lê (name, price). O segundo teste garante o comportamento 404 correto, porque a lógica notFound() no frontend depende disso.
7. Erros comuns
Expor variáveis secretas para o cliente. Em Next.js, toda variável com prefixo NEXT_PUBLIC_ será embutida no bundle. Em Nuxt, tudo em runtimeConfig.public também. Não coloque chaves de API lá.
Nota: Um Server Component importado por engano em um Client Component puxará o código do servidor para o navegador, ou causará erro de build. Separe claramente qual arquivo roda onde. Marque
"use client"apenas em componentes folha que realmente precisem de interação.
Usar APIs do navegador ao renderizar no servidor. Chamar window ou localStorage em código que roda SSR causará o erro window is not defined. Coloque esses trechos em um Client Component, ou em onMounted no Vue.
Hydration mismatch. Este erro ocorre quando o HTML renderizado no servidor é diferente da renderização inicial no cliente. As causas comuns são new Date(), Math.random() ou formatação de números com locales diferentes entre servidor e navegador.
Não entender o mecanismo de cache. Dados que “não atualizam” geralmente é por causa do cache do framework, não do backend. Declare revalidate ou swr propositalmente para cada rota.
Transformar o meta-framework em backend principal. Server routes são convenientes para BFF. Mas se você colocar lógica de negócio, transações e acesso a banco de dados lá, você terá dois backends para manter. Mantenha a lógica de negócio no Spring Boot.
8. Quando não usar
Padrão não significa sempre correto. Considere alternativas quando:
- A aplicação fica completamente atrás de login, não precisa de SEO. Exemplo: dashboard interno, ferramentas administrativas. Uma SPA construída com Vite pode ser mais simples: faça deploy como arquivos estáticos, sem precisar de servidor Node.js.
- Infraestrutura não pode rodar Node.js, ou a equipe de operações está acostumada apenas a fazer deploy de JARs. SSR precisa de um runtime separado para rodar, monitorar e escalar.
- O site é principalmente conteúdo estático, com pouca interação. Um gerador de site estático, ou Thymeleaf renderizado direto em Spring Boot, podem ser suficientes.
- Aplicações embarcadas como widgets, micro-frontends em páginas existentes, ou aplicações desktop. Roteamento e SSR do meta-framework se tornam um peso morto.
- Equipe pequena, não familiarizada com o modelo servidor/cliente. Conceitos de Server Component, hydration e cache têm sua própria curva de aprendizado. Se você não os compreender, vai encontrar erros difíceis de debugar.
Uma pergunta simples ajuda você a decidir: alguma página precisa de HTML com conteúdo imediatamente do servidor? Se a resposta é não, o maior benefício do meta-framework praticamente desaparece.
9. Conclusão
Meta-frameworks como Next.js e Nuxt empacotam roteamento, modos de renderização, data fetching e server routes. Seu papel com React e Vue é semelhante ao de Spring Boot com Spring. Eles se tornaram a escolha padrão porque reduzem decisões iniciais, suportam SEO e velocidade de exibição, permitem escolher estratégias de renderização por rota e criam limites claros entre servidor e cliente.
Na prática, o modelo eficiente é deixar o meta-framework cuidar da apresentação e BFF, enquanto Spring Boot mantém a lógica de negócio e dados. Você precisa compreender bem os limites servidor/cliente e o mecanismo de cache. E para aplicações internas que não precisam de SEO, uma SPA pura ainda é uma escolha razoável.