A Nuxt egyik legalulértékeltebb képessége: egy projekt kiterjeszthet egy másikat, és fájlonként felül tudja írni. A modul második fele a te esetedről szól — tenant- vagy márkaspecifikus arculat és funkció egy közös alap fölött —, és arról a döntésről, ami itt tényleg számít: build-időben vagy futásidőben különböznek a tenantjaid?
Egy layer maga is egy Nuxt-projekt: van app/-ja, server/-e, nuxt.config.ts-e. A különbség csak annyi, hogy nem önmagában futtatod, hanem egy másik projekt kiterjeszti:
// a márka-projekt nuxt.config.ts-e
export default defineNuxtConfig({
extends: [
'../base', // lokális könyvtár
'@acme/nuxt-base', // npm-csomag
'github:acme/nuxt-base#v2', // távoli git (giget)
],
})
Innentől a base layer minden oldala, komponense, composable-je és server route-ja úgy viselkedik, mintha a te projektedben lenne — de bármelyiket felülírhatod azzal, hogy létrehozod ugyanazt a fájlt nálad.
| Modul (13. modul) | Layer | |
|---|---|---|
| Mit oszt meg | Viselkedést — programozottan | Fájlokat — konvenció szerint |
| Hogyan | defineNuxtModule, addServerHandler… | Sima Nuxt-projektszerkezet |
| Felülírható-e | Csak amit a modul kiajánl (options) | Fájlonként, bármi |
| Mire jó | Middleware, composable, konfigurációs konvenció | Oldalak, layoutok, arculat, teljes al-alkalmazás |
Amikor több helyen létezik ugyanaz a fájl, ez a prioritási sorrend dönt (fentről lefelé csökkenő erősséggel):
extends sorrendje. Ösztönösen azt gondolnád, hogy a lista végén lévő nyer (mint egy CSS-import-láncnál). Nem: az első elem a legerősebb. Ha a ['../base', '../brand'] sorrendet írod, akkor a base írja felül a brandet — vagyis pont fordítva, mint ahogy szeretnéd. A helyes: ['../brand', '../base'].| Viselkedés | |
|---|---|
| Komponensek | Felülírás azonos név alapján — a Button.vue a te projektedben nyer |
| Oldalak | A route-ok összeadódnak; azonos útvonalnál a magasabb prioritású nyer |
| Composable-ök, utilok | Felülírás azonos fájlnév alapján |
| Server route-ok | Felülírás azonos útvonal alapján |
modules: [], css: [] | Összeolvadnak (a listák összefűződnek) |
runtimeConfig | Mélységi merge — a magasabb prioritású kulcsai nyernek |
routeRules | Merge, mintánként |
// a layerek srcDir-jére automatikus alias készül
import BaseButton from '#layers/base/components/Button.vue'
// hasznos, ha felülírsz, de az eredetit is használni akarod:
<script setup>
import BaseHeader from '#layers/base/components/AppHeader.vue'
</script>
<template>
<div class="brand-bar"><BrandLogo /></div>
<BaseHeader /> <!-- nem másoltuk, csak kiegészítettük -->
</template>
Multitenant SaaS-nál előbb-utóbb jön az igény: „a mi ügyfeleink a saját logónkkal lássák”. A layers erre kézenfekvő eszköznek tűnik — de a legtöbb esetben nem az a helyes válasz. A döntés egyetlen kérdésen múlik:
Build-időben ismered a tenantokat, vagy futásidőben?
| Futásidejű témázás | Layer tenantonként | |
|---|---|---|
| Mi különbözik | Színek, logó, szövegek, funkció-kapcsolók | Oldalak, komponensek, üzleti logika |
| Honnan jön | A tenant-rekordból, kérésenként (4. modul) | Build-időben, a layer fájljaiból |
| Deployok száma | Egy | Tenantonként egy |
| Új tenant felvétele | Egy adatbázis-sor | Új build és deploy |
| Skálázódás | Ezer tenantig gond nélkül | Néhány tíznél már fáj |
| Meddig lehet elmenni | CSS-változó, logó, feliratok, feature flag | Bármeddig — más oldalak, más folyamat |
// server/api/bootstrap.get.ts — a tenant publikus arculata (4. modul)
export default defineEventHandler((event) => {
const t = event.context.tenant
return t && {
slug: t.slug, name: t.name, logoUrl: t.logoUrl,
theme: t.theme, // { primary: '#0b7285', radius: '10px', … }
features: t.features, // { exportPdf: true, sso: false, … }
}
})
<!-- app/layouts/default.vue — a téma CSS-változókká válik -->
<script setup lang="ts">
const tenant = useTenant()
const themeVars = computed(() => ({
'--brand-primary': tenant.value?.theme.primary ?? '#00b96b',
'--brand-radius': tenant.value?.theme.radius ?? '8px',
}))
</script>
<template>
<div :style="themeVars">
<AppHeader :logo="tenant?.logoUrl" />
<slot />
</div>
</template>
// és a funkció-kapcsolók ugyanabból a forrásból
export const useFeature = (name: FeatureKey) => {
const tenant = useTenant()
return computed(() => tenant.value?.features?.[name] === true)
}
Akkor, amikor a különbség nem arculati, hanem funkcionális, és nem lehet feature flaggel lefedni:
repo/
├─ layers/
│ └─ base/ ← minden közös: app/, server/, komponensek
├─ apps/
│ ├─ saas/ ← a fő, sokbérlős alkalmazás (futásidejű témázással)
│ │ └─ nuxt.config.ts extends: ['../../layers/base']
│ └─ acme-enterprise/ ← EGY nagy ügyfél saját kiadása
│ ├─ app/pages/onboarding/… ← felülírja a base-ét
│ └─ nuxt.config.ts extends: ['../../layers/base']
server/utils egy base layerbe kerülhet.| Buktató | Mi történik | Mit tegyél |
|---|---|---|
Fordított extends-sorrend |
A base írja felül a testreszabást | Az első elem a legerősebb — a specifikus előre |
| „Melyik fájl nyer?” | Órákig keresed, miért nem érvényesül egy komponens | Nuxt DevTools → a komponens forrásának útvonala; illetve .nuxt/components.d.ts |
| Relatív útvonalak a layerben | A layer a saját gyökeréhez képest old fel, nem a tiédhez | createResolver(import.meta.url) a layer konfigjában, vagy #layers/… alias |
| Layer-függőségek | A layer node_modules-a nem automatikusan a tiéd |
npm-csomagként dependencies-ben; monorepóban workspace-szel |
| Növekvő build-idő | Minden layer fájljait be kell olvasni és feloldani | Mérd; ne csinálj öt layert három fájlért |
| Túl korai absztrakció | Van egy base layered egyetlen felhasználóval | Amíg nincs második fogyasztó, ne emelj ki semmit |
props és slot van; a layer ehhez nagyágyú.wrangler check startup kapuval), mi fogyasztja el, és mit tegyél, ha közelítesz hozzá.A modul viselkedést oszt meg programozottan (defineNuxtModule, middleware, composable, konfiguráció), és csak azt lehet testre szabni rajta, amit kiajánl. A layer fájlokat oszt meg konvenció szerint — maga is egy Nuxt-projektszerkezet —, és bármelyik fájlját felülírhatod azzal, hogy létrehozod ugyanazt nálad.
extends: ['../base', '../brand'] — miért nem működik ez úgy, ahogy vártad?
Mert az extends listában az első elem a legerősebb, nem az utolsó. Ebben a sorrendben a base írja felül a brand-et. A helyes sorrend: ['../brand', '../base'] — a specifikus előre. (A saját projekted fájljai amúgy is mindent felülírnak, és a ~~/layers/* ábécésorrendben, Z > A módon rendeződik.)
Futásidejű témázást, ha a különbség arculati vagy funkció-kapcsolóval lefedhető (szín, logó, feliratok, feature flag) — ez a tenantjaid túlnyomó része, és egyetlen deployjal, adatbázis-sorral kezelhető. Layert akkor, ha a különbség funkcionális: más oldalak, más folyamat, más validáció — jellemzően egy-két nagy ügyfélnél. „Futásidejű a soknak, layer a kevésnek.”
Mert minden hibajavítás N deployt jelent, a CI-idő lineárisan nő, és a Cloudflare gradual deployment / version-affinity mechanikája N-szeresére bonyolódik. Egy Worker + Cloudflare for SaaS custom hostnames mellett egy deploy, egy verzió, egy rollback van — a tenant-különbségek pedig futásidőben, a tenant-rekordból jönnek.
A layer srcDir-jére automatikusan készül alias, tehát importálhatod közvetlenül: import BaseHeader from '#layers/base/components/AppHeader.vue', és a saját komponensedben körbeveheted vagy kiegészítheted. Így nem kell lemásolnod a base-implementációt — ami a felülírások leggyakoribb, később fájdalmas hibája.
Ha csak témázásról van szó (CSS-változó elég), ha csak egy komponens tér el (props/slot), ha a különbség szerveroldali üzleti logika (feature flag vagy stratégia-minta), és főleg: ha még csak egy projekted van. A layer akkor kezd értéket adni, amikor legalább két fogyasztója van — előtte absztrakciót tervezel egyetlen példa alapján.