Meta-framework (Next.js, Nuxt) comme point de départ par défaut

1. Vue d’ensemble

Il y a quelques années, un nouveau projet frontend commençait généralement par React ou Vue pur, puis vous ajoutiez manuellement un routeur, un bundler, une stratégie de récupération de données et le rendu côté serveur. Aujourd’hui, la première question est souvent : « On utilise Next.js ou Nuxt ? ». Ces meta-frameworks deviennent le point de départ par défaut.

Cet article explique ce qu’est un meta-framework et pourquoi il est choisi par défaut. Vous verrez comment construire une page produit avec Next.js et Nuxt, en appelant un backend Spring Boot. À la fin, vous trouverez les erreurs courantes et les cas où il ne faut pas les utiliser.

2. Qu’est-ce qu’un meta-framework ?

React et Vue sont des bibliothèques UI. Ils gèrent le rendu des composants et l’état. Ils ne décident pas quelle URL correspond à quelle page, d’où les données sont chargées, ou si le HTML est généré sur le serveur ou dans le navigateur.

Un meta-framework est une couche au-dessus de la bibliothèque UI. Il fournit ces décisions par défaut :

  • Next.js est construit sur React.
  • Nuxt est construit sur Vue.
  • Le même groupe inclut aussi SvelteKit, Remix/React Router, Astro, SolidStart…

2.1. Ce qui est fourni d’emblée

Un meta-framework typique offre :

  • Routage basé sur les fichiers : la structure des dossiers détermine les URL. Pas besoin de déclarer les routes manuellement.
  • Plusieurs modes de rendu : SSR (rendu côté serveur à chaque requête), SSG (rendu au moment du build), et des modes mixtes comme la révalidation périodique ou le rendu côté client uniquement.
  • Chargement de données lié aux routes : les données sont récupérées sur le serveur avant d’envoyer le HTML.
  • Routes serveur : écrivez de petits endpoints API directement dans votre projet frontend, souvent utilisés comme BFF (Backend for Frontend).
  • Build et optimisation : la séparation du code, l’optimisation des images, des polices et des assets sont tous préconfigurés.

On peut comparer cela à Spring Boot. Spring Framework vous donne tous les briques. Spring Boot ajoute des conventions et l’auto-configuration pour que vous commenciez tout de suite. Un meta-framework joue un rôle similaire avec React et Vue.

3. Pourquoi c’est devenu le choix par défaut ?

Il y a plusieurs raisons fondamentales.

Réduire les décisions initiales. Configurer manuellement le routeur, le SSR, le bundler et la séparation du code prend du temps. Chaque équipe le fait différemment. Un meta-framework offre une structure commune, ce qui rend le code plus facile à lire pour les nouveaux membres.

SEO et vitesse d’affichage. Une application SPA pure envoie un fichier HTML presque vide, puis attend que JavaScript se charge avant d’avoir du contenu. Avec SSR ou SSG, le navigateur et les robots reçoivent du HTML avec du contenu dès le départ. C’est important pour les pages de vente, les blogs et les pages d’accueil.

Rendu flexible selon chaque route. La page d’accueil peut être prérendue au build. Les pages produits peuvent être mises en cache et rafraîchies périodiquement. Les pages d’administration ne sont rendues que côté client. Tout se trouve dans le même projet.

Des limites server/client plus claires. Le code qui s’exécute sur le serveur peut lire des variables d’environnement secrètes et appeler des API internes sans les exposer au navigateur. Vous n’avez pas besoin d’ouvrir CORS au backend.

L’écosystème et la documentation pointent dans cette direction. La documentation officielle de React suggère maintenant de commencer par un framework. De nombreuses plateformes d’hébergement supportent déjà le déploiement de Next.js et Nuxt.

4. Application avec Next.js

Supposons que vous disposez d’un backend Spring Boot qui expose GET /api/products/{id}. Vous avez besoin d’une page /products/{id} qui affiche le produit, a un bon SEO et ne fait pas d’appel API à chaque requête.

4.1. Initialiser le projet

npx create-next-app@latest shop-web --typescript --app
cd shop-web
npm run dev

Cette commande crée un projet utilisant App Router. Le dossier app/ contient les routes. L’application s’exécute sur http://localhost:3000.

Déclarez l’adresse du backend dans .env.local :

API_URL=http://localhost:8080

Cette variable n’a pas le préfixe NEXT_PUBLIC_. Par conséquent, elle n’existe que côté serveur et n’est pas intégrée au JavaScript envoyé au navigateur.

4.2. Page produit avec 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("Impossible de charger le produit");
  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("fr-FR")} €</p>
      <AddToCartButton productId={product.id} />
    </main>
  );
}

Ce code fait les choses suivantes :

  • Le chemin app/products/[id]/page.tsx génère automatiquement la route /products/:id. [id] est un paramètre dynamique.
  • Le composant est async et s’exécute sur le serveur. Vous pouvez await fetch directement, sans besoin de useEffect ou d’état de chargement.
  • next: { revalidate: 60 } met en cache le résultat et le rafraîchit toutes les 60 secondes au maximum. Le backend n’est pas appelé à chaque visite.
  • notFound() renvoie une page 404 standard quand le backend rapporte qu’il n’y a pas de produit.

Ouvrez http://localhost:3000/products/1 et consultez le source HTML. Vous verrez que le contenu est déjà présent dans le HTML :

<main><h1>Chapeau conique Huế</h1><p>150 000 €</p>...

4.3. La partie interactive avec Client Component

Le bouton « Ajouter au panier » a besoin de gérer les clics, il doit donc s’exécuter dans le navigateur :

// 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 ? "Ajouté" : `Ajouter le produit #${productId} au panier`}
    </button>
  );
}

La directive "use client" marque la limite. Seul ce composant et ce qu’il importe sont envoyés au navigateur sous forme de JavaScript. Le reste de la page n’est que du HTML.

5. La même idée avec Nuxt

Nuxt résout le même problème selon l’approche Vue. Cette fois, on ajoute une route serveur pour en faire un BFF. Le navigateur ne parle qu’à Nuxt, qui appelle ensuite Spring Boot.

Initialiser le projet :

npm create nuxt@latest shop-nuxt
cd shop-nuxt
npm run dev

Configuration dans nuxt.config.ts :

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    apiUrl: "http://localhost:8080", // uniquement côté serveur
  },
  routeRules: {
    "/": { prerender: true },          // prérendu au build
    "/products/**": { swr: 60 },       // cache, rafraîchit en arrière-plan toutes les 60s
    "/admin/**": { ssr: false },       // rendu uniquement côté client
  },
});

routeRules montre clairement l’avantage du « rendu par route ». Trois stratégies différentes en quelques lignes de configuration. La clé apiUrl se trouve en dehors de public, elle n’est donc pas exposée au navigateur. Vous pouvez remplacer cette valeur avec la variable d’environnement NUXT_API_URL lors du déploiement.

Route serveur agissant en 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}`);
});

Le nom de fichier [id].get.ts génère l’endpoint GET /api/products/:id directement dans l’application Nuxt.

Page affichant le produit :

<!-- 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">Produit non trouvé.</p>
    <template v-else-if="product">
      <h1>{{ product.name }}</h1>
      <p>{{ product.price.toLocaleString("fr-FR") }} €</p>
    </template>
  </main>
</template>

useFetch s’exécute sur le serveur lors du premier chargement. Nuxt envoie ensuite les données au client, le navigateur n’appelle donc pas l’API à nouveau lors de l’hydratation. Quand l’utilisateur navigue côté client, la requête va vers /api/products/:id sur la même origine, il n’y a donc pas besoin de CORS.

6. Côté backend Spring Boot

Les deux frontends appellent la même 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, "Chapeau conique Huế", 150_000),
            2L, new Product(2, "Áo dài en soie", 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 retourne 200 avec le corps quand il y a un produit, et 404 quand l’Optional est vide. La page Next.js de la section 4.2 s’appuie sur ce code 404 pour appeler notFound().

Notez que le contrôleur n’a pas @CrossOrigin. Toutes les requêtes proviennent du serveur du meta-framework, pas du navigateur. Le backend peut donc se trouver sur un réseau interne.

Ce contrat d’API est ce dont dépendent les deux frontends, il faut donc le tester :

@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\":\"Chapeau conique Huế\"")
                .contains("\"price\":150000");
    }

    @Test
    void returns404WhenMissing() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/products/99")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(404);
    }
}

Le premier test vérifie la structure JSON que la page frontend lit (name, price). Le deuxième test maintient le comportement 404, car la logique notFound() du frontend en dépend.

7. Erreurs courantes

Exposer les secrets au client. Dans Next.js, toute variable avec le préfixe NEXT_PUBLIC_ est intégrée au bundle. Dans Nuxt, tout ce qui se trouve dans runtimeConfig.public aussi. Ne mettez pas les clés d’API là.

Attention : Un Server Component qui importe accidentellement un Client Component va tirer le code serveur vers le navigateur, ou causer une erreur de build. Séparez clairement les fichiers selon où ils s’exécutent. Marquez "use client" uniquement sur les composants feuilles qui ont vraiment besoin d’interactivité.

Utiliser les API du navigateur lors du rendu côté serveur. Appeler window ou localStorage dans le code SSR causera l’erreur window is not defined. Placez ces passages dans un Client Component, ou dans un hook onMounted en Vue.

Hydration mismatch. Cette erreur apparaît quand le HTML rendu sur le serveur diffère du premier rendu du client. Les causes courantes sont new Date(), Math.random() ou une mise en forme numérique différente entre le serveur et le navigateur.

Ne pas comprendre le mécanisme de cache. Les données « qui ne se mettent pas à jour » sont généralement dues au cache du framework, pas au backend. Déclarez revalidate ou swr intentionnellement pour chaque route.

Faire du meta-framework votre backend principal. Les routes serveur sont pratiques pour un BFF. Mais si vous y mettez la logique métier, les transactions et l’accès aux bases de données, vous aurez deux backends à maintenir. Gardez la logique métier dans Spring Boot.

8. Quand ne pas les utiliser

Le défaut n’est pas une règle universelle. Envisagez une autre approche quand :

  • L’application est entièrement derrière une authentification, pas besoin de SEO. Par exemple : un tableau de bord interne, un outil d’administration. Une SPA construite avec Vite peut être plus simple : déployez en tant que fichiers statiques, sans serveur Node.js.
  • L’infrastructure ne peut pas exécuter Node.js, ou l’équipe n’est à l’aise que pour déployer des JAR. Le SSR nécessite un runtime séparé à exécuter, surveiller et scaler.
  • La page est surtout du contenu statique, peu d’interactivité. Un générateur de site statique, ou Thymeleaf rendant directement dans Spring Boot, peut suffire.
  • Application embarquée comme un widget, micro-frontend dans une page existante, ou application desktop. Le routage et le SSR du meta-framework deviennent un fardeau.
  • Petite équipe, peu familière avec le modèle server/client. Les concepts de Server Component, hydration et cache ont leur propre courbe d’apprentissage. Sans les comprendre, vous rencontrerez des bugs difficiles à corriger.

Une question simple peut vous aider à décider : y a-t-il une page qui a besoin du HTML avec du contenu dès le serveur ? Si la réponse est non, le plus grand avantage du meta-framework disparaît presque.

9. Conclusion

Les meta-frameworks comme Next.js et Nuxt emballent le routage, les modes de rendu, la récupération de données et les routes serveur. Leur rôle avec React et Vue est semblable à celui de Spring Boot avec Spring. Ils deviennent le choix par défaut car ils réduisent les décisions initiales, supportent le SEO et la vitesse d’affichage, permettent de choisir une stratégie de rendu par route, et créent des limites server/client claires.

En pratique, un modèle efficace est de laisser le meta-framework gérer la présentation et le BFF, tandis que Spring Boot conserve la logique métier et les données. Vous devez bien maîtriser la limite server/client et le mécanisme de cache. Et pour une application interne sans besoin de SEO, une SPA pure reste un choix raisonnable.

tech-trendsnextjsnuxtfrontendspring-boot