Skip to content

Building blocks

Events

The kit fires typed events when something happens: a payment settles, an account is purged, someone joins an organization. You react to one by adding a file to server/listeners/(Licensees only), without editing the code that fired it. You can declare and fire events of your own the same way.

How it works

  1. Kit code calls emit(fromRequest(event), name, payload) after the write the event describes has landed.
  2. When Nuxt starts, modules/listeners-registry.ts(Licensees only) collects every file in server/listeners/, and server/plugins/events.ts(Licensees only) registers each on Nitro's hooks.
  3. Each listener runs in its own try/catch. A throw is logged with the event and the listener's file name, then swallowed. It can't stop another listener or fail the request, and emit never rejects.
  4. A deferred listener, the default, runs after the response through waitUntil. A sync listener is awaited inside emit.

Nothing on the bus is stored or retried, and the bus never logs a payload.

Add a listener

Create server/listeners/crm-contact.ts and default-export defineListener:

import { defineListener } from '#server/events/types'

export default defineListener({
  event: 'auth:emailVerified',

  async handle({ userId, email }) {
    await $fetch('https://crm.example.com/contacts', {
      method: 'POST',
      body: { externalId: userId, email },
    })
  },
})

The payload type follows from event, so there is no type argument and no list to register the file in. The file name is the listener's id and must be lowercase kebab-case with an optional numeric prefix (00-billing-email), or the build fails. The registry is generated when Nuxt starts, so restart pnpm dev after you add, rename or delete a listener. In development the server prints each one as it registers:

[events] crm-contact → auth:emailVerified (deferred)

A listener's failure is never retried. When the work has to survive an outage, have the listener enqueue() a job instead (Background jobs).

What a listener gets

The second argument's event is built from the Worker's bindings and carries nothing else. useDrizzle(event), useRuntimeConfig(event) and enqueue(event, …) work; headers, cookies and the session don't exist on it. Anything a listener needs from the original request has to be in the payload.

Choose a mode

A listener is deferred unless it says otherwise. Deferred work is handed to waitUntil, so the response doesn't wait for it, and it is lost if the isolate is evicted or runs out of time after the response. A sync listener is awaited inside emit, so the request that fired the event waits for it:

export default defineListener({
  event: 'org:member:added',
  mode: 'sync',

  async handle({ orgId, userId }, { event }) {
    await useDrizzle(event)
      .insert(tables.memberProfiles)
      .values({ orgId, userId })
      .onConflictDoNothing()
  },
})

Use sync only when the request that fired the event reads the listener's result back, as the members page would read memberProfiles here.

Listeners for one event run one after another in file-name order, sorted numerically, so 00- runs first and 10- sorts after 2-. That order holds within a mode only: the deferred batch can start before a sync listener whose name sorts earlier. Nothing orders listeners across different events.

Fire your own event

Declare it by augmenting BusEvents in server/events/custom.d.ts, a file the template never writes:

declare module '#server/events/types' {
  interface BusEvents {
    'app:project:archived': { projectId: number; userId: number }
  }
}

export {}

The export {} makes the file a module. Without it the declare module replaces the kit's types instead of extending them, and every emit and listener loses its types.

Then fire it after the write lands:

import { emit, fromRequest } from '#server/events/emit'

await useDrizzle(event)
  .update(tables.projects)
  .set({ archivedAt: new Date() })
  .where(eq(tables.projects.id, projectId))

await emit(fromRequest(event), 'app:project:archived', { projectId, userId })
  • Emit after a transaction resolves, never inside its callback. A listener could act on a write that rolls back, and a retried transaction would fire the event twice.
  • Keep payloads plain JSON: ids, strings, numbers. No H3Event, binding or function.
  • Name events in the past tense (archived, not archive). A listener can't refuse an event, and an imperative name suggests it can.

Register a listener in code

For a listener that needs a closure, register it from a Nitro plugin with onEvent. It always runs deferred:

import { onEvent } from '#server/events/emit'

export default defineNitroPlugin(() => {
  const endpoint = 'https://crm.example.com/contacts'

  onEvent('account:purged', async ({ userId }) => {
    await $fetch(`${endpoint}/${userId}`, { method: 'DELETE' })
  })
})

onPaymentSettled(nitro, handler) from #server/payments/settled does the same for payments:settled and hands the handler the payload with event merged in. server/plugins/notify-payments.ts(Licensees only) uses it.

The catalogue

The payload types are in server/events/catalogue.ts(Licensees only).

EventFiredPayload
payments:settledThe payments webhook, once per provider event it appliesprovider, normalized, rawBody, userId, orgId, eventRowId, country, retried
payments:refundedThe payments webhook, when a refund is appliedprovider, userId, eventRowId, plan, amountMinor, currency
account:deletionScheduledDELETE /api/auth/account, with a grace period or withoutuserId, purgeAt, subscriptions
account:purgedThe end of purgeAccount, after the user row is goneuserId
auth:registeredA new account from a password, magic-link or OAuth signupuserId, email, method, verified
auth:emailVerifiedAn unproven account proves an address, or staff mark it as verifieduserId, email
auth:emailChangedAn account moves to a new address, by its link or by a staff edituserId, email, previousEmail
storage:uploadedputObject, after the object is storedpurpose, key, ownerId, bytes, format
storage:deleteddeleteObject, only when the delete succeededpurpose, key, ownerId
org:*The tenancy services (Organizations)See the catalogue

The eight org:* events are org:created, org:deleted, org:member:added, org:member:removed, org:member:roleChanged, org:invitation:created, org:invitation:accepted and org:invitation:revoked.

A few payloads need care:

  • auth:registered fires while a password signup's address is still unproven; verified says which case you have. Provision anything real on auth:emailVerified, which fires once per proof, whether a verify link, a sign-in link or a completed password reset made it. An account created by guest checkout fires no auth:registered; it fires auth:emailVerified the first time its owner signs in by email link.
  • payments:refunded sits next to payments:settled on purpose: take back there whatever you grant on settle.
  • account:purged carries only the id, because the address is erased. Use it for data the database cascades can't reach, such as a record in an external CRM.
  • org:invitation:created carries the invitation's plaintext token, the only place it exists. Don't log it or store it anywhere readable.

Shipped listeners

server/listeners/ belongs to your app, and an empty directory is fine. The kit ships three files there:

ListenerOnDelete it and
00-org-invitation-mail.tsorg:invitation:createdInvitations are still created, but no mail is sent
00-billing-email.tsauth:emailChangedA new address no longer reaches the payment provider's customer
00-cancel-billing.tsaccount:deletionScheduledA deleted account's subscriptions keep billing at the provider

Good to know

  • Events have no config keys. What fires depends on which parts of the kit are on in starter.config.ts(Licensees only).
  • test/unit/events-catalogue.test.ts(Licensees only) reads declared events from catalogue.ts only, so its check for undeclared emits fails on your first emit of an event from custom.d.ts. Add your event names to that check.
  • In unit tests, installEvents() from test/helpers/events.ts(Licensees only) records every emit and holds deferred work until you await settled(). Assert after that, or the listener hasn't run yet.
  • storage:uploaded fires inside putObject, before the upload route writes its row. When that write fails, the route deletes the object and storage:deleted follows, so undo on the second whatever you did on the first.

A link with a lock opens the kit’s private repository, which every licensee can read.