Frontend
Pages & Routing
Page structure, definePageMeta, and routing conventions.
File Conventions
Pages live in app/pages/. Nuxt file-based routing applies:
| File | Route |
|---|---|
app/pages/index.vue | / |
app/pages/login.vue | /login |
app/pages/bookings/index.vue | /bookings |
app/pages/bookings/[id].vue | /bookings/:id |
app/pages/[organization]/index.vue | /:organization (dynamic org slug) |
Page Structure
Pages follow the same <script setup> convention as components:
<script lang="ts" setup>
// 1. Page meta — always first in <script setup>
definePageMeta({
layout: 'default',
middleware: ['auth'],
})
// 2. Composables
const { getBookings, items, page, pages } = useBooking()
const { handleApiAction, loading } = useFormHandler()
// 3. Reactive state
const dialogs = reactive({ view: false, cancel: false })
const selected = ref<Record<string, any> | null>(null)
// 4. Data fetching — called manually (SPA, no SSR)
onMounted(async () => {
await fetchBookings()
})
async function fetchBookings() {
await handleApiAction(
async () => {
const res = await getBookings({ page: page.value })
items.value = res.bookings
pages.value = res.pages
},
{ skipAlert: true },
)
}
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">My Bookings</h1>
<BookingTable
:bookings="items"
@view="(b) => { selected = b; dialogs.view = true }"
@cancel="(b) => { selected = b; dialogs.cancel = true }"
/>
<BookingViewModal
v-if="dialogs.view"
:booking="selected"
@close="dialogs.view = false"
/>
</div>
</template>
definePageMeta
Always declared at the top of <script setup>:
definePageMeta({
layout: 'default', // matches app/layouts/default.vue
middleware: ['auth'], // named middleware file(s) to run
})
Common middleware values:
| Value | File | Effect |
|---|---|---|
'auth' | app/middleware/auth.ts | Redirects to /login if no token |
'admin' | app/middleware/admin.ts | Throws 403 if not admin role |
'org' | app/middleware/01.org.ts | Loads org into state |
Dynamic Routes
Dynamic segments use [param] in the file name and useRoute() to access:
// app/pages/bookings/[id].vue
const route = useRoute()
const bookingId = route.params.id as string
Data Fetching Pattern
All data fetching is done manually inside onMounted — not with useAsyncData or useFetch at the top level — because SSR is disabled and we want explicit control:
onMounted(async () => {
await loadData()
})
async function loadData() {
await handleApiAction(
async () => {
const res = await someComposable.getData()
items.value = res.items
},
{ skipAlert: true }, // suppress toast on initial load
)
}
Pair with a watch when a filter or page number changes:
watch(page, () => loadData())
watch(searchQuery, useDebounceFn(() => loadData(), 400))