8. modulServer routes és h3 — Express-ből érkezve
Nuxt for Devs · 8. modul

Server routes és a h3 — Express-ből érkezve

Innentől a szerveroldalról szól a tananyag. A jó hír: a h3 mentális modellje szinte azonos az Expressével, csak a nevek mások és a fájlszerkezet veszi át a router szerepét. Ez a modul egy teljes Express → h3 megfeleltetést ad, hogy a meglévő API-d mintáit sorról sorra át tudd fordítani — plusz azt a hármat, ami tényleg máshogy működik.

8.1A fájl adja a végpontot — és a metódust is

server/
  api/
    projects/
      index.get.ts        →  GET    /api/projects
      index.post.ts       →  POST   /api/projects
      [id].get.ts         →  GET    /api/projects/:id
      [id].patch.ts       →  PATCH  /api/projects/:id
      [id].delete.ts      →  DELETE /api/projects/:id
      [id]/archive.post.ts→  POST   /api/projects/:id/archive
    health.ts             →  minden metódus a /api/health-en
  routes/
    sitemap.xml.get.ts    →  GET /sitemap.xml   ← NINCS /api prefix
    webhooks/
      stripe.post.ts      →  POST /webhooks/stripe
  middleware/
    1.request-id.ts          minden kérésnél fut
    2.tenant.ts
  utils/
    db.ts                    auto-import, csak szerveroldalon
    session.ts
  plugins/
    error-logging.ts         Nitro-szintű plugin (nem app-plugin!)

Két dolog, ami elsőre nem nyilvánvaló:

8.2Az event és a h3-eszköztár

Expressben két objektumot kapsz (req, res), és rajtuk hívsz metódusokat. h3-ban egy objektumot kapsz (event), és függvényeket hívsz rá. Ennyi a különbség — de emiatt más lesz a kód formája:

Express
router.patch('/:id', requireAuth, async (req, res, next) => {
  try {
    const { name } = req.body
    if (!name) return res.status(422).json({ error: 'name kötelező' })

    const updated = await db.project.update({
      where: { id: req.params.id, tenantId: req.session.tenantId },
      data: { name },
    })

    res.set('X-Request-Id', req.id)
    res.json(updated)
  } catch (err) { next(err) }
})
h3 — server/api/projects/[id].patch.ts
export default defineEventHandler(async (event) => {
  const { tenantId } = await requireSession(event)          // server/utils, auto-import
  const id   = getRouterParam(event, 'id')!
  const body = await readValidatedBody(event, updateProjectSchema.parse)

  const updated = await useDb(event).update(projects)
    .set({ name: body.name })
    .where(and(eq(projects.id, id), eq(projects.tenantId, tenantId)))
    .returning()

  if (!updated.length) throw createError({ statusCode: 404, statusMessage: 'Nincs ilyen projekt' })

  setResponseHeader(event, 'X-Request-Id', event.context.requestId)
  return toProjectDto(updated[0])          // a visszatérési érték a válasz teste
})

Négy szerkezeti különbség, amit érdemes rögtön beépíteni a reflexbe:

  1. Nincs next(err) és nincs try/catch boilerplate. Dobsz egy createError-t, és a Nitro elintézi. A nem várt kivételeket is elkapja és 500-ra fordítja.
  2. A visszatérési érték a válasz. Objektumból JSON lesz, stringből szöveg/HTML, ReadableStream-ből stream. Nincs res.json().
  3. Nincs middleware-lánc a route-on. Az requireAuth nem middleware, hanem egy függvény, amit meghívsz. Ez explicitebb, és látszik a kódban, hogy mi fut le.
  4. Egy fájl = egy végpont. Nincs 400 soros routes/projects.js.

A teljes megfeleltetés

Expressh3 / Nitro
app.get('/x', h)server/api/x.get.ts
req.params.idgetRouterParam(event, 'id')
req.querygetQuery(event)
req.body (express.json())await readBody(event)
(nyers törzs)await readRawBody(event)
req.headers['x']getHeader(event, 'x')
req.cookies.x (cookie-parser)getCookie(event, 'x')
res.cookie(n, v, o)setCookie(event, n, v, o)
res.clearCookie(n)deleteCookie(event, n)
res.json(obj)return obj
res.status(201).json(obj)setResponseStatus(event, 201); return obj
res.set('X', v)setResponseHeader(event, 'X', v)
res.redirect(302, url)return sendRedirect(event, url, 302)
next(err) / throwthrow createError({ statusCode, statusMessage })
app.use(mw)server/middleware/*.ts
multerawait readMultipartFormData(event)
cors()routeRules: { '/api/**': { cors: true } }
express.static('public')public/ → Workers static assets
req.ipgetRequestIP(event) — CF-en lásd lentebb
req.protocol + req.get('host')getRequestURL(event) / getRequestHost(event)
res.localsevent.context
Két Cloudflare-specifikum a táblázathoz. ① A valódi kliens-IP-t a Cloudflare a CF-Connecting-IP fejlécben adja — ha IP-re építesz (rate limit, audit napló), ezt olvasd, ne a x-forwarded-for-t. ② A bindingok és az ExecutionContext az event.context.cloudflare alatt vannak: event.context.cloudflare.env.MY_KV, illetve event.context.cloudflare.context.waitUntil(promise). Ez utóbbi azért fontos, mert így tudsz „a válasz után is fusson” munkát indítani anélkül, hogy a felhasználó várna rá — a Cloudflare-anyag 2. és 7. modulja ezt részletezi.

8.3Validáció

Expressben ezt jellemzően middleware-rel oldottad meg (express-validator, celebrate). h3-ban egy függvényhívás, és a típus is jön vele:

// shared/schemas/project.ts — mindkét oldal használhatja (2. modul: shared/)
import { z } from 'zod'

export const createProjectSchema = z.object({
  name:        z.string().min(1).max(120),
  description: z.string().max(2000).optional(),
  status:      z.enum(['active', 'archived']).default('active'),
})
export type CreateProject = z.infer<typeof createProjectSchema>
// server/api/projects/index.post.ts
export default defineEventHandler(async (event) => {
  const { tenantId, userId } = await requireSession(event)

  // hiba esetén automatikusan 400-at dob — a body típusos lesz
  const body = await readValidatedBody(event, createProjectSchema.parse)

  // query-re ugyanez: getValidatedQuery(event, listQuerySchema.parse)

  const [row] = await useDb(event).insert(projects)
    .values({ ...body, tenantId, createdBy: userId })
    .returning()

  setResponseStatus(event, 201)
  return toProjectDto(row)
})
Ha szebb hibaüzenetet akarsz (mezőnkénti hibalista az űrlaphoz), akkor safeParse-szal magad formázd:
const raw = await readBody(event)
const parsed = createProjectSchema.safeParse(raw)
if (!parsed.success) {
  throw createError({
    statusCode: 422,
    statusMessage: 'Érvénytelen adat',
    data: { errors: parsed.error.flatten().fieldErrors },
  })
}
A kliens ezt az 5. modulban látott módon kapja meg: catch (e) { e.data.errors }.

8.4Hibakezelés

throw createError({
  statusCode:    403,
  statusMessage: 'Nincs jogosultság',   // ✓ a kliens LÁTJA
  data:          { needed: 'admin' },      // ✓ a kliens LÁTJA
  message:       'user 42 lacks role on tenant 7', // belső — élesben nem megy ki
  fatal:         false,                  // true → az egész oldal a hibaoldalra megy
})
Emlékeztető a 4. modulból, mert itt követhető el: multitenant rendszerben a 403 önmagában információ — elárulja, hogy az erőforrás létezik. Ha a felhasználó nem az adott tenant tagja, adj 404-et. A 403-at tartsd fenn arra, amikor a tenanton belül nincs meg a szerepköre.

Globális hibakezelés

// server/plugins/errors.ts — Nitro-plugin, nem app-plugin
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('error', (error, { event }) => {
    // a 4xx-eket ne zajongd tele — csak a valódi hibákat
    const code = (error as any).statusCode ?? 500
    if (code < 500) return

    console.error('[hiba]', {
      requestId: event?.context.requestId,
      tenant:    event?.context.tenant?.slug,
      path:      event?.path,
      message:   (error as Error).message,
    })
  })
})
Ez a te logolási belépési pontod Workers-en. A console.error kimenetét a Workers Logs gyűjti (Cloudflare-anyag 9. modul), és ha itt strukturáltan írsz — request-azonosító, tenant, útvonal —, akkor a hibakeresés kereshető lesz. Amit itt ne írj ki: személyes adatot és teljes kérés-törzset.

8.5Server middleware

A server/middleware/ alatti fájlok minden szerverre érkező kérésnél lefutnak — az SSR-nél és a /api-hívásoknál is. A sorrendet a fájlnevek adják (ábécésorrend), ezért szokás számmal prefixálni:

// server/middleware/1.request-id.ts
export default defineEventHandler((event) => {
  event.context.requestId =
    getHeader(event, 'cf-ray') ?? crypto.randomUUID()
})
// server/middleware/2.tenant.ts  (4. modul)
export default defineEventHandler(async (event) => {
  event.context.tenant = await resolveTenant(event)
})
A legfontosabb szabály: a server middleware ne adjon vissza értéket, hacsak nem akarod megszakítani a kérést. Expressben a next() hiánya az, ami elakasztja a láncot; h3-ban fordítva: ha visszaadsz bármit (akár egy véletlen return true-t), az lesz a válasz, és a tényleges handler már le sem fut. Ez egy csendes, nehezen megtalálható hiba — figyelj rá, hogy a middleware-eid ne térjenek vissza semmivel.
És egy teljesítmény-megjegyzés: a server middleware minden kérésnél fut, beleértve a prerenderelt oldalakat kiszolgáló asseteket megelőző kéréseket és a statikus fájlokat is (ha a Workeren mennek át). Ha adatbázist hívsz benne — például tenant-feloldásra —, azt minden kérés megfizeti CPU-időben. Érdemes cache-elni (KV, 7. modul) vagy csak akkor futtatni, ha az útvonal tényleg igényli.

8.6Szervezés: server/utils és a rétegek

A server/utils/ auto-importált és csak szerveroldalon létezik — itt lakik minden, ami nem HTTP-kezelés. Érdemes három réteget elkülöníteni, még ha kicsi is a projekt:

RétegHolMit csinál — és mit NEM
Handlerserver/api/**HTTP: olvas, validál, státuszkódot ad, DTO-t formáz. Nincs benne üzleti logika.
Serviceserver/utils/services/Üzleti szabályok, tranzakciók, több lépéses műveletek. Nem ismer event-et, csak paramétereket.
Adathozzáférésserver/utils/db.ts, repókLekérdezések. A tenant-szűrés itt kötelező, nem opció.
// server/api/projects/index.post.ts — a handler vékony
export default defineEventHandler(async (event) => {
  const { tenantId, userId } = await requireSession(event)
  const body = await readValidatedBody(event, createProjectSchema.parse)

  const project = await createProject(useDb(event), { tenantId, userId, ...body })

  setResponseStatus(event, 201)
  return toProjectDto(project)
})
Miért érdemes ez, ha kicsi a projekt? Mert a 9. modulban dönteni fogsz arról, hogy a Nitro váltja-e ki teljesen az Express API-dat, vagy marad egy külön API. Ha a service-réteg nem ismeri az event-et, akkor bármelyik irányba mozgatható: hívhatja egy Nitro-handler, egy Express-controller, egy queue-consumer vagy egy cron. Ha viszont az üzleti logika beleragad a handlerbe, akkor a döntésed visszafordíthatatlan lesz.

8.7Három minta, ami biztosan kelleni fog

① Webhook: a nyers törzs csapdája

// server/routes/webhooks/stripe.post.ts
export default defineEventHandler(async (event) => {
  // FONTOS: nyers törzs kell az aláírás-ellenőrzéshez — a readBody() már JSON-t ad,
  // és az újraszerializálva NEM egyezik bájtra az eredetivel
  const raw = await readRawBody(event, 'utf8')
  const sig = getHeader(event, 'stripe-signature')

  if (!raw || !sig || !(await verifySignature(raw, sig, useRuntimeConfig(event).stripeSecret))) {
    throw createError({ statusCode: 400, statusMessage: 'Érvénytelen aláírás' })
  }

  const payload = JSON.parse(raw)

  // gyors 200, a munka a háttérben — Workers-en waitUntil vagy Queue
  event.context.cloudflare.context.waitUntil(handleStripeEvent(payload))
  return { received: true }
})

② Fájlfeltöltés R2-be

// server/api/uploads.post.ts
export default defineEventHandler(async (event) => {
  const { tenantId } = await requireSession(event)
  const parts = await readMultipartFormData(event)
  const file  = parts?.find(p => p.name === 'file')

  if (!file) throw createError({ statusCode: 422, statusMessage: 'Hiányzó fájl' })
  if (file.data.length > 10_000_000) {
    throw createError({ statusCode: 413, statusMessage: 'Túl nagy fájl' })
  }

  // a kulcs ELSŐ szegmense a tenant — így a jogosultság az útból is látszik
  const key = `${tenantId}/${crypto.randomUUID()}-${sanitize(file.filename!)}`
  await event.context.cloudflare.env.BUCKET.put(key, file.data, {
    httpMetadata: { contentType: file.type ?? 'application/octet-stream' },
  })

  return { key }
})
Nagy fájloknál ne ezt csináld. A Workers memóriakorlátja 128 MB, és a readMultipartFormData a memóriába olvas. Néhány MB-os képnél rendben van; nagyobbnál presigned URL a helyes minta: a szerver csak egy aláírt feltöltési URL-t ad, a böngésző pedig közvetlenül az R2-be tölt fel. Ez a Cloudflare-anyag 6. moduljának témája.

③ Streamelt válasz

// server/api/export.get.ts — nagy CSV, nem a memóriából
export default defineEventHandler(async (event) => {
  const { tenantId } = await requireSession(event)

  setResponseHeader(event, 'content-type', 'text/csv; charset=utf-8')
  setResponseHeader(event, 'content-disposition', 'attachment; filename="export.csv"')

  const stream = new ReadableStream({
    async start(controller) {
      const enc = new TextEncoder()
      controller.enqueue(enc.encode('id,nev,statusz\n'))
      for await (const row of iterateProjects(useDb(event), tenantId)) {
        controller.enqueue(enc.encode(`${row.id},${csv(row.name)},${row.status}\n`))
      }
      controller.close()
    },
  })

  return stream          // a ReadableStream visszaadható válaszként
})
Miért fontos ez Workers-en? Mert a 128 MB memóriakorlát miatt a „gyűjtsük össze egy tömbbe, aztán küldjük el” minta nagy exportnál elszáll — a Cloudflare best practices kifejezetten a stream alapú válaszokat ajánlja. Plusz a TTFB is jobb lesz: az első bájt azonnal megy.

8.8Ami a Nuxt 5-tel változni fog

A h3 következő major verziója (v2) a Nitro v3-mal, vagyis a Nuxt 5-tel érkezik, és néhány segédfüggvény neve, illetve a belső Request/Response-kezelés változik. Amit most tehetsz, hogy ne fájjon:

Mi jön ezután? A 9. modul az elágazás: full-stack Nuxt vagy külön API? Most már van elképzelésed arról, hogyan néz ki egy Nitro-végpont, tehát össze tudjuk vetni a két utat őszintén — a te multitenant SaaS-odra vetítve, csapatmérettel, migrációs kockázattal és üzemeltetési költséggel együtt.

8.9Ellenőrizd magad

  1. Mikor teszel valamit a server/routes/ alá a server/api/ helyett?
    Válasz

    Amikor a végpont nem a saját frontendednek szól, és az URL-jét nem te választod meg szabadon: webhookok, sitemap.xml, robots.txt, OAuth-callback, jól ismert (/.well-known/…) útvonalak. A server/api/ automatikusan /api prefixet kap, a server/routes/ nem.

  2. Egy server middleware-ed után a végpontod már nem fut le. Mi a legvalószínűbb ok?
    Válasz

    Hogy a middleware visszaad valamit. h3-ban a middleware visszatérési értéke lesz a válasz, és a lánc megszakad. Expressben a next() elfelejtése akasztja el a láncot; itt fordítva: a middleware ne térjen vissza semmivel, ha folytatni akarod.

  3. Miért nem használhatod a readBody()-t webhook-aláírás ellenőrzéséhez?
    Válasz

    Mert a readBody() már feldolgozott (parse-olt) adatot ad, és ha újra szerializálod, a bájtok nem feltétlenül egyeznek az eredetivel — a kulcsok sorrendje, a szóközök, a számformátum eltérhet. Az aláírás viszont a nyers bájtokra készült. Ezért readRawBody(event) kell, és a JSON-t abból parse-olod.

  4. Hogyan érsz el egy KV-namespace-t egy server route-ból, és hogyan indítasz „a válasz után is fusson” munkát?
    Válasz

    A bindingot az event.context.cloudflare.env.MY_KV-n keresztül, a háttérmunkát pedig az event.context.cloudflare.context.waitUntil(promise)-szal — így a válasz azonnal elmegy, a promise viszont még lefut. Tartós vagy több lépéses munkára inkább Queue vagy Workflow való (Cloudflare-anyag 7. modul).

  5. Miért érdemes az üzleti logikát olyan service-függvénybe tenni, ami nem ismeri az event-et?
    Válasz

    Mert így ugyanaz a logika hívható HTTP-handlerből, queue-consumerből, cronból — és ha a 9. modulban úgy döntesz, hogy mégis marad külön API, akkor a logika mozgatható. Ha az üzleti szabályok a handlerbe ragadnak, az architekturális döntésed visszafordíthatatlanná válik.

  6. Miért ad a 403 többet a támadónak, mint a 404, multitenant rendszerben?
    Válasz

    Mert a 403 megerősíti, hogy az erőforrás létezik — csak épp nincs hozzá jogod. Ebből azonosítók és nagyságrendek derülnek ki. Ha a felhasználó nem tagja az adott tenantnak, adj 404-et; a 403-at tartsd fenn arra az esetre, amikor a tenanton belül nincs meg a szerepköre.

Előző7. modul — Renderelési módok és routeRules Következő 9. modul — A nagy döntés: full-stack vagy BFF