Building a starter kit that powers 400+ event registrations
31 March 2026
Every project started by copying the previous one. Find the most recent registration app that was similar enough, duplicate the repo, rip out the client-specific parts, and start building. This worked for the first dozen projects. By the time we’d shipped fifty, bugs fixed in one project hadn’t made it back to the others, new developers spent their first week figuring out which version of the boilerplate they were looking at, and I was spending more time adapting old code than writing new features.
At AirLST we build event registration platforms. Every client event needs the same infrastructure: authentication, multi-step forms, a registration state machine, dynamic form fields driven by server configuration, companion guest management, capacity tracking, and an API layer. The specifics change — this client needs hotel bookings, that one needs dietary preferences, another needs Stripe payments — but the foundation is always the same.
So I built a starter kit. Not a framework, not a library — a Nuxt 4 project template with opinions about how event registration apps should work. Client-specific customisation layers on top without modifying the core. This is the second generation; the first was a Nuxt 2 boilerplate that served the same purpose before Vue 3 and the Composition API existed.
Dynamic option mapping with capacity tracking
The hardest pattern to extract was form field generation. Every event has different registration fields, and those fields come from the server with options that have capacity limits. A workshop with 50 seats shows “[12 remaining]” in the dropdown. When a companion guest selects the same option, the count drops for both the main guest and all companions in real time — before anything is saved to the server.
The useForm composable maps server field definitions into UI-ready option arrays:
function mapOptions({ field, showRemaining = false, event, form, user }) {
const fieldDef = event.value.fieldDefinitions[field]
return fieldDef.options.map((option) => {
let label = option.label[currentLocale.value]
let enabled = true
const savedValue = get(user.value, `fields.${field}`, null)
const saved = savedValue === option.key
if (option.limit !== null) {
enabled = saved || !!option.remainingLimit
if (showRemaining && option.remainingLimit >= 1) {
label = `${label} [${option.remainingLimit} remaining]`
}
}
const selected = option.key === get(form.value, `fields.${field}`, false)
return { label, value: option.key, remaining: option.remainingLimit || 0, disabled: !enabled, saved, selected }
})
}
The saved flag matters. If the user previously selected an option that’s now full, they should still see it as selected — they already have the spot. But new users see it as disabled. Without tracking saved vs selected, returning users would lose their selection when capacity fills up.
When a main guest and their companions share the same limited options, a group mapper aggregates all unsaved selections across all forms and adjusts remaining counts:
function mapGroupOptions(mainSelections, companionSelections) {
const allSelections = [mainSelections.value, ...companionSelections.value].flat()
const pendingCounts = {}
allSelections.forEach((opt) => {
if (opt.selected && !opt.saved) {
pendingCounts[opt.value] = (pendingCounts[opt.value] || 0) + 1
}
})
function adjust(options) {
return options.map((opt) => {
const baseLabel = opt.label.split(' [')[0]
const remaining = Math.max((opt.remaining ?? opt.limit ?? 0) - (pendingCounts[opt.value] || 0), 0)
return {
...opt,
remaining,
disabled: remaining === 0,
label: remaining >= 1 ? `${baseLabel} [${remaining} remaining]` : baseLabel,
}
})
}
return { main: adjust(mainSelections.value), companions: companionSelections.value.map(adjust) }
}
This runs reactively. Every time someone changes a selection, the remaining counts update across all forms on the page.
Guest model factory
Every registration has a main guest, optionally with companions and recommendations. Before anything hits the server, these are tracked locally with UUIDs:
function createGuest() {
const guest = cloneDeep(guestModel)
guest.id = `local-${uuidv4()}`
guest.role = 'main'
guest.companions = []
guest.recommendations = []
return guest
}
function createCompanion() {
const companion = cloneDeep(guestModel)
companion.id = `local-${uuidv4()}`
companion.role = 'companion'
return companion
}
The local- prefix makes it obvious which records exist only on the client. Server-side records have numeric IDs. The form submission strips local IDs before posting — the server assigns real ones on creation.
View-based state machine
Registration isn’t a linear form. A user might be in “start” (new visitor), “confirming” (filling out the form), “confirmed” (done), “cancelling” (wants to cancel), or “cancelled.” The view component renders the current state as a named slot:
<div ref="viewAnchor" class="scroll-mt-40">
<div :key="currentView">
<slot :name="currentView" />
</div>
</div>
Client projects declare their views:
<BaseViews :views="['start', 'confirming', 'confirmed', 'cancelled']">
<template #start><RegistrationStart /></template>
<template #confirming><RegistrationConfirming /></template>
<template #confirmed><RegistrationConfirmed /></template>
<template #cancelled><RegistrationCancelled /></template>
</BaseViews>
The :key="currentView" forces a re-render on view change, and a watcher auto-scrolls to the view anchor. View transitions feel like page navigation without actually navigating.
What the starter kit is not
It’s not a framework. There’s no plugin system, no configuration DSL, no abstraction over Nuxt’s own APIs. It’s a project template with composables, stores, and base components that encode decisions I’ve made hundreds of times: how auth works, how forms map server fields, how capacity tracking aggregates across guests, how registration state transitions.
New projects clone it, add their client-specific views and fields, and ship. The core patterns stay consistent across 400+ projects. When I fix a bug in useForm, I fix it in the starter kit and new projects get it automatically. Existing projects can pull the fix if they want, but they’re not forced to — they’re forks, not dependents.
A starter kit is an opinion about what every project in a domain needs. After five years in event registration, I’m fairly confident about the opinion.