Contributing

Create a feature layer

Edit page
Author a local Nuxt layer with public portal contracts, feature contributions, and its own migration provider.

A local feature is an ordinary Nuxt layer plus a PortalLayerManifest. Keep it self-contained and access the platform only through documented package exports.

Register the provider

portal.config.ts
import { definePortalConfig, localPortalLayer } from '@nuxt-customer-portal/kit'

export default definePortalConfig({
  layers: [
    '@nuxt-customer-portal/preset',
    localPortalLayer({
      id: 'notes',
      source: './layers/notes',
      schema: './layers/notes/server/db/schema',
      migrations: './layers/notes/migrations',
      dependsOn: ['core']
    })
  ]
})

The source needs nuxt.config.ts with a stable $meta.name. Resolve local CSS or assets from import.meta.url, not the host root.

Define the feature

layers/notes/shared/feature.ts
import type { PortalFeatureDefinition } from '@nuxt-customer-portal/core/feature'

export type NoteAction = 'view' | 'manage'

export const notesFeature: PortalFeatureDefinition<NoteAction> = {
  id: 'notes',
  modules: [{
    id: 'notes',
    labelKey: 'notes.title',
    to: '/notes',
    routePrefixes: ['/notes'],
    audiences: ['authenticated']
  }],
  dashboardWidgets: [{
    id: 'notes-overview',
    component: 'NotesDashboardWidget',
    area: 'aside',
    size: 'full'
  }],
  policy: {
    owner: ['view', 'manage'],
    admin: ['view', 'manage'],
    member: ['view']
  }
}

Register it from a client plugin with usePortalFeatures().register(notesFeature). Component names are serializable strings resolved by the UI package; contracts do not depend on Nuxt UI or Vue component types.

Because Nuxt cannot discover components that appear only as runtime strings, register the layer's components globally. Nuxt keeps global components lazy, so they remain split from the main application bundle:

nuxt.config.ts
import { fileURLToPath } from 'node:url'

export default defineNuxtConfig({
  components: [{
    path: fileURLToPath(new URL('./app/components', import.meta.url)),
    global: true
  }]
})

Use a surface contribution when another screen should host an optional panel:

surfaces: [{
  id: 'notes-organization-panel',
  surface: 'administration.organization.detail',
  component: 'NotesOrganizationPanel',
  order: 40
}]

Administration discovers the contribution without importing your package.

Author tenant-safe routes

layers/notes/server/api/notes/index.get.ts
import { requireFeatureAccess } from '@nuxt-customer-portal/core/server'
import { definePortalRouteMeta } from '@nuxt-customer-portal/core/route-meta'
import { notesFeature } from '../../../shared/feature'

defineRouteMeta(definePortalRouteMeta({
  operationId: 'notesListGet',
  query: notesListQuerySchema
}))

export default defineEventHandler(async (event) => {
  const context = await requireFeatureAccess(event, notesFeature.policy, 'view')
  return listNotes(context.organizationId)
})

The route-owning package owns its Zod validation and OpenAPI metadata. Every query must derive tenant scope from the authenticated context; never trust an organization ID supplied by a caller.

Own schema and migrations

Use a package-owned PostgreSQL schema such as pgSchema('notes'). Extend official data with foreign keys from host-owned tables; do not add columns to official tables unless you take over that provider's migration stream.

Generate a local migration with:

npx nuxt-customer-portal db generate --provider notes

Commit schema and migration together. Test locale parity, policy behavior, unauthorized and cross-organization requests, fresh migration, repeat migration, failure rollback, and a build both with and without the local layer.