Custom Pages

Add custom pages to your admin panel for features beyond standard CRUD operations.

Custom Pages

Add custom pages to your admin panel for features beyond standard CRUD operations.

Overview

Custom pages allow you to add specialized admin functionality like:

  • Analytics dashboards
  • Report generators
  • Settings pages
  • Custom workflows
  • Data import/export interfaces

Configuration

Add custom pages in your nuxt.config.ts:

export default defineNuxtConfig({
  autoAdmin: {
    customPages: [
      {
        name: 'analytics',
        label: 'Analytics',
        path: 'analytics',
        icon: 'i-heroicons-chart-bar',
        group: 'Reports',
        order: 1,
      },
      {
        name: 'settings',
        label: 'Settings',
        path: 'settings',
        icon: 'i-heroicons-cog-6-tooth',
        order: 999,
      },
    ],
  },
})

Page Options

name

Unique identifier for the page:

{
  name: 'analytics',  // Must be unique
}

label

Display text shown in the sidebar:

{
  label: 'Analytics Dashboard',
}

path

URL path relative to admin prefix:

{
  path: 'analytics',  // Accessible at /admin/analytics
}

For absolute paths, use a leading slash:

{
  path: '/custom-admin/analytics',  // Absolute path
}

icon

Heroicon name for sidebar:

{
  icon: 'i-heroicons-chart-bar',
}

Browse icons at: https://heroicons.com/

group

Organize custom pages into sidebar groups:

{
  group: 'Reports',  // Grouped with other report pages
}

Pages without a group appear in the ungrouped section.

order

Control display order within groups:

{
  order: 1,  // Lower numbers appear first
}

Creating Page Components

Create a Vue component in your Nuxt pages directory:

# For path: 'analytics'
touch app/pages/admin/analytics.vue
<!-- app/pages/admin/analytics.vue -->
<template>
  <div class="space-y-4">
    <div>
      <h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
        Analytics Dashboard
      </h1>
      <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
        View your site statistics
      </p>
    </div>

    <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
      <UCard>
        <div class="text-center">
          <div class="text-3xl font-bold">{{ stats.users }}</div>
          <div class="text-sm text-gray-500">Total Users</div>
        </div>
      </UCard>

      <UCard>
        <div class="text-center">
          <div class="text-3xl font-bold">{{ stats.posts }}</div>
          <div class="text-sm text-gray-500">Total Posts</div>
        </div>
      </UCard>

      <UCard>
        <div class="text-center">
          <div class="text-3xl font-bold">{{ stats.comments }}</div>
          <div class="text-sm text-gray-500">Total Comments</div>
        </div>
      </UCard>
    </div>
  </div>
</template>

<script setup lang="ts">
definePageMeta({
  layout: 'admin',  // Use admin layout
})

const stats = ref({
  users: 0,
  posts: 0,
  comments: 0,
})

onMounted(async () => {
  // Fetch stats from API
  const [users, posts, comments] = await Promise.all([
    $fetch('/api/users', { query: { aggregate: 'count' } }),
    $fetch('/api/posts', { query: { aggregate: 'count' } }),
    $fetch('/api/comments', { query: { aggregate: 'count' } }),
  ])

  stats.value = {
    users: users.meta.aggregates.count,
    posts: posts.meta.aggregates.count,
    comments: comments.meta.aggregates.count,
  }
})
</script>

Important: Always include definePageMeta({ layout: 'admin' }) to use the admin layout.

Permission Control

Using permission strings

permissions names what the caller must be allowed to do through the API, as '<resource>:<action>' (create, read, update, delete, restore, purge) — checked against GET /api/permissions. The <resource> must be a registered resource; for anything else use a route middleware (below):

{
  name: 'settings',
  label: 'Settings',
  path: 'settings',
  icon: 'i-heroicons-cog-6-tooth',
  permissions: 'users:update', // callers who may update users
}

Or require several:

{
  permissions: ['orders:read', 'customers:read'],  // the caller needs ALL
}

An entry in any other form grants nothing — a typo hides the page instead of opening it.

Any other rule: a route middleware

For a rule that is not an API permission (a role, a feature flag), put a route middleware on the page itself — the page is yours:

<script setup lang="ts">
definePageMeta({ layout: 'admin', middleware: 'staff-only' })
</script>

A canAccess: (user) => … function in nuxt.config could never reach the running app (runtime config is JSON), so it was never applied; it now fails the build.

Custom pages respect the unauthorizedSidebarItems setting:

autoAdmin: {
  permissions: {
    unauthorizedSidebarItems: 'hide',  // or 'disable'
  },
}
  • 'hide' - Pages without access are removed from sidebar
  • 'disable' - Pages without access appear grayed out

The global middleware automatically protects custom pages:

  • Checks the page's permissions before allowing access
  • Returns 403 Forbidden if unauthorized
  • Shows nice error page with navigation options

Complete Examples

Analytics Dashboard

// nuxt.config.ts
customPages: [
  {
    name: 'analytics',
    label: 'Analytics',
    path: 'analytics',
    icon: 'i-heroicons-chart-bar',
    group: 'Reports',
    permissions: ['orders:read', 'customers:read'],
  },
]

Settings Page

// nuxt.config.ts
customPages: [
  {
    name: 'settings',
    label: 'Settings',
    path: 'settings',
    icon: 'i-heroicons-cog-6-tooth',
    order: 999,  // Show at bottom
    permissions: 'users:update',
  },
]
<!-- app/pages/admin/settings.vue -->
<template>
  <div class="space-y-4">
    <h1 class="text-2xl font-semibold">Settings</h1>

    <UCard>
      <UForm :state="settings" @submit="saveSettings">
        <div class="space-y-4">
          <UFormGroup label="Site Name">
            <UInput v-model="settings.siteName" />
          </UFormGroup>

          <UFormGroup label="Contact Email">
            <UInput v-model="settings.contactEmail" type="email" />
          </UFormGroup>

          <UFormGroup label="Maintenance Mode">
            <UToggle v-model="settings.maintenanceMode" />
          </UFormGroup>

          <UButton type="submit">Save Settings</UButton>
        </div>
      </UForm>
    </UCard>
  </div>
</template>

<script setup lang="ts">
definePageMeta({
  layout: 'admin',
})

const settings = ref({
  siteName: '',
  contactEmail: '',
  maintenanceMode: false,
})

onMounted(async () => {
  const data = await $fetch('/api/settings')
  settings.value = data
})

async function saveSettings() {
  await $fetch('/api/settings', {
    method: 'PUT',
    body: settings.value,
  })
  // Show success notification
}
</script>

Data Export Page

(Every resource list already has an Export button — see Features. A page like this one exports several resources from one place.)

// nuxt.config.ts
customPages: [
  {
    name: 'export',
    label: 'Export Data',
    path: 'export',
    icon: 'i-heroicons-arrow-down-tray',
    group: 'Tools',
    permissions: 'orders:read',
  },
]
<!-- app/pages/admin/export.vue -->
<template>
  <div class="space-y-4">
    <h1 class="text-2xl font-semibold">Export Data</h1>

    <UCard>
      <div class="space-y-4">
        <UFormGroup label="Select Resource">
          <USelect
            v-model="selectedResource"
            :options="resources"
          />
        </UFormGroup>

        <UFormGroup label="Format">
          <USelect
            v-model="format"
            :options="['csv', 'json']"
          />
        </UFormGroup>

        <UButton
          :loading="isExporting"
          @click="exportData"
        >
          Export
        </UButton>
      </div>
    </UCard>
  </div>
</template>

<script setup lang="ts">
definePageMeta({
  layout: 'admin',
})

const selectedResource = ref('users')
const format = ref('csv')
const isExporting = ref(false)

const resources = ['users', 'posts', 'comments']

async function exportData() {
  isExporting.value = true
  try {
    // GET /api/{resource}/export — added by createExportPlugin
    const blob = await $fetch<Blob>(`/api/${selectedResource.value}/export`, {
      query: { format: format.value },
      responseType: 'blob',
    })
    // Download file
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `${selectedResource.value}.${format.value}`
    a.click()
  } finally {
    isExporting.value = false
  }
}
</script>

Best Practices

  1. Always use admin layout - Include definePageMeta({ layout: 'admin' })
  2. Declare permissions - permissions: ['<resource>:<action>'], or a route middleware on the page
  3. Follow naming conventions - Use kebab-case for paths
  4. Group related pages - Use groups for better organization
  5. Provide clear labels - Make purpose obvious in sidebar
  6. Handle loading states - Show loading indicators
  7. Error handling - Handle API errors gracefully

Accessing Admin Data

Custom pages can access all admin composables:

<script setup>
// Get current user permissions
const { canCreate, canUpdate } = useAdminPermissions('posts')

// Access admin configuration
const { branding } = useAdminConfig()

// Get resource schemas
const { resource } = useAdminResource('users')

// Use admin actions
const { goToList, goToCreate } = useAdminActions('posts')
</script>

Next Steps

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.