Hub

Nuxt.js

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.

  1. Cách hoạt động: pages/index.vue là một route. server/api là HTTP trên cùng origin. useAsyncData ghi nhận một payload để client không refetch lúc hydrate.
  2. 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.
  3. 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: false toàn cục là Vue SPA với framework thừa.
  4. Ở 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ầngNuxt thêmVue SPA đã có
UIVue 3Vue 3
RouterFile routing pages/vue-router bạn tự setup
HTMLSSR / SSG / hybridShell index.html
ServerNitro + server/apiAPI 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: false toà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: false toàn cục. Cách xử lý: routeRules hybrid, hoặc cứ dùng Vite.
  • Triệu chứng: hai /api/me lúc load đầu. Nguyên nhân: $fetch trong setup. Cách xử lý: useAsyncData có 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: false toà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ó.

  1. 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.
  2. Routing Nuxt theo file. Shell query-driven kiểu all-in-one sẽ đánh nhau với pages/ và routeRules. useFetch không tồn tại trong app Vite: không có payload.
  3. SSR tốn CPU origin, hydration, và ranh giới môi trường (window, cookie khác localStorage). Vue SPA tĩnh trên object storage rẻ hơn khi không có SEO.
  4. 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ầngVue SPANuxt
UI / reactivityVue 3Vue 3
HTML đầuShell trốngSSR / SSG / hybrid
Data cho HTMLBạn tự dựngPayload + keys
ServerTáchNitro, cùng origin

Góc nhìn senior

  • Thêm Nuxt cho public + hybrid, đừng viết lại design system.
  • useFetch và 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 useFetch và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.
  • useFetch và 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.

  1. 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.
  2. 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.
  3. 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.
  4. routeRules không thay key useAsyncData. 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ỏ.

ModeBrowser nhận trướcKhi nào
SSGHTML tĩnhPublic, ít đổi
SWR / ISRHTML cache, rồi refreshPublic, hơi stale được
SSRHTML mới + payloadCá nhân hóa, crawl được
CSR / SPAShell JSAuth / 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.
  • routeRules là control plane. Key trên useAsyncData vẫ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: false trên path private.
  • Triệu chứng: TTFB nát. Nguyên nhân: SSR dashboard chart. Cách xử lý: ssr: false route đó.
  • 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

  • routeRules là 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 / transform thu 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.

  1. Cách hoạt động: defineNuxtRouteMiddleware chạ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.
  2. Nitro server/middleware / server/api mới là ranh giới thật: nó thấy request thô. Route middleware không bảo vệ được server/api.
  3. localStorage vô hình trên SSR, nên không phải source of truth cho auth. Loại /login khỏi auth global, không thì loop.
  4. 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ầngViệcKhông phải việc
Route middlewareRedirect, chọn layout, trước renderAuthorization của server/api
Middleware server NitroRequest thô, cookie, 401Redirect UX đẹp
Token localStorageExtra chỉ trên clientSession 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: /me mỗ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. localStorage khô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 để /me khô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? beforeEach là 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.

  1. 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 refresh hoặc key đổi. Key trùng thì share state.
  2. $fetch cho 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.
  3. Sau mutation thì gọi refreshNuxtData(key). Một POST không thuộc setup. watch key khi param của route đổi. lazy: true không chặn navigation.
  4. Đọc error và pending. Chúng không phải error boundary Vue. transform / pick thu 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.

APIPayloadDùng
$fetchKhôngClick, Nitro, plugin
useFetch(url)Có, URL là keyHTTP đơn giản trong setup
useAsyncDataCó, bạn đặt tênKey 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: $fetch trong 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

  • $fetch trong setup fetch 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. Đọc error; không phải boundary Vue.
  • useFetch khi URL là key; useAsyncData khi 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 AbortController cho $fetch imperative.
  • 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.

  1. Cách hoạt động: runtimeConfig được dựng vào snapshot server. Key public serialize ra client thành NUXT_PUBLIC_*. Key private chỉ đọc được trong Nitro / plugin .server.
  2. process.env.FOO trong code app là snapshot lúc build, trừ khi đi qua runtimeConfig. Env trên host phải thắng default .env đã commit lúc runtime.
  3. Tạo client trong defineNuxtPlugin, không lúc import. Đánh dấu .server / .client khi môi trường là ý chính. ssr: false không làm config private an toàn: không có server.
  4. 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ấyDùng
runtimeConfig.publicCóHost CDN, flag không secret
runtimeConfig privateKhôngAPI key, secret session
import.meta.envNếu bị inlineChỉ 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ý: runtimeConfig private + BFF.
  • Triệu chứng: staging vẫn dùng CDN prod. Nguyên nhân: process.env lúc build. Cách xử lý: override runtimeConfig lú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 đọc window lú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ào public.
  • 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.
  • .server / .client khi 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.

  1. Cách hoạt động: File server/api/users.get.ts là HTTP trên origin của trang. Cookie dễ (SameSite), CORS thường biến mất. Handler phải stateless theo request.
  2. Dùng để gom ba upstream, cắt field, map lỗi thành 4xx UI hiểu. Billing và inventory ở lại upstream.
  3. Nó chia CPU với SSR. Waterfall upstream 200 ms trên BFF là 200 ms TTFB của trang. defineCachedEventHandler cho body public giống nhau, không phải /api/me.
  4. server/api vẫn là HTTP public. Auth thuộc về đó, không chỉ middleware của trang. Tách server/ 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ĐượcChi phí
Browser → upstreamScale độc lậpToken trong JS, CORS
Nuxt BFFCookie, một origin, một deployCPU chung với SSR
BFF táchScale riêng, ngôn ngữ riêngHai 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/me hoặ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: window trong 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/api là 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”.

  1. 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 ($fetch trong setup), date/locale, UI auth mà server có cookie còn client thì không.
  2. <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.
  3. 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.
  4. Bọc cả trang trong ClientOnly xó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ữ HTMLJS
Sửa nguồn / payloadCó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ý: ClientOnly hoặc cây false khớp.
  • Triệu chứng: $fetch trong setup lệch. Nguyên nhân: JSON lần hai khác. Cách xử lý: useAsyncData có 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

  • ClientOnly bỏ 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.