Meta-framework (Next.js, Nuxt) como punto de partida predeterminado
1. Descripción general
Hace unos años, un nuevo proyecto frontend generalmente comenzaba con React o Vue puro, y luego añadías manualmente enrutador, bundler, forma de obtener datos y renderizado del lado del servidor. Hoy, la primera pregunta generalmente es: “¿Usamos Next.js o Nuxt?”. Estos meta-frameworks se están convirtiendo en el punto de partida predeterminado.
Este artículo explica qué es un meta-framework y por qué se elige de forma predeterminada. Verás cómo construir una página de producto con Next.js y Nuxt, llamando a un backend Spring Boot. Al final están los errores comunes y los casos en los que no deberías usarlos.
2. ¿Qué es un meta-framework?
React y Vue son librerías de UI. Se encargan de renderizar componentes y gestionar estado. No deciden qué URL corresponde a qué página, dónde se cargan los datos, ni si el HTML se genera en el servidor o en el navegador.
Un meta-framework es una capa que se sitúa encima de la librería de UI. Toma esas decisiones por ti:
- Next.js se construye sobre React.
- Nuxt se construye sobre Vue.
- Otros del mismo grupo incluyen SvelteKit, Remix/React Router, Astro, SolidStart…
2.1. Lo que viene incluido de serie
Un meta-framework típico proporciona:
- File-based routing: la estructura de carpetas determina las URLs. No necesitas declarar rutas manualmente.
- Múltiples modos de renderizado: SSR (renderizado en servidor con cada solicitud), SSG (renderizado en tiempo de construcción), y modos híbridos como revalidación periódica o solo renderizado en cliente.
- Obtención de datos vinculada a rutas: los datos se cargan en el servidor antes de enviar el HTML.
- Server routes: escribe pequeños endpoints de API dentro del proyecto frontend, usualmente para actuar como BFF (Backend for Frontend).
- Construcción y optimización: code splitting, optimización de imágenes, fuentes y activos están preconfigurados.
Se puede comparar con Spring Boot. Spring Framework te proporciona todos los ladrillos. Spring Boot añade convención y auto-configuración para que ejecutes las cosas de inmediato. Los meta-frameworks juegan un papel similar con React y Vue.
3. ¿Por qué se convierte en la opción predeterminada?
Hay varias razones fundamentales.
Reducir decisiones iniciales. Configurar manualmente enrutador, SSR, bundler y code splitting consume mucho esfuerzo. Cada equipo lo hace de una forma diferente. Los meta-frameworks proporcionan una estructura común, así que los nuevos miembros del proyecto entienden el código más fácilmente.
SEO y velocidad de visualización. Una aplicación SPA pura envía un archivo HTML prácticamente vacío y espera a que JavaScript se cargue para tener contenido. Con SSR o SSG, el navegador y los bots reciben HTML con contenido desde el principio. Esto es importante para tiendas en línea, blogs y páginas de destino.
Renderizado flexible por ruta. La página principal puede renderizarse en tiempo de construcción. Las páginas de productos se cachean y se actualizan periódicamente. Las páginas de administración se renderizan solo en cliente. Todo en el mismo proyecto.
Límites servidor/cliente más claros. El código que se ejecuta en el servidor puede leer variables de entorno secretas e invocar APIs internas sin exponerse al navegador. Tampoco necesitas habilitar CORS en el backend.
Ecosistema y documentación apuntan en esa dirección. La documentación oficial de React ya sugiere comenzar con un framework. Muchas plataformas de alojamiento también soportan de forma nativa la forma de desplegar Next.js y Nuxt.
4. Aplicando con Next.js
Supongamos que tienes un backend Spring Boot que proporciona GET /api/products/{id}. Necesitas una página /products/{id} que muestre el producto, con buen SEO y sin llamar a la API en cada solicitud.
4.1. Inicializar el proyecto
npx create-next-app@latest shop-web --typescript --app
cd shop-web
npm run dev
El comando anterior crea un proyecto usando App Router. La carpeta app/ contiene las rutas. La aplicación se ejecuta en http://localhost:3000.
Declara la dirección del backend en .env.local:
API_URL=http://localhost:8080
Esta variable no tiene el prefijo NEXT_PUBLIC_. Por lo tanto, solo existe en el servidor y no se incluye en el JavaScript que se envía al navegador.
4.2. Página de producto con 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("No se pudo cargar el producto");
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("vi-VN")} ₫</p>
<AddToCartButton productId={product.id} />
</main>
);
}
Este código hace lo siguiente:
- La ruta
app/products/[id]/page.tsxgenera automáticamente la ruta/products/:id.[id]es un parámetro dinámico. - El componente es
asyncy se ejecuta en el servidor. Puedes usarawait fetchdirectamente, sin necesidad deuseEffectni estado de carga. next: { revalidate: 60 }cachea el resultado y lo actualiza como máximo cada 60 segundos. El backend no es llamado en cada visita.notFound()devuelve una página 404 estándar cuando el backend informa que no hay producto.
Abre http://localhost:3000/products/1 y mira el código fuente HTML. Verás que el contenido ya está en el HTML:
<main><h1>Nón lá Huế</h1><p>150.000 ₫</p>...
4.3. Parte interactiva con Client Component
El botón “Agregar al carrito” necesita manejar eventos de clic, así que debe ejecutarse en el 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 ? "Agregado" : `Agregar producto #${productId} al carrito`}
</button>
);
}
La directiva "use client" marca el límite. Solo este componente y lo que importa será enviado al navegador como JavaScript. El resto de la página es solo HTML.
5. La misma idea con Nuxt
Nuxt resuelve el mismo problema a la manera de Vue. Esta vez añadiremos una server route para actuar como BFF. El navegador solo habla con Nuxt, y Nuxt llama al Spring Boot.
Inicializar el proyecto:
npm create nuxt@latest shop-nuxt
cd shop-nuxt
npm run dev
Configuración en nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiUrl: "http://localhost:8080", // solo en el servidor
},
routeRules: {
"/": { prerender: true }, // prerrenderizar en tiempo de construcción
"/products/**": { swr: 60 }, // cachear, revalidar en segundo plano cada 60s
"/admin/**": { ssr: false }, // solo renderizar en cliente
},
});
routeRules muestra claramente el beneficio de “renderizar por ruta”. Tres estrategias diferentes en solo unas pocas líneas de configuración. La clave apiUrl está fuera de public, así que no se expone al navegador. Puedes reemplazar este valor con la variable de entorno NUXT_API_URL al desplegar.
Server route actuando 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}`);
});
El nombre de archivo [id].get.ts genera automáticamente el endpoint GET /api/products/:id dentro de la aplicación Nuxt.
Página que muestra el producto:
<!-- 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">Producto no encontrado.</p>
<template v-else-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.price.toLocaleString("vi-VN") }} ₫</p>
</template>
</main>
</template>
useFetch se ejecuta en el servidor en la carga inicial. Nuxt entonces transfiere los datos al cliente, por lo que el navegador no vuelve a llamar a la API al hidratarse. Cuando el usuario navega al lado del cliente, las solicitudes van a /api/products/:id del mismo origen, así que aún no necesitas CORS.
6. Lado del backend Spring Boot
Ambos frontends llaman al mismo 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 devuelve 200 con el cuerpo cuando hay un producto, y devuelve 404 cuando el Optional está vacío. La página Next.js de la sección 4.2 se basa en exactamente este código 404 para llamar a notFound().
Nota que el controlador no tiene @CrossOrigin. Todas las solicitudes provienen del servidor del meta-framework, no del navegador. Por lo tanto, el backend puede estar en una red interna.
Este contrato de API es en lo que ambos frontends confían, así que debe tener pruebas:
@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);
}
}
La primera prueba verifica la estructura JSON que la página frontend lee (name, price). La segunda prueba mantiene el comportamiento 404 correcto, ya que la lógica notFound() en el frontend depende de ella.
7. Errores comunes
Exponer variables secretas al cliente. En Next.js, cualquier variable con el prefijo NEXT_PUBLIC_ se incluirá en el bundle. En Nuxt, todo en runtimeConfig.public también. No coloques claves de API allí.
Nota: Un Server Component importado por error en un Client Component traerá el código del servidor al navegador o causará un error de construcción. Mantén clara la separación de qué archivo se ejecuta dónde. Solo marca
"use client"en los componentes que realmente necesitan interactividad.
Usar APIs del navegador mientras se renderiza en el servidor. Llamar a window o localStorage en código que se ejecuta como SSR causará window is not defined. Coloca estos fragmentos en un Client Component, o en onMounted en Vue.
Hidratación incorrecta. Este error ocurre cuando el HTML renderizado en el servidor es diferente del primer renderizado en el cliente. Las causas comunes son new Date(), Math.random() o formato de números según diferentes configuraciones regionales entre servidor y navegador.
No entender el mecanismo de caché. Los datos “que no se actualizan” generalmente se deben a la caché del framework, no al backend. Declara revalidate o swr deliberadamente para cada ruta.
Convertir el meta-framework en el backend principal. Las server routes son muy convenientes para un BFF. Pero si colocas lógica empresarial, transacciones y acceso a bases de datos allí, tendrás dos backends que mantener. Mantén la lógica empresarial en Spring Boot.
8. Cuándo no deberías usarlo
El valor predeterminado no significa siempre correcto. Considera alternativas cuando:
- La aplicación está completamente detrás de un login, no necesita SEO. Ejemplos: dashboard interno, herramientas de administración. Una SPA construida con Vite podría ser más simple: desplegar como archivos estáticos, sin necesidad de servidor Node.js.
- La infraestructura no puede ejecutar Node.js, o el equipo de operaciones solo está familiarizado con desplegar JARs. SSR necesita un runtime separado para ejecutar, monitorear y escalar.
- El sitio es principalmente contenido estático, poco interactivo. Un generador de sitios estáticos, o Thymeleaf renderizado directamente en Spring Boot, podría ser suficiente.
- Aplicaciones incrustadas como widgets, micro-frontends dentro de una página existente, o aplicaciones de escritorio. El enrutamiento y SSR del meta-framework se vuelven una carga.
- Equipo pequeño, no familiarizado con el modelo servidor/cliente. Los conceptos de Server Component, hidratación y caché tienen su propia curva de aprendizaje. Sin entenderlos, es fácil encontrar errores difíciles de depurar.
Una pregunta simple te ayuda a decidir: ¿hay alguna página que necesite HTML con contenido desde el servidor? Si la respuesta es no, el mayor beneficio del meta-framework prácticamente desaparece.
9. Conclusión
Los meta-frameworks como Next.js y Nuxt incluyen de serie enrutamiento, modos de renderizado, obtención de datos y server routes. Su papel con respecto a React y Vue es similar al de Spring Boot con Spring. Se convierten en la opción predeterminada porque reducen las decisiones iniciales, soportan SEO y velocidad de visualización, permiten elegir estrategias de renderizado por ruta y crean límites claros servidor/cliente.
En la práctica, un modelo efectivo es dejar que el meta-framework maneje la presentación y el BFF, mientras que Spring Boot mantiene la lógica empresarial y los datos. Necesitas dominar claramente los límites servidor/cliente y el mecanismo de caché. Y para aplicaciones internas que no necesitan SEO, una SPA pura sigue siendo una opción razonable.