A fájl-alapú routing teljes szabályrendszere, a háromféle middleware és hogy melyik hol fut — majd a modul második fele a legnehezebben visszafordítható döntésedről: hogyan tervezd meg a multitenant útvonalaidat. Ez a szakasz kifejezetten a te SaaS-odra készült.
A app/pages/ alatti szerkezet közvetlenül fordul route-táblává. A teljes szabályrendszer nem nagy, és érdemes egyszerre látni:
app/pages/
index.vue → /
about.vue → /about
projects/index.vue → /projects
projects/[id].vue → /projects/:id (kötelező paraméter)
projects/[id]/settings.vue → /projects/:id/settings
blog/[[slug]].vue → /blog ÉS /blog/:slug (opcionális)
docs/[...slug].vue → /docs/* — a slug TÖMB lesz
(marketing)/pricing.vue → /pricing — a zárójeles mappa nem látszik az URL-ben
settings.vue ┐ → /settings szülő, benne <NuxtPage />
settings/ │
index.vue ├─ egyben → /settings
billing.vue ┘ → /settings/billing (a szülőn belül renderel)
Négy dolog, ami elsőre nem magától értetődő:
route.params.id soha nem szám, akkor sem, ha annak néz ki. Ha számot vársz, konvertálj és validálj (mindjárt jön a validate).[...slug].vue esetén a /docs/a/b/c-ből ['a','b','c'] lesz, nem 'a/b/c'.(marketing)/ mappa csoportosít, de nem jelenik meg az URL-ben. Arra jó, hogy a publikus oldalaid egy helyen legyenek, közös layouttal, anélkül hogy prefixet kapnának.settings/ mappa mellé settings.vue — és abban egy <NuxtPage />, ahova a gyerek renderel. Enélkül a gyerekek testvér-route-ok lesznek, nem beágyazottak.definePageMeta-val<script setup lang="ts">
definePageMeta({
layout: 'app',
middleware: ['auth', 'tenant'], // ebben a sorrendben futnak
alias: ['/p/:id'], // régi URL életben tartása
// ha nem illik a minta, a route ELDOBÓDIK → 404, még mielőtt renderelnénk
validate: (route) => /^\d+$/.test(route.params.id as string),
})
</script>
A validate alulértékelt: ezzel intézed el a „/projects/abc nem létező azonosító” esetet anélkül, hogy lefutna az adatlekérés és a renderelés. Multitenantnál különösen hasznos — a rosszul formázott tenant-slugokat itt szűröd ki.
.nuxt/ alatt van, de kényelmesebb a Nuxt DevTools „Pages” füle: ott listában látod az összes route-ot, a hozzárendelt layoutot és middleware-t, és ki is próbálhatsz mintákat. Amikor egy URL nem oda visz, ahova gondolod, ez az első hely, ahova nézni kell.<NuxtLink> és navigateTo<template>
<!-- alapból prefetchel, amikor a link a nézetbe kerül -->
<NuxtLink to="/projects">Projektek</NuxtLink>
<!-- listákban érdemes visszafogni: interakcióra, ne láthatóságra -->
<NuxtLink :to="`/projects/${p.id}`" :prefetch-on="{ interaction: true }">
{{ p.name }}
</NuxtLink>
<!-- külső link: felismeri, és nem kliensoldali navigációt csinál -->
<NuxtLink to="https://developers.cloudflare.com" target="_blank">Dokumentáció</NuxtLink>
</template>
Programozott navigációra navigateTo-t használj, ne router.push-t — mert ez a szerveren is működik:
await navigateTo('/projects') // normál navigáció
await navigateTo('/login', { replace: true }) // nincs vissza-gomb
await navigateTo('/login', { redirectCode: 303 }) // SSR alatt valódi HTTP-átirányítás
await navigateTo('https://billing.example.com', { external: true })
navigateTo egy valódi 302/303 választ ad — nem renderel le egy oldalt, hogy aztán a böngésző ugorjon tovább. Ez egy megspórolt teljes SSR-ciklus kérésenként: kevesebb CPU-idő, gyorsabb válasz.A layout egy komponens egy <slot />-tal, ahova az oldal renderel:
<!-- app/layouts/default.vue -->
<template>
<div class="shell">
<AppHeader />
<main><slot /></main>
<AppFooter />
</div>
</template>
<!-- app/app.vue — a gyökér -->
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
Oldalanként a definePageMeta({ layout: 'admin' }) választ; futásidőben a setPageLayout('admin') vált (emlékeztető a 3. modulból: a makró nem lát változót). A 4.5-ös useLayout() composable-lel pedig kiolvashatod és állíthatod az aktuális layoutot reaktívan — ez korábban körülményesebb volt.
Ha egy oldal semmilyen keretet nem akar (például egy nyomtatási nézet vagy egy beágyazható widget): definePageMeta({ layout: false }).
Ha egy szülő-oldalnak több kimenete van (például egy fő tartalom és egy oldalsáv, amit a gyerek route tölt fel), a gyerek@nezet.vue névkonvencióval és több <NuxtPage name="…" />-dzsel megoldható. Ritkán kell, de amikor kell, jó tudni, hogy létezik — korábban erre kényszerű trükközés volt a válasz.
Ez a szakasz a modul másik fontos része. Négy különböző dolgot hívnak „middleware”-nek Nuxtban, és összekeverni őket költséges:
| Fajta | Hol van | Mikor fut | Biztonsági határ? |
|---|---|---|---|
| Inline route middleware | definePageMeta({ middleware: (to, from) => … }) |
Az adott oldalra navigáláskor | Nem |
| Névvel hivatkozott | app/middleware/auth.ts |
Ahol a definePageMeta kéri |
Nem |
| Globális | app/middleware/auth.global.ts |
Minden navigációnál | Nem |
| Szerver middleware | server/middleware/tenant.ts |
Minden szerverre érkező kérésnél | Igen — itt kell |
Az első három ugyanaz az eszköz, csak másképp aktiválva. Mindhárom a Vue-router szintjén fut: az első, szerveroldali kérésnél a szerveren, utána viszont minden kliensoldali navigációnál a böngészőben. Ezért nem lehet rájuk bízni a védelmet.
// app/middleware/auth.ts — UI-szintű terelés, nem védelem
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
return navigateTo(`/login?next=${encodeURIComponent(to.fullPath)}`)
}
// abortNavigation('Nincs jogosultság') → hibaoldal, navigáció nélkül
})
// server/middleware/tenant.ts — EZ fut minden kérésnél, a szerveren
export default defineEventHandler(async (event) => {
const host = getRequestHost(event)
const tenant = await resolveTenant(event, host) // server/utils/
// a kérés kontextusába tesszük — innen látja minden server route és az SSR
event.context.tenant = tenant
})
server/api végponton külön történik. Ha egy adatot csak a route middleware véd, akkor nem véd semmi: a támadó nem navigál, hanem közvetlenül hívja az API-t. A 10. modulban ezt teljes mintával megcsináljuk.Most jön az a döntés, amit később drágán lehet visszacsinálni, mert a felhasználók URL-jeibe, könyvjelzőibe és a Google indexébe beépül. Három modell van:
| ① Útvonal-prefix | ② Aldomain | ③ Egyedi domain | |
|---|---|---|---|
| Cookie-izoláció | Nincs — közös origin | Van, ha nem a szülő-domainre sütsz | Teljes |
| Tanúsítvány | Semmi teendő | Wildcard — csak egy szintre | Ügyfelenként kiállítás |
| Cloudflare-oldal | — | Wildcard DNS + Universal SSL | Cloudflare for SaaS custom hostnames |
| Cache-kulcs | Az útvonalban benne a tenant | A hosztot bele kell venni | A hosztot bele kell venni |
| Tenant feloldása | route.params.tenant | a Host fejlécből | a Host fejlécből, DB-lookuppal |
| SEO | Egyszerű | Aldomainenként külön kell gondolkodni | Canonical és duplikáció-kezelés kötelező |
acme.pelda.hu-t tervezel, a Cloudflare Universal SSL alapból lefedi. Ha viszont acme.app.pelda.hu-t — vagyis egy szinttel mélyebbre teszed —, akkor a *.pelda.hu már nem fedi, és fizetős Advanced Certificate Manager (vagy saját feltöltött tanúsítvány) kell hozzá. Ez az egy karakternyi tervezési különbség havi tételt jelent. Ha aldomain-modellben gondolkodsz, tedd a tenantokat az első szintre.Bármelyik modellt választod, a tenant feloldása egyetlen szerver middleware dolga legyen, és onnan terjedjen tovább:
// server/middleware/tenant.ts
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
const host = url.hostname
let slug: string | null = null
// ② aldomain: acme.pelda.hu
if (host.endsWith('.pelda.hu') && host !== 'app.pelda.hu') {
slug = host.replace('.pelda.hu', '')
}
// ① útvonal-prefix: /t/acme/…
else if (url.pathname.startsWith('/t/')) {
slug = url.pathname.split('/')[2] ?? null
}
// ③ egyedi domain: app.acme.hu → DB-lookup (érdemes cache-elni, 18. modul)
else {
slug = await lookupCustomDomain(event, host)
}
if (slug) {
const tenant = await loadTenant(event, slug)
if (!tenant) throw createError({ statusCode: 404, statusMessage: 'Ismeretlen munkaterület' })
event.context.tenant = tenant // innen látja minden server route ÉS az SSR
}
})
A kliensoldal ugyanezt az egy forrást kapja meg — nem oldja fel újra:
// server/api/bootstrap.get.ts — publikus tenant-adatok (arculat, funkciók)
export default defineEventHandler((event) => {
const t = event.context.tenant
return t ? { slug: t.slug, name: t.name, theme: t.theme } : null
})
// app/composables/useTenant.ts
export const useTenant = () => useState<PublicTenant | null>('tenant', () => null)
event.context? Mert kérésenként külön objektum — pontosan az, ami a 3. modulban tárgyalt modul-szintű állapot nem. Ez a helyes hely a kérés-hatókörű adatnak Nitróban, és Workers-en ez a különbség adatszivárgás és helyes működés között van..pelda.hu-ra sütöd, akkor az minden tenant aldomainjére elmegy — ezzel eldobtad az izoláció fő hasznát. Ha viszont aldomainenként sütsz, akkor a tenantváltásnál újra be kell jelentkezni (vagy egy központi SSO-lépés kell). Ez tudatos döntés, nem véletlen: a 10. modulban végigvesszük mindkét felállást./dashboard) különböző tenanthoz tartozik. Ha a cache csak az útvonalat nézi, az egyik tenant oldalát szolgálod ki a másiknak. A hoszt része kell hogy legyen a kulcsnak. A 18. modul erről szól, de a tervezésnél már most tudni kell.Két külön dolog, és gyakran keverednek:
// bármelyik server route-ban vagy setup-ban:
throw createError({ statusCode: 404, statusMessage: 'Nincs ilyen projekt', fatal: true })
<!-- app/error.vue — a gyökérben, NEM layout, NEM a pages alatt -->
<script setup lang="ts">
const props = defineProps<{ error: { statusCode: number, statusMessage?: string } }>()
</script>
<template>
<div class="err">
<h1>{{ error.statusCode }}</h1>
<p>{{ error.statusMessage }}</p>
<button @click="clearError({ redirect: '/' })">Vissza a főoldalra</button>
</div>
</template>
statusMessage kimegy a kliensre. Ne tegyél bele adatbázis-hibát, belső azonosítót vagy stack trace-t — az error.message és a data mező bizalmas részei szerveroldalon maradnak, de amit te írsz a statusMessage-be, azt a felhasználó látja. Multitenantnál különösen: a „nincs jogosultságod ehhez a projekthez” üzenet is elárulja, hogy létezik az a projekt. Ilyenkor a 404 a helyes válasz, nem a 403.pages/ mappa → nincs routing. Ha csak app.vue van, a Nuxt SPA-módban, egyetlen oldalként fut, és a <NuxtPage /> nem csinál semmit. Az első pages/index.vue létrehozásakor „kapcsol be” a router./projects és a /projects/ alapból két különböző URL a cache szempontjából. Válassz egy formát, és a másikat irányítsd át — Cloudflare-en ezt szabállyal is megteheted, a Worker előtt (Cloudflare-anyag 21. modul).settings/ mappád, de nincs settings.vue-d, akkor nincs beágyazás — csak testvér-route-ok. A „miért nem marad meg a fül-sáv navigációkor” kérdés jellemzően ez.return. A navigateTo route middleware-ben csak akkor irányít át, ha visszaadod (return navigateTo(…)). Enélkül lefut, de a navigáció folytatódik.*.global.ts tényleg minden navigációnál fut, beleértve a publikus marketing-oldalakat is. Ha DB-t hívsz benne, azt minden oldalletöltés megfizeti.useFetch vs. useAsyncData vs. $fetch, a kulcsok szerepe, és a legkevésbé ismert csapda: mi utazik ki a HTML payloadjába a szerveri lekérésből, és hogyan szivárogtatsz vele más tenant adatát.app/pages/settings/billing.vue-d és app/pages/settings/index.vue-d, de a fül-sáv eltűnik, amikor a /settings/billing-ra navigálsz. Mi hiányzik?
A app/pages/settings.vue szülőfájl, benne egy <NuxtPage />-dzsel. Enélkül a settings/ alatti oldalak testvér-route-ok, nem beágyazottak — így a szülő (és benne a fül-sáv) nem marad renderelve.
app/middleware/auth.global.ts a védelemhez?
Mert a route middleware a Vue-router szintjén fut: az első kérésnél a szerveren, utána viszont minden kliensoldali navigációnál a böngészőben — tehát megkerülhető. Arra jó, hogy a felhasználó ne lásson olyan képernyőt, amihez nincs joga. Az adatot minden server/api végponton külön kell védeni.
acme.app.pelda.hu formátumban gondolkodsz. Mi a probléma?
A wildcard tanúsítvány csak egy aldomain-szintet fed le: a *.pelda.hu lefedi az acme.pelda.hu-t, de nem az acme.app.pelda.hu-t. Ehhez fizetős Advanced Certificate Manager vagy saját feltöltött tanúsítvány kell. Ha teheted, tedd a tenantokat az első szintre: acme.pelda.hu.
Az event.context-ben, egy server/middleware-ből beállítva. Azért ott, mert az kérésenként külön objektum — szemben a modul-szintű változóval, ami a szerveren megosztott a kérések között, és tenantok közti adatszivárgást okoz. A kliensoldal ugyanezt az egy forrást kapja meg egy useState-be, nem oldja fel újra.
404-et, ne 403-at. A 403 azt is elárulja, hogy az erőforrás létezik — multitenant rendszerben ez önmagában információszivárgás (kikövetkeztethető belőle, mely azonosítók vannak használatban). Egységesen 404-et adj mindenre, amihez a hívónak nincs hozzáférése.
navigateTo('/login')-t hívsz return nélkül?
Semmi hasznos: a hívás lefut, de a navigáció folytatódik az eredeti cél felé. A route middleware a visszatérési értéket értelmezi utasításként — return navigateTo(…) vagy return abortNavigation(…). Ez a leggyakoribb néma hiba ebben a fájltípusban.