Bài này nói về Nuxt 3.x / 4 mức senior cho phỏng vấn: hybrid rendering, data payload, Nitro như BFF, và hydration. Dành cho engineer đã ship Vue, giờ cần một policy dưới ràng buộc SEO, auth, TTFB, và chi phí server.
Đọc 6.1–6.3 trước (Nuxt thêm gì, khi nào ở lại Vue SPA, bốn mode). Người phỏng vấn xoáy routeRules, key payload, và $fetch-in-setup nhiều hơn cú pháp file-router. Rồi 6.4–6.8 về middleware, data fetching, env, Nitro, và ClientOnly. Nêu rõ ràng buộc, lỗi bạn đã gặp, và cách chứng minh cái fix.
Tóm tắt: Nuxt là Vue kèm file routing, một policy render, data fetching first-class với payload serialize được, auto-import, và Nitro như server deploy được. Nó không thay thế Vue.
Vì sao quan trọng: “Vì sao Nuxt thay vì Vue + Vite + vue-router?” Họ muốn convention đã chốt sẵn quyết định production (mode render, data, env, server), không phải danh sách tính năng.
- Cách hoạt động:
pages/index.vuelà một route.server/apilà HTTP trên cùng origin.useAsyncDataghi nhận một payload để client không refetch lúc hydrate. - Hybrid rendering (
routeRules) chọn SSR, SSG, SWR, hoặc CSR theo từng path. Module (Pinia, i18n) cài sẵn mặc định production, không thì bạn phải viết tay. - Auto-import gồm component, composable, và utility. Đụng tên là chuyện thường; với library thì import tường minh thắng.
ssr: falsetoàn cục là Vue SPA với framework thừa. - Ở lại Vue SPA khi product là một shell đã auth nằm sau CDN, không có phần SEO, và có API tách riêng.
pages/index.vue → /
pages/users/[id].vue → /users/:id
server/api/me.get.ts → GET /api/me
nuxt.config.ts → routeRules, runtimeConfig
// request đầu: Nitro render HTML + payload
// client hydrate từ payload, không gọi /api/me lần hai
// auto-import: components/ và composables/ không cần import tường minh
// ssr: false toàn cục sẽ bỏ hết và còn lại Vite SPAĐiều gì xảy ra: Cây file vừa là router vừa là BFF. HTML đầu đã gồm payload của user. Client hydrate thay vì fetch lại. Câu chuyện payload đó là thứ Vite SPA không cho bạn sẵn.
| Tầng | Nuxt thêm | Vue SPA đã có |
|---|---|---|
| UI | Vue 3 | Vue 3 |
| Router | File routing pages/ | vue-router bạn tự setup |
| HTML | SSR / SSG / hybrid | Shell index.html |
| Server | Nitro + server/api | API tách riêng |
Góc nhìn senior
- Chọn Nuxt cho HTML hybrid, SEO, hoặc BFF. Ở lại SPA khi không có phần SEO.
- Giá trị nằm ở payload, routeRules, và preset deploy, không phải auto-import như trò khoe.
ssr: falsetoàn cục là Vue SPA với framework thừa. Phải giải thích được vì sao mới dùng.- Module cài sẵn mặc định cookie và env. Đọc thứ chúng đưa vào browser trước khi bật.
- Ở sản phẩm admin, catalog public là Nuxt; grid đã auth có thể ở lại Vite SPA.
Lỗi hay gặp
- Triệu chứng: app Nuxt không bao giờ SSR. Nguyên nhân:
ssr: falsetoàn cục. Cách xử lý:routeRuleshybrid, hoặc cứ dùng Vite.- Triệu chứng: hai
/api/melúc load đầu. Nguyên nhân:$fetchtrong setup. Cách xử lý:useAsyncDatacó key.- Triệu chứng: đụng auto-import. Nguyên nhân: hai file
useUser. Cách xử lý: import tường minh.- Triệu chứng: SEO vẫn trống. Nguyên nhân: fetch chỉ client. Cách xử lý: await data trong setup.
Nhà tuyển dụng hay hỏi gì
“Vì sao Nuxt thay vì Vue + Vite + vue-router?” Họ muốn convention đã chốt sẵn quyết định production (mode render, data, env, server), không phải danh sách tính năng.
Trả lời khi phỏng vấn
Nuxt không thay thế Vue. Nó là Vue kèm routing, một policy render, và Nitro. Mình chọn khi cần HTML hybrid, SEO, hoặc BFF. Mình ở lại Vue SPA khi product là shell auth với API tách riêng. ssr false toàn cục là Vue SPA với framework thừa: mình phải giải thích được vì sao mới dùng. Payload mới là trọng tâm: useAsyncData ghi data HTML để client không refetch. Ở sản phẩm admin, catalog là Nuxt vì SEO; grid có thể ở lại Vite.
Nhớ nhanh
- Nuxt = Vue + routing + policy render + Nitro.
- Dùng nó cho hybrid, SEO, hoặc BFF. Ở lại SPA khi không có phần SEO.
ssr: falsetoàn cục là SPA với framework thừa.- Payload là lý do data trong setup không refetch lúc hydrate.
Câu hỏi nối tiếp thường gặp
- Nuxt chậm hơn Vite? SPA thì tương tự. Bạn trả CPU SSR và hydration. Đo TTFB theo từng route.
- Vẫn dùng Vue Router được không? File routing nằm trên Vue Router. Shell query-driven dị sẽ đánh nhau với
pages/. - Cần Nitro nếu đã có API? Cần cho HTML SSR. BFF là tùy chọn; API tách vẫn ổn.
- Auto-import trong design system? Ưu tiên import tường minh để đụng tên và tree-shaking còn rõ.
Tóm tắt: Vue phụ trách UI và reactivity. Nuxt phụ trách lúc HTML được tạo, cách fetch HTML đó, cách tách env, và cách server chạy.
Vì sao quan trọng: Bảng so sánh là câu junior. Senior map tầng nào chịu trách nhiệm vấn đề nào, nhất là so với Vue SPA công ty đã có.
- Cách hoạt động: Nếu đã có Vue 3 SPA, thêm Nuxt cho route public và HTML hybrid, không phải để viết lại design system. Guard Vue Router vẫn còn; middleware Nuxt là bản hiểu SSR.
- Routing Nuxt theo file. Shell query-driven kiểu all-in-one sẽ đánh nhau với
pages/vàrouteRules.useFetchkhông tồn tại trong app Vite: không có payload. - SSR tốn CPU origin, hydration, và ranh giới môi trường (
window, cookie kháclocalStorage). Vue SPA tĩnh trên object storage rẻ hơn khi không có SEO. - Pinia trên Nuxt không phải Pinia SPA: store phải theo từng request và hydrate được từ payload. Store module-scope rò sang user khác.
// Vue SPA (Vite): không payload
onMounted(async () => { user.value = await getMe() }) // HTML đầu trống
// Nuxt: hiểu payload
const { data: user } = await useAsyncData('me', () => $fetch('/api/me'))
// HTML chứa user; client hydrate; một GET
// thêm Nuxt vào design system sẵn: giữ SFC, thêm pages/ + routeRules
// useFetch không tồn tại trong Vite: không có runtime payloadĐiều gì xảy ra: Vite SPA vẽ một shell rồi fetch sau mount. HTML đầu của Nuxt đã có user. Đó là tầng Nuxt phụ trách. Component button không cần đổi.
| Tầng | Vue SPA | Nuxt |
|---|---|---|
| UI / reactivity | Vue 3 | Vue 3 |
| HTML đầu | Shell trống | SSR / SSG / hybrid |
| Data cho HTML | Bạn tự dựng | Payload + keys |
| Server | Tách | Nitro, cùng origin |
Góc nhìn senior
- Thêm Nuxt cho public + hybrid, đừng viết lại design system.
useFetchvà Nuxt Pinia không port sang Vite SPA. Đừng copy qua.- Vue SPA + BFF tách: ranh giới process rõ, hai deploy, payload thì bạn tự dựng sau.
- Nuxt monolith: một deploy, BFF in-process, CPU dùng chung với SSR.
- Ở sản phẩm admin, design system vẫn là SFC Vue; chỉ thêm
pages/cho catalog public.
Lỗi hay gặp
- Triệu chứng: copy
useFetchvào Vite. Nguyên nhân: không có runtime payload. Cách xử lý:onMounted+ cache tự viết, hoặc cứ dùng Nuxt.- Triệu chứng: viết lại từng button sang Nuxt. Nguyên nhân: coi Nuxt như UI library. Cách xử lý: giữ design system.
- Triệu chứng: SSR rò user. Nguyên nhân: thói quen Pinia kiểu SPA (module singleton). Cách xử lý: Pinia per-app.
- Triệu chứng: file routing không diễn tả được URL. Nguyên nhân: shell toàn query. Cách xử lý: ở lại Vue Router.
Nhà tuyển dụng hay hỏi gì
Bảng so sánh là câu junior. Senior map tầng nào phụ trách vấn đề nào, nhất là so với Vue SPA công ty đã có.
Trả lời khi phỏng vấn
Vue phụ trách UI và reactivity. Nuxt phụ trách lúc HTML được tạo, cách fetch data cho HTML đó, cách tách env, và cách server chạy. Nếu đã có Vue 3 SPA, mình thêm Nuxt cho route public và hybrid rendering, không viết lại design system. useFetch không tồn tại trong app Vite, và Nuxt Pinia không phải Pinia SPA vì payload. SSR tốn CPU và hydration; SPA tĩnh rẻ hơn khi không có SEO. Ở sản phẩm admin, design system vẫn là SFC Vue; chỉ catalog được thêm pages và routeRules.
Nhớ nhanh
- Vue = UI. Nuxt = lúc HTML ra, data, env, server.
- Thêm Nuxt cho public + hybrid, không viết lại design system.
useFetchvà Nuxt Pinia không port sang Vite SPA.- SSR tốn CPU và hydration; SPA rẻ hơn khi không có SEO.
Câu hỏi nối tiếp thường gặp
- Giữ guard Vue Router? Có, cho UX in-app. Route middleware của Nuxt mới là cổng hiểu SSR.
- Migrate dần? Bọc route public trước. Grid đã auth cứ để Vite SPA nếu không có SEO.
- Vì sao Pinia khác? Một store mỗi request, hydrate từ payload, không module singleton.
- Chi phí SSR? CPU origin, JS hydration, cookie khác localStorage, class bug mismatch.
Tóm tắt: Chọn HTML đúng, rẻ nhất theo từng route. routeRules là control plane; key payload là identity của cache. SPA là CSR cả app.
Vì sao quan trọng: Họ để bạn định nghĩa bốn thuật ngữ, rồi hỏi: “Trộn chúng trong một app thế nào?” Đó là routeRules, payload, island, và khi nào không SSR.
- Cách hoạt động: CSR / SPA: browser nhận shell, rồi JS render. SSR: server gửi HTML, rồi JS hydrate. SSG / prerender: HTML được build lúc compile. SWR / kiểu ISR: trả HTML đã cache, refresh ở background.
- Public, cache được, nhạy SEO → SSG hoặc SWR. Cá nhân hóa nhưng crawl được → SSR với key cache không gồm secret. Chrome đã auth và dashboard nặng chart →
ssr: false. - Một process có ngân sách CPU. SSR dashboard 50-series mỗi lần navigation thì TTFB nát. SSG không thấy cookie theo user lúc build.
routeRuleskhông thay keyuseAsyncData. Trang SWR keyed theo cookie sẽ trả HTML của user A cho user B.
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/blog/**': { swr: 3600 },
'/product/**': { swr: 60 },
'/search': { ssr: true, cache: false },
'/account/**': { ssr: false },
'/editor/**': { ssr: false },
},
})
// browser nhận: HTML tĩnh ở / ; HTML+payload ở /search ;
// shell JS ở /account. Key payload vẫn không được trùng.Điều gì xảy ra: / là byte từ CDN. /search là HTML origin, tắt cache. /account là island SPA. Chart và dashboard auth không SSR. Cỡ payload là ngân sách TBT: pick / transform dùng để thu nhỏ.
| Mode | Browser nhận trước | Khi nào |
|---|---|---|
| SSG | HTML tĩnh | Public, ít đổi |
| SWR / ISR | HTML cache, rồi refresh | Public, hơi stale được |
| SSR | HTML mới + payload | Cá nhân hóa, crawl được |
| CSR / SPA | Shell JS | Auth / canvas / nội bộ |
Góc nhìn senior
- HTML đúng, rẻ nhất theo từng route. Đừng SSR chart, canvas, hay flag chỉ có trên window.
- Key payload là identity của cache. Key trùng thì trộn trang; blob search 2 MB là một incident.
routeRuleslà control plane. Key trênuseAsyncDatavẫn quan trọng.- Đo TTFB, byte HTML, cache hit ratio, cảnh báo hydration, CPU origin so với RPS.
- Ở sản phẩm admin, / được prerender, /blog là SWR, /app là SSR không cache, /app/grid là CSR.
Lỗi hay gặp
- Triệu chứng: user B thấy user A. Nguyên nhân: HTML SWR keyed theo cookie. Cách xử lý:
cache: falsetrên path private.- Triệu chứng: TTFB nát. Nguyên nhân: SSR dashboard chart. Cách xử lý:
ssr: falseroute đó.- Triệu chứng: giá stale cả ngày. Nguyên nhân: SSG không revalidate. Cách xử lý: SWR / ISR với TTL.
- Triệu chứng: payload phình to. Nguyên nhân: đổ cả list search. Cách xử lý:
pick/transform.
Nhà tuyển dụng hay hỏi gì
Họ để bạn định nghĩa bốn thuật ngữ, rồi hỏi: “Trộn chúng trong một app thế nào?” Đó là routeRules, payload, island, và khi nào không SSR.
Trả lời khi phỏng vấn
Mình chọn HTML đúng, rẻ nhất theo từng route và viết policy đó trong routeRules. Catalog public là SWR; search là SSR tắt cache; account và editor là ssr false. Key payload là identity của cache: key trùng thì trộn trang; blob search 2 MB là một incident. Mình không SSR chart hay dashboard auth. routeRules không thay key useAsyncData; trang SWR keyed theo cookie sẽ trả HTML user A cho user B. Ở sản phẩm admin, home được prerender và grid là CSR.
Nhớ nhanh
routeRuleslà control plane hybrid. Key vẫn quan trọng.- Đừng SSR dashboard auth, canvas, hay flag chỉ có trên
window. - Cỡ payload là ngân sách TBT.
pick/transformthu nhỏ nó. - SPA là CSR cả app; hybrid là dạng production thường gặp.
Câu hỏi nối tiếp thường gặp
- SWR khác ISR? Cả hai trả HTML đã cache rồi regenerate. Kiểu ISR trong Nuxt là rule
isr/ SWR trên path. - Vì sao không mặc định SSR? CPU và hydration. Tool nội bộ hiếm khi cần HTML đầu.
- Payload như identity cache? Cùng key, cùng byte. Cookie trong key làm HTML private, đừng đưa lên CDN.
- Island? Giữ HTML, hoãn JS (lazy hydration). Không giống ClientOnly, vốn bỏ HTML.
Tóm tắt: Route middleware quyết định HTML nào sẽ được thử. Server đọc session mới quyết data nào tồn tại. Middleware là cổng UX, không phải authorization.
Vì sao quan trọng: Redirect auth. Senior bị chấm ở chỗ chạy SSR khác client, cookie khác localStorage, và đúng câu đó: middleware là UX, không phải auth.
- Cách hoạt động:
defineNuxtRouteMiddlewarechạy trên server ở request đầu và trên client ở navigation sau. Nó phải idempotent. Hydrate auth một lần từ cookie httpOnly. - Nitro
server/middleware/server/apimới là ranh giới thật: nó thấy request thô. Route middleware không bảo vệ đượcserver/api. localStoragevô hình trên SSR, nên không phải source of truth cho auth. Loại/loginkhỏi auth global, không thì loop.- Middleware named theo trang dễ quên trên route admin mới: hãy lint
definePageMeta. Global thì dễ bọc luôn/login. Giữ global nhỏ.
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuthStore()
if (!auth.ready) await auth.hydrate() // session cookie, một lần
if (to.path === '/login' && auth.isLoggedIn) return '/'
if (to.meta.auth && !auth.isLoggedIn) {
return '/login?redirect=' + to.fullPath
}
})
// server/api vẫn check session: file này là UX, không phải authorizationĐiều gì xảy ra: HTML đầu của /app đã biết cookie. Navigation client sau dùng lại auth.ready nên /me không bị gọi hai lần. /login được loại. server/api vẫn 401 client giả mạo.
| Tầng | Việc | Không phải việc |
|---|---|---|
| Route middleware | Redirect, chọn layout, trước render | Authorization của server/api |
| Middleware server Nitro | Request thô, cookie, 401 | Redirect UX đẹp |
Token localStorage | Extra chỉ trên client | Session SSR |
Góc nhìn senior
- Hydrate từ cookie httpOnly, một lần. Gọi /me mỗi lần navigation thì nháy UI và dính rate-limit.
- Cùng một file, hai runtime: giữ idempotent. Không
windowở top. - Middleware route không bảo vệ được
server/api. Auth trên handler. - Auth global dễ loop trên /login. Named thì dễ quên. Lint page meta.
- Ở sản phẩm admin, một middleware auth đợi auth.ready; Nitro vẫn kiểm session.
Lỗi hay gặp
- Triệu chứng: vòng redirect
/login→/login. Nguyên nhân: middleware global bọc login. Cách xử lý: loại nó ra.- Triệu chứng: HTML đầu là shell login dù cookie đã đăng nhập. Nguyên nhân: session
localStorage. Cách xử lý: cookie httpOnly.- Triệu chứng:
/memỗi click. Nguyên nhân: hydrate không cóready. Cách xử lý: một flag.- Triệu chứng:
fetch("/api/admin")giả mạo vẫn thành công. Nguyên nhân: chỉ middleware của page. Cách xử lý: auth Nitro.
Nhà tuyển dụng hay hỏi gì
Redirect auth. Senior được chấm ở chỗ SSR khác client, cookie khác localStorage, và ranh giới này: middleware là cổng UX, không phải authorization.
Trả lời khi phỏng vấn
Middleware quyết định HTML nào sẽ thử; server quyết định data nào tồn tại. Mình hydrate auth từ cookie httpOnly, một lần. Cùng file chạy trên server ở hit đầu và trên browser ở nav sau: phải idempotent. localStorage không thấy được lúc SSR, nên không phải nguồn auth. Middleware route không bảo vệ được server/api; middleware server Nitro thì được. Ở sản phẩm admin, một middleware đợi auth.ready và API vẫn kiểm session.
Nhớ nhanh
- Middleware là cổng UX. Authorization nằm ở Nitro/API.
- Cookie httpOnly mới là session.
localStoragekhông thấy được lúc SSR. - Cùng một file, hai runtime: giữ idempotent. Giữ global cho nhỏ.
- Hydrate auth một lần để
/mekhông bị gọi hai lần.
Câu hỏi nối tiếp thường gặp
- server/middleware khác middleware route? Request thô khác UX routing Vue. Cả hai đều chạy được; chỉ Nitro thấy API.
- Lint auth bị quên thế nào? Test rằng mọi file
pages/adminđều códefinePageMeta({ auth: true }). - Feature-flag một route? Middleware có thể 404, nhưng chunk vẫn có thể ship. Không phải security.
- Map sang Vue Router?
beforeEachlà anh em SPA. Cùng rule hydrate một lần.
Tóm tắt: $fetch là imperative và không có payload. useAsyncData / useFetch nắm key payload nên client không refetch lúc hydrate. $fetch trong setup là bug fetch hai lần.
Vì sao quan trọng: “Dùng cái nào?” Họ muốn nghe dedupe, key, refresh, client khác server, và vì sao $fetch trong setup là bẫy trên SSR.
- Cách hoạt động: Trên SSR, composable có key chạy trên server, kết quả vào payload, client không được refetch trừ khi
refreshhoặc key đổi. Key trùng thì share state. $fetchcho event handler, handler Nitro, plugin: không payload.useFetch(url)khi URL là key.useAsyncData(key, handler)khi bạn nắm key, gọi nhiều lần, hoặc việc không phải HTTP.- Sau mutation thì gọi
refreshNuxtData(key). Một POST không thuộcsetup.watchkey khi param của route đổi.lazy: truekhông chặn navigation. - Đọc
errorvàpending. Chúng không phải error boundary Vue.transform/pickthu nhỏ payload. Key không ổn định (Date.now()) không bao giờ hit.
const user = await $fetch('/api/me') // ❌ server + client, không payload
const { data } = await useFetch('/api/users/' + id) // URL là key
const { data: product, refresh, error } = await useAsyncData(
() => 'product:' + route.params.id,
() => $fetch('/api/products/' + route.params.id),
{ watch: [() => route.params.id], pick: ['id', 'title', 'price'] },
)
// cùng key parent và child = một GET; POST thuộc handler clickĐiều gì xảy ra: $fetch thô trong setup không được ghi vào payload, nên waterfall hiện hai GET và response lần hai có thể lệch. useAsyncData với key ổn định thì hydrate. pick giữ payload nhỏ. Handler click có thể $fetch tự do.
| API | Payload | Dùng |
|---|---|---|
$fetch | Không | Click, Nitro, plugin |
useFetch(url) | Có, URL là key | HTTP đơn giản trong setup |
useAsyncData | Có, bạn đặt tên | Key tự đặt, gọi nhiều lần, không HTTP |
Góc nhìn senior
- Key là identity của cache. Key đụng nhau thì share nhầm product; key unique-per-instance phá dedupe.
- Key cá nhân hóa phải gồm user id, không thì payload user A bị gửi cho user B.
- Mutation là event, không phải setup. Refresh key sau POST.
- Chặn quá nhiều data thì hại TTFB. Lazy widget dưới fold.
- Ở sản phẩm admin, route user là useAsyncData với key user kèm id; team mình bắt được $fetch trong setup trên waterfall.
Lỗi hay gặp
- Triệu chứng: hai GET lúc load đầu. Nguyên nhân:
$fetchtrong setup. Cách xử lý: composable có key.- Triệu chứng: waterfall parent và child. Nguyên nhân: key khác cho cùng resource. Cách xử lý: một key chung.
- Triệu chứng: UI stale sau khi lưu. Nguyên nhân: không
refreshNuxtData. Cách xử lý: refresh key.- Triệu chứng: lệch sau hydrate. Nguyên nhân: fetch client lần hai trả JSON khác. Cách xử lý: dùng payload, đừng fetch lần hai.
Nhà tuyển dụng hay hỏi gì
“Dùng cái nào?” Họ muốn nghe dedupe, key, refresh, client khác server, và vì sao $fetch trong setup là bẫy trên SSR.
Trả lời khi phỏng vấn
fetch trong setup chạy trên server rồi chạy lại trên client: không payload, không dedupe. Mình dùng useAsyncData khi mình nắm key, useFetch khi URL là key. Cùng key ở parent và child là một request: tránh waterfall, hoặc share nhầm product. Sau mutation mình gọi refreshNuxtData. POST không thuộc setup. Transform và pick thu nhỏ payload. Ở sản phẩm admin, route user là useAsyncData có key, và team mình bắt được fetch trần trong setup trên waterfall.
Nhớ nhanh
$fetchtrongsetupfetch hai lần. Dùng composable có key cho data SSR.- Key là identity của cache. Key đụng thì share; key unique-per-instance phá dedupe.
- Mutation là event, không phải
setup. Đọcerror; không phải boundary Vue. useFetchkhi URL là key;useAsyncDatakhi bạn đặt tên.
Câu hỏi nối tiếp thường gặp
- lazy true? Đừng chặn navigation. Hiện skeleton. HTML đầu của widget đó trống.
- Abort khi đổi route? Có sẵn khi key đổi. Thêm
AbortControllercho$fetchimperative. - Pinia kèm useAsyncData? Đừng cache hai lần. Payload giữ data server; Pinia giữ session client.
- GET trong plugin? Plugin có thể
$fetch, nhưng data first-paint thuộc composable của page.
Tóm tắt: runtimeConfig.public được serialize ra browser. Key private ở lại server. Plugin theo request, không phải singleton module. Từ chối bỏ secret vào public.
Vì sao quan trọng: Secret nằm ở đâu, và plugin có phải .server / .client không. Họ sẽ bảo bạn bỏ key private vào public: họ muốn bạn từ chối.
- Cách hoạt động:
runtimeConfigđược dựng vào snapshot server. Key public serialize ra client thànhNUXT_PUBLIC_*. Key private chỉ đọc được trong Nitro / plugin.server. process.env.FOOtrong code app là snapshot lúc build, trừ khi đi quaruntimeConfig. Env trên host phải thắng default.envđã commit lúc runtime.- Tạo client trong
defineNuxtPlugin, không lúc import. Đánh dấu.server/.clientkhi môi trường là ý chính.ssr: falsekhông làm config private an toàn: không có server. - Thứ tự plugin so với i18n và auth rất quan trọng. Compose
errorHandler; đừng thay. Cache module scope rò user A sang user B.
export default defineNuxtConfig({
runtimeConfig: {
stripeSecret: '', // chỉ server: NUXT_STRIPE_SECRET
public: {
cdn: '', // NUXT_PUBLIC_CDN: browser thấy được
},
},
})
export default defineNuxtPlugin(() => {
const { stripeSecret, public: pub } = useRuntimeConfig()
// tạo client ở đây, không ở module scope
return { provide: { cdn: pub.cdn } }
})
// CI: grep bundle client tìm stripeSecret: không được xuất hiệnĐiều gì xảy ra: Host CDN có thể xuất hiện trong bundle browser. Secret Stripe thì không. Plugin dựng client theo app (theo request trên SSR). let cache ở module scope sẽ rò.
| Chỗ | Browser thấy | Dùng |
|---|---|---|
runtimeConfig.public | Có | Host CDN, flag không secret |
runtimeConfig private | Không | API key, secret session |
import.meta.env | Nếu bị inline | Chỉ lúc build, không phải secret |
Góc nhìn senior
- Từ chối secret Stripe trong public. Grep bundle client trong CI.
- Tạo client của plugin bên trong plugin. Không cache user ở module scope.
- Env runtime trên host phải thắng default .env lúc build. Preview/staging phải override được.
- Plugin .client không chạy trên server; .server không chạy trên browser. ssr false không phải ranh giới security.
- Ở sản phẩm admin, plugin HTTP provide client theo request; DSN Sentry là public có chủ đích; key Stripe thì không.
Lỗi hay gặp
- Triệu chứng: secret trong bundle browser. Nguyên nhân: nó nằm trong
public. Cách xử lý:runtimeConfigprivate + BFF.- Triệu chứng: staging vẫn dùng CDN prod. Nguyên nhân:
process.envlúc build. Cách xử lý: overrideruntimeConfiglúc runtime.- Triệu chứng: user B thấy user A. Nguyên nhân: cache module-scope trong plugin. Cách xử lý: tạo trong
defineNuxtPlugin.- Triệu chứng:
window is not defined. Nguyên nhân: plugin universal đọcwindowlúc import. Cách xử lý:.client.
Nhà tuyển dụng hay hỏi gì
Secret nằm ở đâu, và plugin có phải .server / .client không. Họ sẽ bảo bạn bỏ key private vào public. Họ muốn bạn từ chối.
Trả lời khi phỏng vấn
runtimeConfig public nằm trên browser. Private ở lại server. Mình từ chối bỏ secret Stripe vào public. Plugin tạo client trong defineNuxtPlugin, không lúc import, và mình đánh .server hoặc .client khi cần tách đúng môi trường. ssr false không làm config private an toàn: không có server. process.env trong code app là snapshot lúc build; env trên host phải thắng lúc runtime. Mình grep bundle client tìm secret trong CI. Ở sản phẩm admin, plugin HTTP theo request và key Stripe không bao giờ ship.
Nhớ nhanh
NUXT_PUBLIC_*browser thấy được. Key private không bao giờ vàopublic.- Tạo client của plugin bên trong plugin. Không cache user ở module scope.
- Env runtime trên host phải thắng default
.envlúc build. .server/.clientkhi cần tách đúng môi trường.
Câu hỏi nối tiếp thường gặp
- Sentry DSN trong public? Thường có: đó là key ingest chỉ ghi. Vẫn là quyết định product.
- Env preview không override? Bạn đóng cứng process.env lúc build. Dùng runtimeConfig.
- Thứ tự plugin khác i18n? Auth và locale phải sẵn trước khi plugin API đọc chúng.
- ssr false kèm config private? Không có server. Mọi thứ bạn đụng đều public.
Tóm tắt: Nitro server/api là BFF cho UI: cookie, gom, giấu upstream. Domain of record ở lại các service phụ trách data. Nó chia CPU với SSR.
Vì sao quan trọng: “Bạn để API trong Nuxt?” Senior nói BFF: gom, session cookie, giấu upstream, rồi khi nào Nitro là chỗ sai cho domain.
- Cách hoạt động: File
server/api/users.get.tslà HTTP trên origin của trang. Cookie dễ (SameSite), CORS thường biến mất. Handler phải stateless theo request. - Dùng để gom ba upstream, cắt field, map lỗi thành 4xx UI hiểu. Billing và inventory ở lại upstream.
- Nó chia CPU với SSR. Waterfall upstream 200 ms trên BFF là 200 ms TTFB của trang.
defineCachedEventHandlercho body public giống nhau, không phải/api/me. server/apivẫn là HTTP public. Auth thuộc về đó, không chỉ middleware của trang. Táchserver/khi CPU, ranh giới team, hoặc việc không HTTP xuất hiện.
export default defineEventHandler(async (event) => {
const session = getCookie(event, 'session')
if (!session) throw createError({ statusCode: 401 })
const [user, orders] = await Promise.all([
getUser(session),
getOrders(session),
])
return { user, orders } // gom BFF, đừng cache
})
// defineCachedEventHandler chỉ khi mọi user nhận cùng byteĐiều gì xảy ra: Một origin, cookie auth, hai upstream song song. TTFB của trang gồm handler này. Cache nó sẽ rò user A sang user B. Service billing tách vẫn chịu trách nhiệm hóa đơn.
| Dạng | Được | Chi phí |
|---|---|---|
| Browser → upstream | Scale độc lập | Token trong JS, CORS |
| Nuxt BFF | Cookie, một origin, một deploy | CPU chung với SSR |
| BFF tách | Scale riêng, ngôn ngữ riêng | Hai process |
Góc nhìn senior
- BFF cho UI. Domain of record ở lại upstream. Đừng để server/ phình thành monolith thứ hai chứa business rule.
- Nitro chia CPU với SSR. BFF chậm = TTFB chậm. Chỉ cache body public giống nhau.
- server/api là HTTP public. Auth handler. Middleware của trang là UX.
- Tách khi RPS của /api/* sánh SSR trang, hoặc client khác cần cùng API.
- Ở sản phẩm admin /api/me gom user kèm flag, không cache; billing ở lại service của nó.
Lỗi hay gặp
- Triệu chứng: user B thấy user A. Nguyên nhân: cache
/api/mehoặc map module-scope. Cách xử lý: theo request,cache: false.- Triệu chứng: TTFB trang chính là BFF. Nguyên nhân: await upstream nối tiếp lúc SSR. Cách xử lý:
Promise.all, hoặc CSR widget đó.- Triệu chứng: client giả mạo hit
/api/admin. Nguyên nhân: chỉ middleware của trang. Cách xử lý: auth trong handler.- Triệu chứng:
windowtrong server route. Nguyên nhân: composable dùng chung. Cách xử lý: tách.server.
Nhà tuyển dụng hay hỏi gì
“Bạn để API trong Nuxt?” Senior nói BFF: gom, session cookie, giấu upstream, rồi khi nào Nitro là chỗ sai cho domain.
Trả lời khi phỏng vấn
Nitro là BFF: cookie, gom, giấu upstream. Billing và inventory ở lại các service phụ trách chúng. Nó chia CPU với SSR, nên lời gọi BFF chậm lúc render là TTFB trang. server/api vẫn là HTTP public. Auth thuộc về đó, không chỉ middleware của trang. defineCachedEventHandler cho byte public, không phải me. Mình tách server/ khi CPU, ranh giới team, hoặc việc không HTTP xuất hiện. Ở sản phẩm admin /api/me là gom không cache và billing ở lại upstream.
Nhớ nhanh
- BFF cho UI. Domain of record ở lại upstream.
- Nitro chia CPU với SSR. BFF chậm = TTFB chậm.
server/apilà HTTP public. Auth nó.- Chỉ cache body public giống nhau, không bao giờ
/api/me.
Câu hỏi nối tiếp thường gặp
- Khi nào backend tách? Client khác, CPU nặng, hoặc ngôn ngữ/runtime Nitro làm kém.
- defineCachedEventHandler kèm cookie? Hầu như không. Body theo user.
- Trang gọi thẳng upstream? Được, nhưng mất sự đơn giản của cookie và rò token ra browser.
- server/ lớn cỡ nào? Nếu giữ domain, hãy tách. Nếu map DTO UI, giữ.
Tóm tắt: Giữ cây deterministic. <ClientOnly> bỏ HTML server, thành island thật. Lazy hydration (Nuxt 3.16+ hydrate-on-visible) giữ HTML và hoãn JS.
Vì sao quan trọng: Screenshot lệch, hoặc “vì sao trang này interactive muộn?” Họ muốn nguyên nhân, ClientOnly, và hydration trì hoãn, không phải “tắt SSR”.
- Cách hoạt động: Hydration tái sử dụng HTML SSR. Lệch thì vứt subtree. Nuxt thêm: fetch lại trên client (
$fetchtrong setup), date/locale, UI auth mà server có cookie còn client thì không. <ClientOnly>(kèm skeleton#fallback) cho map, chart, widget không SSR được. Island đó không có SEO. Hãy SSR giá crawl được bên cạnh.- Lazy hydration giữ HTML và hoãn runtime Vue:
hydrate-on-visible,hydrate-on-idle,hydrate-on-interaction(Nuxt 3.16+). Cây vẫn phải khớp. - Bọc cả trang trong
ClientOnlyxóa SSR.v-if="mounted"luôn lệch trừ khi nhánh false là thứ server đã render.
<ClientOnly>
<HeavyChart :series="series" />
<template #fallback>
<div class="chart-skeleton" />
</template>
</ClientOnly>
<div v-if="price" hydrate-on-visible>
<PriceTable :price="price" />
</div>
<!-- Chart: không HTML SSR, không hydrate ECharts. Price: HTML còn, JS đợi đến khi hiện. -->Điều gì xảy ra: Chart là island client thật: skeleton nằm trong HTML SSR, ECharts thì không. Table giá nằm trong HTML và hydrate khi hiện, nên LCP có thể là giá, không phải chart. Cả hai vẫn phải khớp nếu chúng SSR.
| Công cụ | Giữ HTML | JS |
|---|---|---|
| Sửa nguồn / payload | Có | Hydrate ngay |
<ClientOnly> | Không (island đó) | Chỉ client |
| Lazy hydration 3.16+ | Có | Sau (hiện / idle / click) |
Góc nhìn senior
- Đừng tắt SSR để hết cảnh báo. Giữ cây deterministic với key payload và session server đọc được.
- ClientOnly là island thật. Lazy hydration giữ HTML và hoãn JS. Island không phải ClientOnly.
- Hydrate dashboard 3 MB một lúc làm hỏng INP. Widget dưới fold đợi lúc hiện.
- Kiểm chứng bằng bản production và INP, không chỉ overlay dev. HMR giấu bug lệch class.
- Ở sản phẩm admin, bọn mình SSR giá, ClientOnly chart, và hydrate-on-visible hàng related-items.
Lỗi hay gặp
- Triệu chứng: hết cảnh báo, hết SEO. Nguyên nhân: bọc trang trong
ClientOnly. Cách xử lý: sửa nguồn.- Triệu chứng: INP tệ hơn sau khi “sửa SEO bằng SSR”. Nguyên nhân: hydrate dashboard khổng lồ. Cách xử lý: lazy hydration / CSR canvas.
- Triệu chứng: lệch
v-if="mounted". Nguyên nhân: server render nhánh false. Cách xử lý:ClientOnlyhoặc cây false khớp.- Triệu chứng:
$fetchtrong setup lệch. Nguyên nhân: JSON lần hai khác. Cách xử lý:useAsyncDatacó key.
Nhà tuyển dụng hay hỏi gì
Một screenshot lệch, hoặc “vì sao trang này interactive muộn thế?” Họ muốn nguyên nhân, ClientOnly, và lazy hydration, không phải “tắt SSR”.
Trả lời khi phỏng vấn
Mình không tắt SSR để hết cảnh báo. Mình giữ cây deterministic bằng payload key và session phía server đọc được. Chart vào ClientOnly kèm skeleton. Widget dưới fold lazy-hydrate để HTML còn và JS đợi: hydrate-on-visible từ Nuxt 3.16. Bọc trang trong ClientOnly là xóa SSR. Island không phải ClientOnly: một cái giữ HTML, cái kia bỏ. Mình kiểm bằng production build và INP, không chỉ overlay lúc dev. Ở sản phẩm admin, team mình SSR giá và ClientOnly chart.
Nhớ nhanh
ClientOnlybỏ HTML server. Lazy hydration giữ HTML và hoãn JS.- Đừng bọc trang trong
ClientOnlyđể hết cảnh báo. - Island ≠ ClientOnly: HTML server không Vue client, khác với không có HTML server.
- Lazy hydration là Nuxt 3.16+ (
hydrate-on-visible/ idle / interaction).
Câu hỏi nối tiếp thường gặp
- Map sang Vue 5.2.11? Cùng class lệch. Nuxt thêm payload key và directive lazy hydration.
- data-allow-mismatch? Chỉ dùng cho text, last resort. Ưu tiên sửa payload key.
- Vì sao INP tệ hơn sau SSR? Bạn hydrate cây 3 MB lúc first paint. Lazy-hydrate hoặc CSR canvas.
- useAsyncData trong ClientOnly? Nó chạy trên client. Đừng giả định island đó có payload server.