4. modulRouting, layoutok, middleware
Nuxt for Devs · 4. modul

Routing, layoutok, middleware

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.

4.1A fájl → route leképezés, teljesen

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ő:

Felülírás és validálás 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.

Hol nézheted meg a valódi route-táblát? A generált változat a .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.

4.2Navigáció: <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 })
Miért számít ez Workers-en? Ha a szerveroldali renderelés közben derül ki, hogy át kell irányítani (nincs session, rossz tenant), akkor a 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.

4.3Layoutok

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 }).

Named views (4.5)

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.

4.4Middleware — négy fajta, és csak egy közülük biztonsági

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:

FajtaHol vanMikor futBiztonsá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
})
A szabály, amit érdemes kiírni a monitorra: a route middleware azért van, hogy a felhasználó ne lásson olyan képernyőt, amihez nincs joga. Az adat védelme minden egyes 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.

4.5Multitenant útvonaltervezés

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 app.pelda.hu/t/acme/… + egy domain, egy tanúsítvány + a legegyszerűbb indulás − közös cookie-tér a tenantok közt − nincs saját arculatú URL ezzel indulj ② Aldomain acme.pelda.hu/… + tenantonként külön cookie-tér + profi látszat, önkiszolgáló − wildcard DNS + tanúsítvány kell − a cache-kulcsba be kell venni a tipikus végállapot ③ Egyedi domain app.acme.hu/… + teljes white-label + eladható prémium funkció − tanúsítvány-kezelés ügyfelenként − SEO: canonical kötelező Cloudflare for SaaS
A három modell nem kizárja, hanem rétegzi egymást: a legtöbb SaaS ①-gyel indul, ②-re áll át, és ③-at prémium csomagban adja.

A négy dolog, ami a döntést eldönti

① Útvonal-prefix② Aldomain③ Egyedi domain
Cookie-izolációNincs — közös originVan, ha nem a szülő-domainre sütszTeljes
TanúsítványSemmi teendőWildcard — csak egy szintreÜgyfelenként kiállítás
Cloudflare-oldalWildcard DNS + Universal SSLCloudflare for SaaS custom hostnames
Cache-kulcsAz útvonalban benne a tenantA hosztot bele kell venniA hosztot bele kell venni
Tenant feloldásaroute.params.tenanta Host fejlécbőla Host fejlécből, DB-lookuppal
SEOEgyszerűAldomainenként külön kell gondolkodniCanonical és duplikáció-kezelés kötelező
A tanúsítvány-csapda, amibe sokan belefutnak. A wildcard tanúsítvány pontosan egy aldomain-szintet fed le. Ha 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.

Az implementáció: egy helyen oldd fel

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)
Miért a 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.

Két csapda, ami csak élesben derül ki

Cloudflare-oldalról röviden: az aldomain-modellhez wildcard DNS-rekord és az első szintre eső Universal SSL elég. Az egyedi domainekhez Cloudflare for SaaS custom hostnames való: az ügyfél CNAME-mel rád mutat, a Cloudflare kiállítja és megújítja a tanúsítványt, te pedig ugyanabban a Workerben szolgálod ki. Ez a Cloudflare-anyag 16. moduljának témája — érdemes elolvasni, mielőtt ígéretet teszel egy ügyfélnek egyedi domainre.

4.6Hibaoldal és 404

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>
Amit ne írj a hibaoldalba: a 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.

4.7Buktatók, amikbe mindenki belefut

Mi jön ezután? Az 5. modul az adatlekérésről szól — a tananyag legfontosabb része. 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.

4.8Ellenőrizd magad

  1. Van 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?
    Válasz

    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.

  2. Miért nem elég a app/middleware/auth.global.ts a védelemhez?
    Válasz

    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.

  3. Aldomain-modellt választasz, és acme.app.pelda.hu formátumban gondolkodsz. Mi a probléma?
    Válasz

    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.

  4. Hol tárold a feloldott tenantot a kérés során, és miért pont ott?
    Válasz

    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.

  5. Egy felhasználó olyan projekt URL-jét nyitja meg, ami létezik, de nem az ő tenantjához tartozik. Milyen státuszkódot adj vissza?
    Válasz

    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.

  6. Mi történik, ha egy route middleware-ben navigateTo('/login')-t hívsz return nélkül?
    Válasz

    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.

Előző3. modul — Vue-fejjel Nuxtba: mi változik Következő 5. modul — Adatlekérés — a kulcsmodul