Getting started

UX Patterns

How screens are built so the app stays predictable as modules are added - the layouts, list and detail pages, forms, destructive actions and text conventions.

Every module that ships screens follows the same handful of patterns, so the app feels like one product no matter which modules are installed. The building blocks live in @frigear-nu/ui; the admin shell is @frigear-nu/admin and the account shell is @frigear-nu/account. This page is the rulebook: read it before adding a page, and follow it when reviewing one.

These are patterns, not a theme. Colours, fonts and radius stay whatever Nuxt UI (and your app's app.config.ts) says; the shared components only add structure and spacing.

Layout and navigation

There are three areas, each with its own layout. A page picks its area by path, not by importing a layout.

AreaPathsLayoutWhat it is
Public siteeverything elsedefault from the base layerHeader and footer. Public pages use UContainer > UPage.
Admin back office/admin/**admin (Admin)Dashboard shell: a collapsible sidebar with grouped navigation, a ⌘K palette and the account menu in the sidebar footer.
Account area/account/**account (Account)Inside the site layout, with a persistent left account navigation.

Admin back office

The sidebar navigation is grouped, and each group reads its own menu path:

GroupMenu pathPut here
Overviewadmin.nav.overviewThe dashboard.
Manageadmin.nav (root)The things staff manage every day: users, events, forms.
Settingsadmin.nav.settingsConfiguration: OAuth applications, integrations.

New features go into one of these groups. A new group needs a reason, such as a whole new class of screens that does not read as "managing" or "settings". The ⌘K palette lists the same groups, so a page that is in the navigation is also searchable. Colour mode is not in the chrome: it lives in the account menu under Appearance.

Account area

/account/** renders in the account layout: the site header and footer stay, and the page body gets a left navigation (UPageAside) built from the account menu. On narrow screens the navigation becomes a tab strip above the content. /account itself is an overview: a summary card, the account.overview widget area and shortcuts to the other pages. A module adds a page by registering the route and an item in the account menu; it never draws its own account navigation.

Public site

Public pages (events, forms, content) use UContainer > UPage with UPageHeader for the title and UPageSection for bands. App pages, meaning everything in the admin and account areas, use PageHeading instead. Do not mix them: the public header is a marketing-sized hero, PageHeading is a working title with actions.

Pages

Lists

A top-level entity gets a list page and a detail page.

  • PageHeading with the entity's name and the primary action ("Add user", "New event") in #actions.
  • PageToolbar with search and filters once the list is longer than about eight rows; leave it out for short lists.
  • A UTable inside TableCard, with tableUi() for the shared table styling: small muted headers, compact rows on desktop, taller rows on mobile. Rows are clickable and open the detail page. Long lists add sticky and tableUi({}, { sticky: true }) so the header stays in view while the table scrolls inside its card.
  • A RowMenu in the last column for the row's actions (see Forms and actions).
  • Sortable columns use useTableSort().header(id, label): a ghost button that cycles ascending, descending, unsorted.
  • TablePagination goes in the card's #footer ("21–40 of 132", rows per page, pages). Server-paginated lists keep page, page size, search, sort and filters in the URL with useTableQuery, so a filtered list can be linked and survives a reload.
  • An empty list is a UEmpty (empty states), not an empty table.

Loading never blanks a list that is already on screen. TableCard :loading shows skeleton rows on the first load only; every load after that (the next page, a new sort, a search) passes :refreshing, which keeps the current rows and dims them until the new ones arrive.

Cells follow the same conventions everywhere:

  • The first column is a TableIdentity: the name, with a muted second line (an email, a slug) and an optional avatar or icon. Badges that belong to the name go in its #badges slot.
  • Numbers are right-aligned with tabular figures (meta: numericColumn); dates use useFormat() in a tabular-nums span.
  • A missing value is emptyCell(), a dimmed dash, not an empty cell.
  • Status badges are variant="subtle" with statusColor.
<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'

const t = useModuleT('events')
const format = useFormat()
const actions = useEventActions()

const { page, pageSize, search, sort: sortState, query } = useTableQuery({ sort: { id: 'startsAt', desc: true } })
const { header } = useTableSort(null, sortState)
const { data, status } = await useFetch('/api/events', { query, default: () => ({ items: [], total: 0 }) })
const pending = computed(() => status.value === 'pending')

const TableIdentity = resolveComponent('TableIdentity')
const RowMenu = resolveComponent('RowMenu')
const columns: TableColumn<EventRow>[] = [
  {
    accessorKey: 'title',
    header: header('title', t('admin.title', 'Title')),
    cell: ({ row }) => h(TableIdentity, { title: row.original.title, description: row.original.location }),
  },
  { accessorKey: 'startsAt', header: header('startsAt', t('admin.starts', 'Starts')) },
  { accessorKey: 'capacity', header: t('admin.capacity', 'Capacity'), meta: numericColumn },
  { id: 'actions', cell: ({ row }) => h(RowMenu, { items: actions.menu(row.original), subject: row.original.title }) },
]
</script>

<template>
  <div class="flex flex-col gap-4">
    <PageHeading :title="t('admin.events', 'Events')">
      <template #actions>
        <UButton icon="i-lucide-plus" :label="t('admin.create', 'New event')" @click="actions.create()" />
      </template>
    </PageHeading>

    <PageToolbar>
      <UInput v-model="search" icon="i-lucide-search" :placeholder="t('admin.search', 'Search events')" />
    </PageToolbar>

    <TableCard :loading="pending && !data.items.length" :refreshing="pending && !!data.items.length">
      <UTable
        :data="data.items"
        :columns="columns"
        :ui="tableUi({}, { sticky: true })"
        sticky
        @select="(_, row) => navigateTo(`/admin/events/${row.original.id}`)"
      >
        <template #startsAt-cell="{ row }">
          <span class="tabular-nums">{{ format.dateTime(row.original.startsAt) }}</span>
        </template>
        <template #empty>
          <UEmpty variant="naked" icon="i-lucide-calendar" :title="t('admin.empty', 'No events yet')">
            <template #actions>
              <UButton :label="t('admin.create', 'New event')" @click="actions.create()" />
            </template>
          </UEmpty>
        </template>
      </UTable>
      <template #footer>
        <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="data.total" />
      </template>
    </TableCard>
  </div>
</template>

A short list that is loaded in one go sorts on the client: bind useTableSort()'s sorting with v-model:sorting and leave out useTableQuery and TablePagination.

Pages have no wrapper component: the page root is a plain <div class="flex flex-col gap-4">.

Detail pages

  • PageHeading with a breadcrumb back to the list ([Users, Ada Lovelace]), a status #badge when the record has one, and the primary actions (outline RowMenu last) in #actions.
  • Sections are SectionCards. A form inside a section puts its Save button in the section's #footer (type="submit" with form="<id>"). Key facts are a DescriptionList.
  • Sections other modules can extend are WidgetTabs (extendTabs); sections the page owns are PageTabs. See which tabs to use.
  • A record that does not exist shows a UEmpty with a link back to the list, not a blank page.

Tabs

Both tab components share one look (a bordered strip with an underline highlight), so a page never looks different because of how it is built.

UseWhen
WidgetTabsOther modules should be able to add sections to the page, such as the user detail page, where Payments or Membership appear when those modules are installed. The active tab is in ?tab=.
PageTabsThe sections are fixed by the page itself (a form's builder, settings, access). Pass items with to for link tabs (each tab is a route), or v-model for state tabs.

Light entities

An entity with little of its own (a unit, an application) opens in a slideover instead of getting a detail page. The open record lives in the URL query (?application=<id>), so ⌘K and other pages can deep-link to it.

Overview pages

An overview shows numbers as StatCards, each linking (to) to the list it summarises, then ListCards for what needs a decision: recent items, upcoming events. The admin dashboard and the account overview are both built this way from widget areas.

Empty states

An empty state is a UEmpty that says what the thing is and offers the single action that gets you going: "No events yet" and a "New event" button. Inside a card use variant="naked". When a prerequisite is missing, point to it instead of the action ("Add a ticket type first"). There is no separate empty-state component.

Forms and actions

Create and edit

  • Create and edit forms open in a FormSlideover containing a UForm with a zod schema. Short yes/no choices are a modal (UModal). Never inline a create form next to a table.
  • Open forms through useOverlay(); the overlay resolves with the saved record (or false when cancelled), so the caller can navigate to it.
  • Cancel is color="neutral" variant="outline" and the primary action comes last. FormSlideover does this for you.
  • Hints belong in UFormField's description, not in extra paragraphs.
<!-- EventFormSlideover.vue -->
<script setup lang="ts">
const props = defineProps<{ event?: EventRow }>()
const emit = defineEmits<{ close: [saved: EventRow | false] }>()

const t = useModuleT('events')
const { busy, run } = useAction()
const state = reactive({ title: props.event?.title ?? '' })

const save = () => run(async () => {
  const saved = await $fetch<EventRow>(props.event ? `/api/events/${props.event.id}` : '/api/events', {
    method: props.event ? 'PATCH' : 'POST',
    body: state,
  })
  emit('close', saved)
}, { success: t('admin.saved', 'Event saved') })
</script>

<template>
  <FormSlideover
    form-id="event-form"
    :title="event ? t('admin.edit', 'Edit event') : t('admin.create', 'New event')"
    :submit-label="event ? t('admin.save', 'Save event') : t('admin.create', 'New event')"
    :loading="busy"
    @close="emit('close', false)"
  >
    <UForm id="event-form" :state="state" class="flex flex-col gap-4" @submit="save">
      <UFormField name="title" :label="t('admin.title', 'Title')" :description="t('admin.titleHint', 'Shown on the public page.')">
        <UInput v-model="state.title" />
      </UFormField>
    </UForm>
  </FormSlideover>
</template>

One actions composable per entity

Every entity has a use<Entity>Actions() composable (useUserActions, useEventActions, useFormActions) that owns create, edit and remove, the toasts and the refresh afterwards. It returns menu(record) for the row menu, so every list and detail page offers the same actions in the same order. Pages stay thin. It lives in the module that owns the entity, not in @frigear-nu/ui.

// composables/useEventActions.ts
export function useEventActions() {
  const t = useModuleT('events')
  const overlay = useOverlay()
  const confirm = useConfirmDialog()
  const toast = useToast()
  const { localize } = useLocaleLink()

  async function create() {
    const saved = await overlay.create(EventFormSlideover).open()
    if (saved) await navigateTo(localize(`/admin/events/${saved.id}`))
  }

  async function remove(event: EventRow) {
    const confirmed = await confirm({
      title: t('admin.deleteTitle', 'Delete {name}?', { name: event.title }),
      description: t('admin.deleteBody', 'Its tickets and attendees are deleted too. This cannot be undone.'),
      confirmText: t('admin.delete', 'Delete event'),
      destructive: true,
    })
    if (!confirmed) return
    try {
      await $fetch(`/api/events/${event.id}`, { method: 'DELETE' })
      toast.add({ title: t('admin.deleted', 'Event deleted'), description: event.title })
      await refreshNuxtData('events')
    }
    catch (e) {
      toast.add(errorToToast(e))
    }
  }

  const menu = (event: EventRow) => [
    [{ label: t('open', 'Open'), icon: 'i-lucide-arrow-right', to: localize(`/admin/events/${event.id}`) }],
    [{ label: t('admin.edit', 'Edit event'), icon: 'i-lucide-pencil', onSelect: () => overlay.create(EventFormSlideover).open({ event }) }],
    [{ label: t('admin.delete', 'Delete event'), icon: 'i-lucide-trash', color: 'error' as const, onSelect: () => remove(event) }],
  ]

  return { create, remove, menu }
}

Row actions

Row actions go in a RowMenu (the ⋯ button) at the end of the row, with groups in this order: Open first, then the edits, then the destructive action in its own group, coloured red. There are no loose icon buttons in rows. Pass subject so the button's accessible name reads "Actions for Ada Lovelace".

Destructive actions

Destructive actions confirm with useConfirmDialog({ destructive: true }), never window.confirm. The title asks about the record by name ("Delete Spring Gala?"), the description says what else is affected ("Its tickets and attendees are deleted too"), and the confirm button names the verb and the object ("Delete event").

Feedback and errors

  • A successful action is a toast with a title and, when useful, the record's name as the description.
  • A failed action is a toast built with errorToToast(e), or useErrorToast() when you want a ready-made function. They read the server's message from the usual places and fall back to a translated generic message.
  • Page-level problems that block the page (not found, forbidden) use UEmpty, not a toast.
  • useAction() wraps the common "busy, try, toast on error" block; do not hand-write it again.

A page that needs context from another page reads it from the URL or opens the right form directly. "Add ticket type" on an event opens the form with the event preselected; a ⌘K entry links to ?application=<id>. Do not pass state through stores or history.state.

Text

  • Every string goes through useModuleT('<namespace>') with an English fallback, and the module ships da and en JSON (see Module Kit). Shared strings (Open, Edit, Delete, "Not set") come from @frigear-nu/ui's patterns.* keys; see overriding them.
  • Buttons and menu items name the object: "Add unit", "Delete application", "Revoke token". Never bare "Add", "Delete" or "Save" unless the context is a section that is clearly about one thing.
  • Format dates and money through useFormat() so they follow the active locale; do not call toLocaleString or Intl directly.
  • Build links with useLocaleLink().localize so a visitor on /da/... stays under their locale.

Checklist for a new page

  1. Pick the area (admin, account or public) and register the route; add one item to the matching menu.
  2. Use the heading for the area: PageHeading in app areas, UPageHeader on public pages.
  3. Lists are TableCard plus UTable, with a RowMenu and a UEmpty; details are SectionCards with a breadcrumb.
  4. Writes go through a use<Entity>Actions() composable and a FormSlideover; destructive ones confirm.
  5. Strings go through useModuleT with da and en JSON; buttons name the object.
  6. Check it in light and dark, at desktop width and at 375px.

Where things live

NeedWhere
Components, useConfirmDialog, useErrorToast, useFormat, statusColor, tableUi@frigear-nu/ui
Admin shell, navigation groups, dashboard areas@frigear-nu/admin
Account shell, account menu, overview widgets@frigear-nu/account
Extensible tabs and widget areas@frigear-nu/widget
Navigation menus (admin.nav.*, account, main)@frigear-nu/menu
Copyright © 2026