Meta-frameworks (Next.js, Nuxt) as the New Default

1. Overview

A few years ago, a new frontend project usually started with plain React or Vue, and you’d manually add a router, bundler, data fetching strategy, and server rendering approach. Today, the first question is often: “Should we use Next.js or Nuxt?” These meta-frameworks are becoming the default starting point.

This article explains what a meta-framework is and why it’s chosen by default. You’ll see how to build a product page using both Next.js and Nuxt, calling a Spring Boot backend. By the end, we’ll look at common pitfalls and cases where you shouldn’t use them.

2. What is a meta-framework?

React and Vue are UI libraries. They handle rendering components and managing state. They don’t decide which URLs map to which pages, where data gets loaded, or whether HTML is generated on the server or in the browser.

A meta-framework is a layer on top of the UI library. It makes those decisions for you:

  • Next.js is built on React.
  • Nuxt is built on Vue.
  • The category also includes SvelteKit, Remix/React Router, Astro, SolidStart, and others.

2.1. What comes bundled

A typical meta-framework provides:

  • File-based routing: folder structure determines URLs. You don’t declare routes manually.
  • Multiple rendering modes: SSR (render on server per request), SSG (pre-render at build time), and hybrid modes like periodic revalidation or client-only rendering.
  • Data fetching tied to routes: data loads on the server before sending HTML to the browser.
  • Server routes: write small API endpoints right in your frontend project, often used as a BFF (Backend for Frontend).
  • Build and optimization: code splitting, image optimization, font loading, and asset handling all come pre-configured.

Think of it like Spring Boot versus Spring Framework. Spring Framework gives you every brick. Spring Boot adds conventions and auto-configuration so you can run immediately. Meta-frameworks play the same role for React and Vue.

3. Why it became the default choice?

There are a few fundamental reasons.

Fewer decisions upfront. Manually adding routing, SSR, configuring bundlers, and code splitting takes effort. Every team does it slightly differently. A meta-framework provides a common structure, making it easier for newcomers to understand the codebase.

SEO and initial rendering speed. A pure SPA sends back an almost empty HTML file, then waits for JavaScript to load before showing content. With SSR or SSG, browsers and search bots get HTML with content immediately. This matters for e-commerce sites, blogs, and landing pages.

Flexible rendering per route. The homepage might be pre-rendered at build time. Product pages could be cached and revalidated periodically. Admin pages might render only client-side. All in the same project.

Clearer server/client boundaries. Code running on the server can read secret environment variables and call internal APIs without exposing them to the browser. You also don’t need to enable CORS on your backend.

Ecosystem and documentation point this way. React’s official documentation now suggests starting with a framework. Many hosting platforms also have built-in support for deploying Next.js and Nuxt applications.

4. Implementing with Next.js

Say your Spring Boot backend provides GET /api/products/{id}. You need a /products/{id} page that displays the product, has good SEO, and doesn’t call the API on every request.

4.1. Initialize the project

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

This creates a project using the App Router. The app/ folder contains routes. The app runs at http://localhost:3000.

Declare the backend address in .env.local:

API_URL=http://localhost:8080

This variable has no NEXT_PUBLIC_ prefix. So it only exists on the server and doesn’t get bundled into the JavaScript sent to browsers.

4.2. Product page as a 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("Failed to fetch product");
  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("en-US")} ₫</p>
      <AddToCartButton productId={product.id} />
    </main>
  );
}

This code does several things:

  • The path app/products/[id]/page.tsx automatically creates the route /products/:id. [id] is a dynamic parameter.
  • The component is async and runs on the server. You await fetch directly, no need for useEffect or loading state.
  • next: { revalidate: 60 } caches the result and revalidates at most every 60 seconds. The backend isn’t called on every visit.
  • notFound() returns a standard 404 page when the backend reports the product doesn’t exist.

Open http://localhost:3000/products/1 and view the page source. You’ll see the content is already in the HTML:

<main><h1>Conical Hat Hue</h1><p>150,000 ₫</p>...

4.3. Interactive parts with Client Components

The “Add to Cart” button needs to handle clicks, so it must run in the browser:

// 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 ? "Added" : `Add product #${productId} to cart`}
    </button>
  );
}

The "use client" directive marks the boundary. Only this component and what it imports get sent to the browser as JavaScript. The rest of the page is just HTML.

5. The same concept with Nuxt

Nuxt solves the same problem in Vue style. This time we’ll add a server route to act as a BFF. The browser only talks to Nuxt, which then calls Spring Boot.

Initialize the project:

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

Configure in nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    apiUrl: "http://localhost:8080", // server-only
  },
  routeRules: {
    "/": { prerender: true },          // pre-render at build time
    "/products/**": { swr: 60 },       // cache, revalidate in background every 60s
    "/admin/**": { ssr: false },       // render only on client
  },
});

routeRules shows the power of “render per route”. Three different strategies fit in a few lines. The apiUrl key sits outside public, so it won’t leak to browsers. You can override it with the NUXT_API_URL environment variable when deploying.

Server route as a 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}`);
});

The filename [id].get.ts creates the endpoint GET /api/products/:id right in the Nuxt app.

Product display page:

<!-- 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">Product not found.</p>
    <template v-else-if="product">
      <h1>{{ product.name }}</h1>
      <p>{{ product.price.toLocaleString("en-US") }} ₫</p>
    </template>
  </main>
</template>

useFetch runs on the server on the first load. Nuxt then streams the data to the client, so the browser doesn’t call the API during hydration. When the user navigates client-side, the request goes to /api/products/:id on the same origin, so still no CORS needed.

6. The Spring Boot backend side

Both frontends call the same 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, "Conical Hat Hue", 150_000),
            2L, new Product(2, "Silk Áo Dài", 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 returns 200 with a body when a product exists, and 404 when the Optional is empty. The Next.js page in section 4.2 relies on exactly this 404 status to call notFound().

Note the controller has no @CrossOrigin. Every request originates from the meta-framework’s server, not from a browser. So the backend can sit on an internal network.

This API contract is what both frontends depend on, so it should be tested:

@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\":\"Conical Hat Hue\"")
                .contains("\"price\":150000");
    }

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

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

The first test verifies the JSON structure that the frontend reads (name, price). The second ensures the 404 behavior holds, because the frontend’s notFound() logic depends on it.

7. Common mistakes

Leaking secrets to the client. In Next.js, any variable prefixed with NEXT_PUBLIC_ gets bundled into the bundle. In Nuxt, anything in runtimeConfig.public does too. Don’t put API keys there.

Tip: If a Server Component accidentally imports a Client Component, it can drag server code down to the browser or cause a build error. Keep a clear separation of which files run where. Only mark files with "use client" when they truly need interactivity.

Using browser APIs while rendering on the server. Calling window or localStorage in SSR code will throw window is not defined. Move those into Client Components or into onMounted in Vue.

Hydration mismatch. This happens when server-rendered HTML differs from the first client render. Common culprits are new Date(), Math.random(), or number formatting with different locales between server and browser.

Not understanding cache mechanics. Data that “won’t update” usually comes from framework caching, not the backend. Declare revalidate or swr intentionally for each route.

Turning the meta-framework into your main backend. Server routes are handy for a BFF. But if you put business logic, transactions, and database access there, you’ll have two backends to maintain. Keep business logic in Spring Boot.

8. When not to use

Default doesn’t mean always right. Consider alternatives when:

  • The app sits entirely behind login and needs no SEO. Examples: internal dashboards, admin tools. A plain SPA built with Vite might be simpler: deploy as static files, no Node.js server needed.
  • Your infrastructure can’t run Node.js, or your ops team only knows how to deploy JARs. SSR needs its own runtime to run, monitor, and scale.
  • The site is mostly static content with little interactivity. A static site generator, or Thymeleaf rendering in Spring Boot itself, might be enough.
  • Embedded apps like widgets, micro-frontends in existing pages, or desktop apps. The routing and SSR of a meta-framework become baggage.
  • Small team unfamiliar with the server/client model. Server Components, hydration, and caching have their own learning curve. Without understanding them, you’ll hit hard-to-debug errors.

A simple question helps you decide: Is there any page that needs HTML with content from the server immediately? If not, the biggest benefit of a meta-framework nearly disappears.

9. Conclusion

Meta-frameworks like Next.js and Nuxt bundle routing, rendering modes, data fetching, and server routes. They play the same role for React and Vue that Spring Boot plays for Spring. They’ve become the default because they reduce initial decisions, support SEO and fast rendering, allow per-route rendering strategies, and create clear server/client boundaries.

In practice, an effective model is to let the meta-framework handle presentation and the BFF layer, while Spring Boot keeps the business logic and data. You need to understand the server/client boundary and caching mechanics. And for internal apps with no SEO needs, a plain SPA is still a reasonable choice.

tech-trendsnextjsnuxtfrontendspring-boot