Meta-Framework (Next.js, Nuxt) als Standard-Einstiegspunkt
1. Übersicht
Vor ein paar Jahren hat ein neues Frontend-Projekt typischerweise mit reinem React oder Vue begonnen, dann hat man manuell Router, Bundler, Datenbeschaffung und Server-Side Rendering hinzugefügt. Heute ist die erste Frage meist: “Next.js oder Nuxt?”. Diese Meta-Frameworks werden zur Standardwahl.
Dieser Artikel erklärt, was ein Meta-Framework ist und warum es zur Standardwahl wird. Du wirst sehen, wie man eine Produktseite mit Next.js und Nuxt baut und ein Spring-Boot-Backend aufruft. Am Ende folgen häufige Fehler und Fälle, in denen man sie nicht verwenden sollte.
2. Was ist ein Meta-Framework?
React und Vue sind UI-Bibliotheken. Sie kümmern sich um das Rendern von Komponenten und State-Management. Sie entscheiden nicht, welche URL zu welcher Seite führt, wo Daten geladen werden, oder ob HTML vom Server oder im Browser generiert wird.
Ein Meta-Framework ist eine Ebene über der UI-Bibliothek. Es trifft diese Entscheidungen bereits für dich:
- Next.js basiert auf React.
- Nuxt basiert auf Vue.
- In der gleichen Familie gibt es auch SvelteKit, Remix/React Router, Astro, SolidStart…
2.1. Was wird bereits verpackt mitgeliefert
Ein typisches Meta-Framework bietet:
- File-based Routing: Die Verzeichnisstruktur bestimmt die URLs. Du musst Routes nicht manuell deklarieren.
- Mehrere Render-Modi: SSR (Server-seitiges Rendering bei jedem Request), SSG (Rendering beim Build), und Hybrid-Modi wie regelmäßige Revalidierung oder nur Client-seitiges Rendering.
- Datenbeschaffung gebunden an Routes: Daten werden auf dem Server vor dem Senden des HTML geladen.
- Server-Routes: Schreibe kleine API-Endpunkte direkt im Frontend-Projekt, oft als BFF (Backend for Frontend) genutzt.
- Build und Optimierung: Code-Splitting, Bildoptimierung, Font und Asset-Konfiguration sind bereits vorkonfiguriert.
Man könnte das mit Spring Boot vergleichen. Spring Framework gibt dir alle Bausteine. Spring Boot fügt Convention und Auto-Konfiguration hinzu, damit du sofort loslegen kannst. Meta-Frameworks spielen eine ähnliche Rolle für React und Vue.
3. Warum ist es zur Standardwahl geworden?
Es gibt mehrere grundlegende Gründe.
Reduzierte anfängliche Entscheidungen. Manuell Router, SSR, Bundler-Konfiguration und Code-Splitting zusammenzusetzen kostet viel Aufwand. Jedes Team macht es ein bisschen anders. Meta-Frameworks bieten eine gemeinsame Struktur, damit Neulinge Code leichter lesen können.
SEO und Anzeigegeschwindigkeit. Eine reine SPA sendet eine fast leere HTML-Datei, dann wartet man auf JavaScript zum Laden des Inhalts. Mit SSR oder SSG bekommt der Browser und Bots sofort HTML mit Inhalt. Das ist wichtig für E-Commerce-Seiten, Blogs und Landing Pages.
Flexibles Rendering pro Route. Die Startseite kann beim Build vorab gerendert werden. Produktseiten können gecacht und regelmäßig aktualisiert werden. Admin-Seiten nur Client-seitig gerendert. Alles im gleichen Projekt.
Klare Server/Client-Grenzen. Code auf dem Server kann geheime Umgebungsvariablen lesen und interne APIs aufrufen, ohne sie dem Browser auszusetzen. Du brauchst auch kein CORS für das Backend zu öffnen.
Ökosystem und Dokumentation orientieren sich dahin. Die offizielle React-Dokumentation empfiehlt inzwischen, mit einem Framework zu beginnen. Viele Hosting-Plattformen unterstützen auch schon die Deployment-Methoden von Next.js und Nuxt.
4. Anwendung mit Next.js
Angenommen, du hast ein Spring-Boot-Backend, das GET /api/products/{id} bereitstellt. Du brauchst eine Seite /products/{id}, die das Produkt mit guter SEO anzeigt und die API nicht bei jedem Request aufruft.
4.1. Projekt initialisieren
npx create-next-app@latest shop-web --typescript --app
cd shop-web
npm run dev
Dieser Befehl erstellt ein Projekt mit App Router. Das Verzeichnis app/ enthält die Routes. Die Anwendung läuft auf http://localhost:3000.
Deklariere die Backend-Adresse in .env.local:
API_URL=http://localhost:8080
Diese Variable hat kein NEXT_PUBLIC_ Präfix. Daher existiert sie nur auf der Serverseite und wird nicht in das JavaScript gebündelt, das an den Browser gesendet wird.
4.2. Produktseite mit 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("Produkt konnte nicht geladen werden");
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("de-DE")} €</p>
<AddToCartButton productId={product.id} />
</main>
);
}
Dieser Code macht folgendes:
- Der Pfad
app/products/[id]/page.tsxgeneriert automatisch die Route/products/:id.[id]ist ein dynamischer Parameter. - Die Komponente ist
asyncund läuft auf dem Server. Du kannstawait fetchdirekt verwenden, ohneuseEffectoder Loading-State. next: { revalidate: 60 }zwischenspeichert das Ergebnis und aktualisiert es maximal alle 60 Sekunden. Das Backend wird nicht bei jedem Zugriff aufgerufen.notFound()gibt eine Standard-404-Seite zurück, wenn das Backend meldet, dass das Produkt nicht existiert.
Öffne http://localhost:3000/products/1 und sieh dir den HTML-Quelltext an. Du wirst sehen, dass der Inhalt bereits im HTML vorhanden ist:
<main><h1>Nón lá Huế</h1><p>150.000 €</p>...
4.3. Interaktiver Teil mit Client Component
Der Button “Zum Warenkorb hinzufügen” muss auf Klicks reagieren, also muss er im Browser laufen:
// 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 ? "Hinzugefügt" : `Produkt #${productId} zum Warenkorb hinzufügen`}
</button>
);
}
Die Direktive "use client" markiert die Grenze. Nur diese Komponente und ihre Importe werden als JavaScript an den Browser gesendet. Der Rest der Seite ist nur HTML.
5. Die gleiche Idee mit Nuxt
Nuxt löst das gleiche Problem im Vue-Stil. Diesmal fügen wir eine Server-Route als BFF ein. Der Browser spricht nur mit Nuxt, Nuxt ruft wiederum Spring Boot auf.
Projekt initialisieren:
npm create nuxt@latest shop-nuxt
cd shop-nuxt
npm run dev
Konfiguration in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiUrl: "http://localhost:8080", // nur auf dem Server
},
routeRules: {
"/": { prerender: true }, // beim Build vorab rendern
"/products/**": { swr: 60 }, // cachen, im Hintergrund alle 60s erneuern
"/admin/**": { ssr: false }, // nur Client-seitig rendern
},
});
routeRules zeigt den Vorteil klar: “Rendering pro Route”. Drei verschiedene Strategien in wenigen Zeilen Konfiguration. Der apiUrl-Schlüssel liegt außerhalb von public, also bleibt er dem Browser verborgen. Du kannst diesen Wert beim Deployment mit der Umgebungsvariable NUXT_API_URL überschreiben.
Server-Route als 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}`);
});
Der Dateiname [id].get.ts generiert den Endpunkt GET /api/products/:id direkt in der Nuxt-Anwendung.
Produktseite:
<!-- 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">Produkt nicht gefunden.</p>
<template v-else-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.price.toLocaleString("de-DE") }} €</p>
</template>
</main>
</template>
useFetch läuft auf dem Server bei der ersten Ladung. Nuxt sendet dann die Daten an den Client, sodass der Browser die API beim Hydration nicht erneut aufruft. Wenn der Benutzer Client-seitig die Seite wechselt, geht die Anfrage an /api/products/:id mit gleicher Herkunft, also immer noch kein CORS nötig.
6. Die Spring-Boot-Seite
Beide Frontends rufen die gleiche API auf:
// 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 gibt 200 mit Body zurück, wenn ein Produkt existiert, und 404 wenn Optional leer ist. Die Next.js-Seite aus Abschnitt 4.2 nutzt genau diesen 404-Code, um notFound() aufzurufen.
Beachte, dass der Controller kein @CrossOrigin hat. Alle Anfragen stammen vom Server des Meta-Frameworks, nicht vom Browser. Daher kann das Backend in einem internen Netzwerk sein.
Diese API ist das, wovon beide Frontends abhängen, daher sollte sie Tests haben:
@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);
}
}
Der erste Test prüft die JSON-Struktur, die das Frontend liest (name, price). Der zweite Test sichert das 404-Verhalten, da die notFound()-Logik im Frontend davon abhängt.
7. Häufige Fehler
Geheimnisse zum Client weitergeben. In Next.js werden alle Variablen mit NEXT_PUBLIC_ Präfix in das Bundle eingebettet. In Nuxt auch alles in runtimeConfig.public. Lege dort keinen API-Key ab.
Hinweis: Wenn eine Server Component versehentlich eine Client Component importiert, wird Server-Code zum Browser gebracht, oder es gibt einen Build-Fehler. Trenne klar, welche Datei wo läuft. Markiere nur mit
"use client"bei echten Blatt-Komponenten, die Interaktion brauchen.
Browser-APIs beim Server-Rendering verwenden. Wenn man window oder localStorage in SSR-Code aufruft, gibt es einen Fehler window is not defined. Lagere solche Teile in Client Components aus oder in onMounted in Vue.
Hydration Mismatch. Das tritt auf, wenn sich das Server-gerenderte HTML von der ersten Client-Renderung unterscheidet. Häufige Ursachen sind new Date(), Math.random() oder unterschiedliche Zahlenformatierung zwischen Server und Browser.
Cache-Mechanismus nicht verstanden. “Daten aktualisieren sich nicht” liegt oft an Framework-Cache, nicht am Backend. Deklariere absichtsvoll revalidate oder swr für jede Route.
Meta-Framework als Haupt-Backend missbrauchen. Server Routes sind praktisch für BFFs. Aber wenn du Geschäftslogik, Transaktionen und Datenbankzugriff dort legst, hast du zwei Backends zu warten. Behalte die Geschäftslogik in Spring Boot.
8. Wann man es nicht verwenden sollte
Standardmäßig bedeutet nicht immer richtig. Erwäge Alternativen, wenn:
- Die Anwendung ganz hinter Login sitzt, keine SEO braucht. Beispiel: internes Dashboard, Admin-Tool. Ein SPA mit Vite könnte einfacher sein: als statische Dateien deployen, kein Node.js-Server nötig.
- Die Infrastruktur Node.js nicht laufen lassen kann, oder dein Ops-Team kennt nur JAR-Deployment. SSR braucht eine eigene Runtime, Überwachung und Skalierung.
- Die Seite hauptsächlich statischer Inhalt ist, wenig Interaktion. Ein Static Site Generator oder Thymeleaf direkt in Spring Boot kann genug sein.
- Embedded Apps, Micro-Frontends in bestehenden Seiten oder Desktop-Anwendungen. Routing und SSR eines Meta-Frameworks werden hier zum Ballast.
- Kleines Team, nicht mit Server/Client-Modell vertraut. Server Components, Hydration und Cache haben ihre Lernkurve. Ohne Verständnis läufst du leicht in schwer zu findende Fehler.
Eine einfache Frage hilft bei der Entscheidung: Gibt es Seiten, die HTML mit Inhalt gleich vom Server brauchen? Wenn nein, fällt ein großer Vorteil des Meta-Frameworks weg.
9. Fazit
Meta-Frameworks wie Next.js und Nuxt verpacken Routing, Render-Modi, Datenbeschaffung und Server Routes vor. Ihre Rolle zu React und Vue ist ähnlich wie Spring Boots zu Spring. Sie werden zur Standardwahl, weil sie anfängliche Entscheidungen reduzieren, SEO und Anzeige-Performance unterstützen, Route-spezifische Render-Strategien erlauben und klare Server/Client-Grenzen schaffen.
In der Praxis ist ein wirksames Muster, das Meta-Framework für Präsentation und BFF zuständig zu lassen, Spring Boot für Geschäftslogik und Daten. Du musst die Server/Client-Grenze und Cache-Mechanismen verstehen. Und für interne Anwendungen ohne SEO-Bedarf ist eine reine SPA immer noch eine vernünftige Wahl.