A Vue-tudásod 90%-a változatlanul érvényes. Ez a modul a maradék 10%-ról szól — hét pontról, ahol a megszokott reflexed rossz kódot ír. Sorrendben a leggyakoribb és a legdrágább hibáktól haladunk a kellemetlenségek felé.
Nuxtban nem írsz importot a saját komponenseidhez, composable-jaidhoz, sem a Vue és a Nuxt API-jaihoz:
// Vue-projektben így írnád:
import { ref, computed, onMounted } from 'vue'
import UserCard from '~/components/UserCard.vue'
import { useCart } from '~/composables/useCart'
// Nuxtban egyik sem kell — mindhárom automatikusan elérhető.
Mi importálódik automatikusan, és mi nem:
| Automatikusan elérhető | Kézzel kell importálni |
|---|---|
app/components/** (mélyen is, névtérrel)app/composables/* és app/composables/*/index.tsapp/utils/* (ugyanezzel a szabállyal)server/utils/* — csak szerveroldalonshared/utils/*, shared/types/* — mindkét oldalonVue API-k: ref, computed, watch, lifecycle hookokNuxt API-k: useFetch, useState, navigateTo, …
|
Bármi a node_modules-ból (zod, drizzle-orm, …)Beágyazott almappák a composables/ alatt (nem top-level)A shared/ egyéb részei — ezekhez a #shared/… aliasTípusok, ha nem a shared/types/-ban vannak
|
A komponensek névtérbe kerülnek a mappaszerkezet szerint, ami elsőre meglepő:
app/components/UserCard.vue → <UserCard />
app/components/admin/UserCard.vue → <AdminUserCard /> ← nem <UserCard />!
app/components/admin/billing/Table.vue → <AdminBillingTable />
useValami()-vel találkozol a kódban, nincs import, amit követhetnél. Két hely, ahol utánanézhetsz: a .nuxt/imports.d.ts (composable-ok és utilok) és a .nuxt/components.d.ts (komponensek). Az IDE „go to definition” általában működik, mert ezek valódi típusdeklarációk.app/utils/format.ts-ben formatDate-ed, és egy modul is exportál ilyet, az egyik nyer. Nincs hibaüzenet. Prefixálj (fmtDate) vagy importálj explicit módon.app/utils/index.ts-t, ami mindent re-exportál, azzal kilőtted a tree-shakinget: minden util bekerül a bundle-be, akkor is, ha egyet használsz. Workers-en, ahol méretlimit van, ez nem elméleti probléma (15. modul).imports.autoImport: false beállítással — és nagy csapatoknál van, aki így dönt, a kiszámíthatóság kedvéért. Ha ezt teszed, cserébe minden Nuxt-composable-t is importálnod kell (import { useFetch } from '#app'). A gyakorlatban a legtöbben meghagyják, és inkább a névütközések ellen fegyelmeznek.useState vs. ref — a legdrágább hibaEz a szakasz ér a legtöbbet az egész modulban. Vue SPA-ban teljesen bevett minta a modul-szintű reaktív állapot:
// app/composables/useCart.ts
const items = ref<Item[]>([]) // ← modul-scope!
export const useCart = () => {
return { items, add: (i: Item) => items.value.push(i) }
}
A böngészőben ez tökéletes: egy felhasználó, egy modulpéldány. A szerveren viszont a modul-scope nem kérésenkénti. A modul egyszer értékelődik ki, és az összes kérés ugyanazt a items tömböt látja. Az egyik felhasználó kosara megjelenik a másiknál — vagy ami rosszabb, az egyik tenant adata a másiknál.
ref-et.// app/composables/useCart.ts
export const useCart = () => {
// kérésenként izolált a szerveren, és átszerializálódik a kliensre
const items = useState<Item[]>('cart', () => [])
return { items, add: (i: Item) => items.value.push(i) }
}
A useState egy kulccsal azonosított, SSR-biztos állapot: a szerveren kérésenként külön példány, és az értéke bekerül a HTML payloadjába, hogy a kliens ugyanonnan folytassa. A szabály egyszerű:
| Használd ezt | Mikor |
|---|---|
ref() komponensen belül | Lokális UI-állapot: nyitva van-e a modál, mit gépelt be a felhasználó. Ez teljesen rendben van. |
useState('kulcs') | Komponensek közt megosztott állapot, ami az SSR-t is túléli: bejelentkezett felhasználó, aktuális tenant, téma. |
| Pinia | Ha tényleg összetett store-logikád van (actionök, getterek, devtools-nyomkövetés). SSR-rel működik, de nem az első választás. |
modul-szintű ref | Soha. Legalábbis olyasmire, ami felhasználóhoz köthető. |
server/ oldalon is él, sőt ott még élesebb: egy modul-szintű let currentUser vagy egy megosztott, kérésenként átírt objektum a server/utils/-ban pontosan ugyanezt a szivárgást csinálja. Amit a szerverkódban modul-szinten tarthatsz: immutábilis konfiguráció és felkészített kliensek, amiket nem írsz felül kérésenként.A Nuxt-composable-ok (useRoute, useState, useFetch, useRuntimeConfig, …) egy rejtett kontextusból, a Nuxt-példányból dolgoznak. Ez a kontextus szinkron módon elérhető három helyen: komponens setup, plugin, és route middleware. Máshol nem.
<script setup>
const { data } = await useFetch('/api/me')
// await UTÁN, callbackben, vagy eseménykezelőben:
button.addEventListener('click', async () => {
const cfg = useRuntimeConfig() // „Nuxt instance unavailable"
})
</script>
<script setup>
// ① a legegyszerűbb: hívd a tetején, szinkronban, és zárd be a változóba
const cfg = useRuntimeConfig()
const nuxtApp = useNuxtApp()
const { data } = await useFetch('/api/me')
async function onClick() {
console.log(cfg.public.apiBase) // már a kezünkben van
// ② ha tényleg kontextus kell egy async ág mélyén:
await nuxtApp.runWithContext(() => navigateTo('/projects'))
}
</script>
A gyakorlati szabály: minden use* hívás a <script setup> tetejére, szinkronban, az await-ek elé. Ha ezt megtartod, ezzel a hibaosztállyal nem találkozol.
Saját composable írása ugyanígy működik — csak arra figyelj, hogy ha Nuxt-composable-t hívsz benne, akkor a te composable-od is örökli a megkötést:
// app/composables/useTenant.ts
export const useTenant = () => {
const route = useRoute() // örökli a setup-megkötést
const tenant = useState<Tenant | null>('tenant', () => null)
const slug = computed(() => route.params.tenant as string)
return { tenant, slug }
}
definePageMeta és társaiNéhány Nuxt-függvény nem futásidejű hívás, hanem build-time makró — a fordító kiemeli a kódból, és a route-táblába fordítja. Ezért nem lehet nekik dinamikus értéket adni:
<script setup>
// ✓ így jó — statikus objektum
definePageMeta({
layout: 'admin',
middleware: ['auth', 'tenant'],
})
// ✗ így nem — a makró nem lát futásidejű változót
const l = user.isAdmin ? 'admin' : 'default'
definePageMeta({ layout: l }) // build-hiba vagy néma hiba
// ✓ ha futásidőben kell váltani:
setPageLayout(user.isAdmin ? 'admin' : 'default')
</script>
Ugyanez a logika érvényes a defineNuxtRouteMiddleware-re és a defineNuxtPlugin-re: a fájl helye és a makró együtt mondja meg a Nuxtnak, mit csináljon a kóddal.
.client, .server és a feltételes futásNégy eszközöd van arra, hogy valami csak az egyik oldalon fusson. Ezek nem cserélhetők fel:
| Eszköz | Mit csinál | Mikor |
|---|---|---|
MapView.client.vue | A komponens SSR-kor egyáltalán nem renderelődik; csak a böngészőben. | Az egész komponens böngésző-függő: térkép, chart, WYSIWYG-szerkesztő. |
<ClientOnly> | A benne lévő rész csak kliensen renderel — de adhatsz #fallback-et az SSR-hez. | Egy komponens része problémás, és kell szerveroldali helykitöltő. |
plugins/x.client.ts | A plugin csak a böngészőben fut le. | Analitika, hibakövető SDK, böngésző-API-k inicializálása. |
onMounted() | Sosem fut a szerveren — ez a garantáltan kliensoldali életciklus-hook. | Egy sornyi böngésző-hozzáférés egy egyébként SSR-elt komponensben. |
<template>
<ClientOnly>
<RevenueChart :data="data" />
<template #fallback>
<!-- ez megy ki a HTML-be SSR-kor: nincs layout-ugrás, van mit indexelni -->
<div class="chart-skeleton">Diagram betöltése…</div>
</template>
</ClientOnly>
</template>
<ClientOnly> nem javítás, hanem kompromisszum. Amit belecsomagolsz, az kikerül az SSR-ből: nem lesz benne a HTML-ben, a robot nem látja, és a felhasználó később látja meg. Ha hydration mismatch elől menekülsz vele, azzal a tünetet takarod el. Először mindig azt kérdezd meg, miért tér el a szerver és a kliens kimenete — a következő szakasz erről szól.Ez az a hibaosztály, ami SPA-ban nem létezik, és Nuxtban a leggyakoribb „miért csinálja ezt?” pillanat. A hiba mindig ugyanaz: a szerveren rendereltől eltérő HTML-t akar a kliens. A konzolban valami ilyet látsz: Hydration node mismatch vagy Hydration text content mismatch, és a komponens újrarenderelődik a kliensen (villan).
| Ok | Példa | Javítás |
|---|---|---|
| Idő és dátum | {{ new Date().toLocaleString() }} — a szerveren más másodperc, más időzóna |
Rögzített időpontot adj át (a szerveren keletkezett), és a relatív időt („3 perce”) számold onMounted után |
| Véletlen | Math.random(), generált ID |
useId() a stabil azonosítókhoz; egyébként kliensre halasztani |
| Böngésző-API elágazás | v-if="window.innerWidth < 768" |
SSR-kor egy alapértelmezés, majd onMounted-ben pontosítás — vagy CSS media query, ami nem is JS |
localStorage-ból olvasott állapot |
téma, nyelv, „elrejtettem ezt a bannert” | Cookie-ba tedd (useCookie) — azt a szerver is látja, tehát nem tér el a két kimenet |
| Érvénytelen HTML-beágyazás | <div> egy <p>-ben, <a> egy <a>-ban |
A böngésző „megjavítja” a HTML-t, ezért lesz más a fa. Javítsd a markupot — ez mindig a te hibád |
| Nem determinisztikus adat | rendezetlen lista, Object.keys() sorrendje, „random ajánlás” |
Rendezz explicit módon, és a szerveri választ használd a kliensen is |
| Harmadik fél belenyúl a DOM-ba | böngésző-kiterjesztés, chat-widget, fordító | Nem tudod javítani — de fel tudod ismerni: inkognitóban, kiterjesztések nélkül eltűnik |
curl vagy „forrás megtekintése” (nem a DevTools Elements fül, mert az már a hidratált DOM-ot mutatja). Ha ott már rossz, akkor SSR-hiba, nem hydration-hiba.<ClientOnly>-ba a gyanús felet — nem javításként, hanem hogy megtaláld a bűnöst.NUXT_E… formában), ami nagyban gyorsítja a keresést — a hibaüzenetre rákeresve célzott dokumentációt kapsz, nem StackOverflow-régészetet. A 16. modulban a hibakeresést részletesen is végigvesszük.Ezek nem hibák, csak más szokások. A bal oldali is működik általában — a jobb oldali az, ami Nuxtban helyes, és amit SSR mellett elvárnak tőled:
| Vue-ban így | Nuxtban így | Miért |
|---|---|---|
<RouterLink to> | <NuxtLink to> | Automatikus prefetch, külső linkek kezelése, prerender-tudatosság |
router.push('/x') | navigateTo('/x') | Szerveren is működik — ott valódi HTTP-átirányítást csinál, nem kliensoldali ugrást |
document.title = x | useSeoMeta({ title: x }) | A meta már a szerveroldali HTML-be kerül — ez az SSR fél haszna |
import.meta.env.VITE_API | useRuntimeConfig() | Futásidőben állítható, és a titkok nem kerülnek a kliens-bundle-be (12. modul) |
axios-példány | $fetch / useFetch | A szerveren a belső /api/… hívás közvetlen függvényhívássá válik — nincs HTTP-kör, Workers-en nincs extra subrequest |
main.ts + app.use() | app/plugins/x.ts | Nincs saját belépési pont; a plugin-mappa veszi át a szerepét |
App.vue + <RouterView> | app/app.vue + <NuxtLayout><NuxtPage/> | A layout-réteg beépült a routingba |
| Pinia mindenre | useState() az egyszerű esetekre | Kevesebb függőség, és SSR-biztos alapból |
$fetch('/api/projects')-et hívsz, a Nitro nem indít HTTP-kérést önmaga felé, hanem közvetlenül meghívja a handlert. Workers-en ez kifejezetten értékes: nem fogyaszt subrequestet, nem terheli a 6 párhuzamos kimenő kapcsolat korlátját, és nincs hálózati késleltetés. Külső URL-nél viszont valódi kérés megy ki — ott már számít.Ha most kezdenél átírni valamit Nuxtra, ezt a hatot nézd át magadon:
ref vagy megosztott mutálható objektum bárhol? → useStateuse* hívás a setup tetején van, await előtt?window, document, localStorage hivatkozás védelem nélkül? → import.meta.client vagy onMountedserver/ alatt van? (2. modul táblája)<ClientOnly>-k valódi okból vannak ott, nem hydration-hiba eltakarására?useSeoMeta-val készülnek, nem onMounted-ben?/t/:tenant/… vs. subdomain vs. egyedi domain), mert ez a döntés később nehezen visszafordítható.const user = ref(null) a composable-fájl tetején, modul-szinten?
Mert a szerveren a modul-scope nem kérésenkénti: a modul egyszer értékelődik ki, és minden kérés ugyanazt a ref-et látja. Így az egyik felhasználó (vagy tenant) adata megjelenhet a másiknál. Workers-en ez különösen valószínű, mert az izolátumok újrahasznosulnak. Megoldás: useState('user', () => null), ami kérésenként izolált és a payloadon keresztül átjut a kliensre.
Szinkron módon, komponens-setupban, pluginben vagy route middleware-ben. Gyakorlati megfogalmazás: a <script setup> tetején, az await-ek elé. Ha egy async ág mélyén mégis kell a kontextus, akkor useNuxtApp().runWithContext(() => …).
app/components/admin/UserCard.vue-d. Hogy hivatkozol rá a sablonban?
<AdminUserCard /> — a mappaszerkezet névtérként előtagolja a komponens nevét. Ha ez zavar, a components konfigban kikapcsolható a pathPrefix, de akkor ügyelj a névütközésekre.
localStorage-ban van, és hydration mismatch-et okoz. Mi a jó megoldás, és mi a rossz?
Rossz: <ClientOnly>-ba csomagolni az egész oldalt — ezzel elveszted az SSR-t. Jó: cookie-ba tenni a témát (useCookie), mert azt a szerver is látja a kérésből, így a szerveroldali és a kliensoldali render ugyanazt adja. Ez a minta minden „felhasználói beállítás, ami befolyásolja az első renderelést” esetre igaz.
definePageMeta-nak?
Mert nem futásidejű függvényhívás, hanem build-time compiler-makró: a fordító kiemeli a komponensből, és a generált route-táblába fordítja, ahol futásidejű változó nem létezik. Ha futásidőben kell layoutot váltani, arra a setPageLayout() való.
$fetch('/api/projects')-et hívsz?
A Nitro felismeri, hogy belső útvonalról van szó, és közvetlenül meghívja a handlert — nem megy ki HTTP-kérés. Ez Workers-en konkrét nyereség: nem fogyaszt subrequestet, nem terheli a párhuzamos kimenő kapcsolatok korlátját, és nincs hálózati késleltetés. Külső URL-re viszont valódi kérés indul.