Meta-framework (Next.js, Nuxt) thành điểm khởi đầu mặc định
1. Tổng quan
Vài năm trước, một dự án frontend mới thường bắt đầu bằng React hoặc Vue thuần, rồi tự lắp thêm router, bundler, cách fetch dữ liệu và cách render phía server. Nay câu hỏi đầu tiên thường là: “Dùng Next.js hay Nuxt?”. Các meta-framework này đang trở thành điểm khởi đầu mặc định.
Bài này giải thích meta-framework là gì và vì sao nó được chọn mặc định. Bạn sẽ thấy cách dựng một trang sản phẩm bằng Next.js và Nuxt, gọi tới backend Spring Boot. Cuối bài là các lỗi hay gặp và những trường hợp không nên dùng.
2. Meta-framework là gì?
React và Vue là thư viện UI. Chúng lo việc render component và quản lý state. Chúng không quyết định URL nào ứng với trang nào, dữ liệu được tải ở đâu, hay HTML được sinh ra trên server hay trên trình duyệt.
Meta-framework là lớp nằm trên thư viện UI. Nó đưa ra sẵn các quyết định đó:
- Next.js xây trên React.
- Nuxt xây trên Vue.
- Cùng nhóm còn có SvelteKit, Remix/React Router, Astro, SolidStart…
2.1. Những gì được đóng gói sẵn
Một meta-framework điển hình cung cấp:
- File-based routing: cấu trúc thư mục quyết định URL. Bạn không cần khai báo route thủ công.
- Nhiều chế độ render: SSR (render trên server mỗi request), SSG (render sẵn lúc build), và các chế độ lai như revalidate định kỳ hoặc chỉ render phía client.
- Data fetching gắn với route: dữ liệu được tải trên server trước khi gửi HTML về.
- Server routes: viết API endpoint nhỏ ngay trong dự án frontend, thường dùng làm BFF (Backend for Frontend).
- Build và tối ưu: code splitting, tối ưu ảnh, font và asset đều có cấu hình sẵn.
Có thể so sánh với Spring Boot. Spring Framework cho bạn mọi viên gạch. Spring Boot thêm convention và auto-configuration để bạn chạy được ngay. Meta-framework đóng vai trò tương tự với React và Vue.
3. Vì sao nó thành lựa chọn mặc định?
Có vài lý do mang tính nền tảng.
Giảm quyết định ban đầu. Tự lắp router, SSR, cấu hình bundler và chia code tốn nhiều công. Đội nào cũng làm lại một kiểu khác nhau. Meta-framework cho một cấu trúc chung, nên người mới vào dự án dễ đọc code hơn.
SEO và tốc độ hiển thị. Ứng dụng SPA thuần gửi về một file HTML gần như trống, rồi chờ JavaScript tải xong mới có nội dung. Với SSR hoặc SSG, trình duyệt và bot nhận HTML có nội dung ngay. Điều này quan trọng với trang bán hàng, blog và landing page.
Render linh hoạt theo từng route. Trang chủ có thể render sẵn lúc build. Trang sản phẩm được cache và làm mới định kỳ. Trang quản trị chỉ render phía client. Tất cả nằm trong cùng một dự án.
Ranh giới server/client rõ hơn. Code chạy trên server có thể đọc biến môi trường bí mật và gọi API nội bộ mà không lộ ra trình duyệt. Bạn cũng không cần mở CORS cho backend.
Hệ sinh thái và tài liệu hướng về đây. Tài liệu chính thức của React đã gợi ý bắt đầu bằng một framework. Nhiều nền tảng hosting cũng hỗ trợ sẵn cách deploy của Next.js và Nuxt.
4. Áp dụng với Next.js
Giả sử bạn có backend Spring Boot cung cấp GET /api/products/{id}. Bạn cần trang /products/{id} hiển thị sản phẩm, có SEO tốt và không gọi API ở mỗi request.
4.1. Khởi tạo dự án
npx create-next-app@latest shop-web --typescript --app
cd shop-web
npm run dev
Lệnh trên tạo dự án dùng App Router. Thư mục app/ chứa route. Ứng dụng chạy ở http://localhost:3000.
Khai báo địa chỉ backend trong .env.local:
API_URL=http://localhost:8080
Biến này không có tiền tố NEXT_PUBLIC_. Vì vậy nó chỉ tồn tại phía server và không bị đóng gói vào JavaScript gửi cho trình duyệt.
4.2. Trang sản phẩm bằng 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("Không tải được sản phẩm");
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>
);
}
Đoạn code này làm những việc sau:
- Đường dẫn
app/products/[id]/page.tsxtự sinh route/products/:id.[id]là tham số động. - Component là
asyncvà chạy trên server. Bạnawait fetchtrực tiếp, không cầnuseEffecthay state loading. next: { revalidate: 60 }cache kết quả và làm mới tối đa mỗi 60 giây. Backend không bị gọi ở mọi lượt truy cập.notFound()trả về trang 404 chuẩn khi backend báo không có sản phẩm.
Mở http://localhost:3000/products/1 rồi xem source HTML. Bạn sẽ thấy nội dung đã có sẵn trong HTML:
<main><h1>Nón lá Huế</h1><p>150.000 ₫</p>...
4.3. Phần tương tác bằng Client Component
Nút “Thêm vào giỏ” cần xử lý sự kiện click, nên nó phải chạy trên trình duyệt:
// 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 ? "Đã thêm" : `Thêm sản phẩm #${productId} vào giỏ`}
</button>
);
}
Directive "use client" đánh dấu ranh giới. Chỉ component này và những gì nó import mới bị gửi xuống trình duyệt dưới dạng JavaScript. Phần còn lại của trang chỉ là HTML.
5. Cùng ý tưởng với Nuxt
Nuxt giải quyết cùng bài toán theo phong cách Vue. Lần này ta thêm một server route làm BFF. Trình duyệt chỉ nói chuyện với Nuxt, còn Nuxt gọi tiếp sang Spring Boot.
Khởi tạo dự án:
npm create nuxt@latest shop-nuxt
cd shop-nuxt
npm run dev
Cấu hình trong nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiUrl: "http://localhost:8080", // chỉ có ở server
},
routeRules: {
"/": { prerender: true }, // render sẵn lúc build
"/products/**": { swr: 60 }, // cache, làm mới nền mỗi 60s
"/admin/**": { ssr: false }, // chỉ render phía client
},
});
routeRules thể hiện rõ lợi ích “render theo từng route”. Ba chiến lược khác nhau nằm trong vài dòng cấu hình. Khóa apiUrl nằm ngoài public nên không lộ ra trình duyệt. Bạn có thể ghi đè giá trị này bằng biến môi trường NUXT_API_URL khi deploy.
Server route làm 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}`);
});
Tên file [id].get.ts sinh endpoint GET /api/products/:id ngay trong ứng dụng Nuxt.
Trang hiển thị sản phẩm:
<!-- 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">Không tìm thấy sản phẩm.</p>
<template v-else-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.price.toLocaleString("vi-VN") }} ₫</p>
</template>
</main>
</template>
useFetch chạy trên server ở lần tải đầu tiên. Nuxt sau đó chuyển dữ liệu xuống client, nên trình duyệt không gọi lại API khi hydrate. Lúc người dùng chuyển trang phía client, request đi tới /api/products/:id cùng origin, nên vẫn không cần CORS.
6. Phía backend Spring Boot
Cả hai frontend đều gọi cùng một 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 trả về 200 kèm body khi có sản phẩm, và trả về 404 khi Optional rỗng. Trang Next.js ở mục 4.2 dựa vào đúng mã 404 này để gọi notFound().
Lưu ý là controller không có @CrossOrigin. Mọi request đều xuất phát từ server của meta-framework, không phải từ trình duyệt. Vì vậy backend có thể nằm trong mạng nội bộ.
Hợp đồng API này là thứ cả hai frontend phụ thuộc vào, nên nên có test:
@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);
}
}
Test thứ nhất kiểm tra cấu trúc JSON mà trang frontend đọc (name, price). Test thứ hai giữ đúng hành vi 404, vì logic notFound() ở frontend phụ thuộc vào nó.
7. Lỗi hay gặp
Lộ biến bí mật xuống client. Trong Next.js, mọi biến có tiền tố NEXT_PUBLIC_ sẽ bị nhúng vào bundle. Trong Nuxt, mọi thứ trong runtimeConfig.public cũng vậy. Đừng đặt API key ở đó.
Lưu ý: Một Server Component import nhầm vào Client Component sẽ kéo code server xuống trình duyệt, hoặc gây lỗi build. Hãy tách rõ file nào chạy ở đâu. Chỉ đánh dấu
"use client"ở những component lá thật sự cần tương tác.
Dùng API trình duyệt khi đang render trên server. Gọi window hoặc localStorage trong code chạy SSR sẽ gây lỗi window is not defined. Hãy đưa những đoạn này vào Client Component, hoặc vào onMounted trong Vue.
Hydration mismatch. Lỗi này xảy ra khi HTML render trên server khác với lần render đầu ở client. Nguyên nhân thường gặp là new Date(), Math.random() hoặc định dạng số theo locale khác nhau giữa server và trình duyệt.
Không hiểu cơ chế cache. Dữ liệu “không chịu cập nhật” thường do cache của framework, không phải do backend. Hãy khai báo revalidate hoặc swr một cách có chủ đích cho từng route.
Biến meta-framework thành backend chính. Server routes rất tiện cho BFF. Nhưng nếu đặt logic nghiệp vụ, transaction và truy cập database ở đó, bạn sẽ có hai backend cần bảo trì. Hãy giữ nghiệp vụ ở Spring Boot.
8. Khi nào không nên dùng
Mặc định không có nghĩa là luôn đúng. Hãy cân nhắc phương án khác khi:
- Ứng dụng nằm hoàn toàn sau đăng nhập, không cần SEO. Ví dụ: dashboard nội bộ, công cụ quản trị. Một SPA dựng bằng Vite có thể đơn giản hơn: deploy dưới dạng file tĩnh, không cần Node.js server.
- Hạ tầng không chạy được Node.js, hoặc đội vận hành chỉ quen deploy JAR. SSR cần một runtime riêng để chạy, giám sát và scale.
- Trang chủ yếu là nội dung tĩnh, ít tương tác. Một static site generator, hoặc Thymeleaf render ngay trong Spring Boot, có thể đủ dùng.
- Ứng dụng nhúng như widget, micro-frontend trong trang có sẵn, hoặc ứng dụng desktop. Routing và SSR của meta-framework lúc này trở thành gánh nặng.
- Đội nhỏ, chưa quen mô hình server/client. Khái niệm Server Component, hydration và cache có đường cong học tập riêng. Nếu không hiểu chúng, bạn dễ gặp lỗi khó gỡ.
Một câu hỏi đơn giản giúp bạn quyết định: có trang nào cần HTML có nội dung ngay từ server không? Nếu câu trả lời là không, lợi ích lớn nhất của meta-framework gần như biến mất.
9. Kết luận
Meta-framework như Next.js và Nuxt đóng gói sẵn routing, các chế độ render, data fetching và server routes. Vai trò của chúng với React và Vue giống vai trò của Spring Boot với Spring. Chúng trở thành lựa chọn mặc định vì giảm quyết định ban đầu, hỗ trợ SEO và tốc độ hiển thị, cho phép chọn chiến lược render theo từng route, và tạo ranh giới server/client rõ ràng.
Trong thực tế, mô hình hiệu quả là để meta-framework lo phần trình bày và BFF, còn Spring Boot giữ nghiệp vụ và dữ liệu. Bạn cần nắm chắc ranh giới server/client và cơ chế cache. Và với ứng dụng nội bộ không cần SEO, một SPA thuần vẫn là lựa chọn hợp lý.