Tasks

Add an admin page

Add a list page with a sidebar entry to the admin area, built from the shared page components.

What you'll build: a Units list page at /admin/units in your app, and the staff-only API route it reads. The page has a sidebar entry under Manage, a heading with an action, a table in a card and an empty state.

Prerequisites

The paths below are for the starter's apps/web. In another host app, leave out the apps/web/ prefix.

Admin brings UI, Menu and Widget. Only users who hold admin.settings.permission get into the admin area. It defaults to admin:access, which Admin registers and grants to the admin role. A role that should see the admin area at all needs it too.

1. Register the permission and add the API route

The page reads its rows from /api/admin/units, so that route comes first. It needs a permission of its own. Declare it with defineAccess, in a file the server and the pages can import too:

apps/web/shared/access/units.ts
import { defineAccess } from '@frigear-nu/auth/access'

export const unitsAccess = defineAccess('units', {
  permissions: {
    read: { label: 'View units', grantTo: ['admin'] }, // units:read
    manage: { label: 'Manage units', grantTo: ['admin'] }, // units:manage
  },
})

A host app registers it in a local module (Nuxt loads every file in apps/web/modules/). A module does the same in its setup(), with the declaration in src/runtime/access.ts:

apps/web/modules/units.ts
import { defineNuxtModule } from 'nuxt/kit'
import { registerAccess } from '@frigear-nu/auth/utils'
import { unitsAccess } from '../shared/access/units'

export default defineNuxtModule({
  meta: { name: 'units' },
  setup(_options, nuxt) {
    registerAccess(nuxt, unitsAccess)
  },
})

After the next nuxi prepare, units:read and units:manage are part of the generated Permission type, so a typo in authorize, <Can>, definePageMeta({ authorize }) or a nav item's permission fails typecheck.

Sign-in alone is not authorization, and neither is the route rule on /admin/**: it guards the page, not the API routes behind it. A route that never calls authorize answers every caller.

Check the permission with authorize, before anything else. It answers 401 without a credential and 403 without the permission. forbidden() and friends are for errors your own code raises after that.

apps/web/server/api/admin/units.get.ts
export default defineEventHandler(async (event) => {
  await authorize(event, 'units:read') // 401 / 403 first: a user who may not list units learns nothing else

  const items: { id: number, name: string, code: string }[] = [] // load your units here
  return { items, total: items.length }
})
  • authorize reads the user's roles from the database on every request, so a demoted admin is refused at once.
  • A route rule is not a substitute for the check. A rule '/api/admin/units': { permission: 'units:read' } in nuxt.config.ts is a coarse first gate that Auth enforces on the path. It guards one pattern, not the handler, so keep the check in the handler.
  • Staff routes live under /api/admin/.... An action that changes who a user is, or moves money, gets its own sessionOnly permission. More on Protect a route.
  • One permission per kind of action (units:read, units:manage), not one per route. Name the staff permission after the resource, and mark ones that expose personal data :pii.
  • The response is { items, total }, the shape the page in step 2 reads.

2. Add the page

Pages under /admin get the admin layout automatically, so your page renders only its own body. Create apps/web/app/pages/admin/units.vue. Its root is a plain <div class="flex flex-col gap-4">. It renders its own heading (PageHeading), which is the page's only <h1>.

apps/web/app/pages/admin/units.vue
<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'

interface Unit { id: number, name: string, code: string }

const t = useModuleT('units')
const { data, loading } = await useCachedFetch<{ items: Unit[], total: number }>('/api/admin/units', { key: 'admin:units' })
const units = computed(() => data.value?.items ?? [])

const TableIdentity = resolveComponent('TableIdentity')
const columns: TableColumn<Unit>[] = [
  {
    accessorKey: 'name',
    header: t('admin.name', 'Name'),
    cell: ({ row }) => h(TableIdentity, { title: row.original.name, description: row.original.code }),
  },
]
</script>

<template>
  <div class="flex flex-col gap-4">
    <PageHeading :title="t('admin.title', 'Units')">
      <template #actions>
        <Can permission="units:manage">
          <UButton icon="i-lucide-plus" :label="t('admin.create', 'Add unit')" />
        </Can>
      </template>
    </PageHeading>

    <TableCard :loading="loading">
      <UTable :data="units" :columns="columns" :ui="tableUi()">
        <template #empty>
          <UEmpty variant="naked" icon="i-lucide-building" :title="t('admin.empty', 'No units yet')">
            <template #actions>
              <Can permission="units:manage">
                <UButton :label="t('admin.create', 'Add unit')" />
              </Can>
            </template>
          </UEmpty>
        </template>
      </UTable>
    </TableCard>
  </div>
</template>

The page follows the list pattern:

  • Heading and action. A list is a PageHeading with the primary action, and a UTable styled with tableUi() inside a TableCard. Past about eight rows, add a PageToolbar for search.
  • Loading. useCachedFetch (from UI) returns loading, which is true only while nothing is on screen yet. TableCard :loading shows skeleton rows for it, and a later refresh keeps the rows that are already there. It also keeps the list when you navigate away and back.
  • Empty state. An empty list is a UEmpty with exactly one action. It is never an empty table, and it never shows while the list is still loading.
  • Permissions. <Can permission="units:manage"> renders its content only for a visitor who holds the permission (<Cannot> is the opposite). It decides what is shown, not what is allowed: the create route checks units:manage itself.
  • Strings. Every string goes through useModuleT with an English default. Buttons name the object: "Add unit", not "Add".
  • Row menu. A list with actions adds a row menu (RowMenu) in the last column. It comes from a use<Entity>Actions() composable that owns create, edit and remove. See Forms and actions.

3. Add the sidebar entry

The page needs an entry in the admin sidebar, or nobody finds it. The sidebar has three groups:

GroupMenu pathArgument
Overviewadmin.nav.overview'overview'
Manageadmin.nav'manage', the default
Settingsadmin.nav.settings'settings'

Put a new feature in an existing group. Add the entry at build time with extendAdminNav from @frigear-nu/admin/utils. It runs in a Nuxt module's setup():

Add the call to the units module from step 1, or to a module of its own in apps/web/modules/, as the starter's libsql-binding.ts shows.

apps/web/modules/units-nav.ts
import { defineNuxtModule } from 'nuxt/kit'
import { adminRoute, extendAdminNav } from '@frigear-nu/admin/utils'

export default defineNuxtModule({
  meta: { name: 'units-nav' },
  setup(_options, nuxt) {
    extendAdminNav(nuxt, { label: 'Units', to: adminRoute(nuxt, 'units'), icon: 'i-lucide-building', order: 20, permission: 'units:read' })
  },
})
  • adminRoute(nuxt, 'units') is /admin/units, below the configured base.
  • permission hides the entry from visitors who do not hold it. It only hides: the route behind it still checks the permission, and a page rule or definePageMeta({ authorize: 'units:read' }) keeps others out of the page itself.
  • A configuration page goes into the Settings group: pass 'settings' as the third argument.
  • A module can instead register a runtime menu source with extendMenuItem('admin.nav', ...). That works whether or not Admin is installed.
What you should see: after a restart of pnpm dev, Units in the Manage group of the admin sidebar. It opens /admin/units, with the Units heading and the Add unit button. A signed-in user whose roles lack units:read sees no entry, and GET /api/admin/units answers 403.

Next steps

  • Forms and actions – make Add unit open a create form, and add the row menu.
  • Admin – add a widget to the admin dashboard.
  • UI – look up PageHeading, TableCard, RowMenu, SectionCard and FormSlideover.
Copyright © 2026