Extending the Frontend
The frontend is a Nuxt 4 application using vertical slice architecture. Features are organized into three layers — foundation/, core/, and product/ — with strict import boundaries enforced by ESLint.
This page covers how to add pages, create components, extend composables, add locales, and customize the design system without breaking existing patterns.
Adding New Pages
Nuxt uses file-based routing. Pages live in frontend/app/pages/ and map directly to URLs:
app/pages/
├── index.vue # /
├── login.vue # /login
├── pricing.vue # /pricing
├── dashboard/
│ ├── index.vue # /dashboard
│ ├── profile.vue # /dashboard/profile
│ ├── settings/
│ │ ├── billing.vue # /dashboard/docs/settings/docs/billing
│ │ ├── organization.vue # /dashboard/docs/settings/organization
│ │ └── preferences.vue # /dashboard/docs/settings/preferences
│ ├── team/
│ │ └── index.vue # /dashboard/team
│ └── tasks/ # Your new feature
│ └── index.vue # /dashboard/tasks
├── manager/
│ ├── index.vue # /manager (platform admin)
│ ├── tenants/
│ └── catalog/
└── onboarding/
└── welcome.vue # /onboarding/welcome
Page Metadata
Every page declares its layout and middleware requirements using definePageMeta:
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
middleware: ['auth', 'tenant'],
})
const { t } = useI18n()
useSeoMeta({
title: () => t('tasks.title'),
})
</script>
The three layouts serve different contexts:
| Layout | Purpose | Middleware |
|---|---|---|
dashboard | Tenant user pages (billing, team, settings) | auth, tenant |
manager | Platform admin pages (tenants, catalog, analytics) | auth, manager |
default | Public pages (landing, pricing, login) | None |
Dynamic Routes
For detail pages, use Nuxt's bracket syntax:
app/pages/dashboard/tasks/[id].vue # /dashboard/tasks/:id
<script setup lang="ts">
const route = useRoute()
const taskId = route.params.id as string
</script>
Feature vs Shared Components
Components live in two places depending on their scope.
Feature Components
Feature components live inside their feature module and are auto-registered by Nuxt:
features/core/tasks/components/
├── TaskList.vue
├── TaskCard.vue
└── TaskStatusBadge.vue
The auto-registration is configured in nuxt.config.ts:
components: [
{ path: '~/components' },
{ path: fileURLToPath(new URL('./common/components', import.meta.url)), pathPrefix: true },
{
path: fileURLToPath(new URL('./features/foundation', import.meta.url)),
pathPrefix: true,
pattern: '*/components/**/*.vue',
},
{
path: fileURLToPath(new URL('./features/core', import.meta.url)),
pathPrefix: true,
pattern: '*/components/**/*.vue',
},
{
path: fileURLToPath(new URL('./features/product', import.meta.url)),
pathPrefix: true,
pattern: '*/components/**/*.vue',
},
],
Because pathPrefix: true is set, feature components are registered with their feature path as prefix. A component at features/core/tasks/components/TaskList.vue is auto-registered by Nuxt based on the relative path from the scan root. In practice, the recommended approach is to import explicitly through the barrel export:
import { TaskList } from '@core/tasks'
Naming convention: Prefix feature components with the feature name to avoid collisions. For example, billing components are named BillingSubscriptionCard, BillingInvoiceRow, etc.
Shared Components
Shared components live in common/components/ and are available everywhere:
common/components/
├── ui/ # Primitives (wrappers, enhanced inputs)
└── layout/ # Structural (PageHeader, Sidebar)
Promote a feature component to shared only when it is used by multiple features.
Working with Nuxt UI
The boilerplate uses Nuxt UI v4 — a component library built on Reka UI with Tailwind CSS theming. Components are available globally without imports.
Common patterns:
<template>
<!-- Card with header -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ t('tasks.title') }}</h3>
<UButton :label="t('tasks.create')" icon="i-lucide-plus" />
</div>
</template>
<!-- Table -->
<UTable :data="tasks" :columns="columns" />
</UCard>
<!-- Modal -->
<UModal v-model:open="isModalOpen">
<UCard>
<UFormField :label="t('tasks.form.title')" name="title">
<UInput v-model="form.title" />
</UFormField>
</UCard>
</UModal>
<!-- Status badge -->
<UBadge :color="statusColor" :label="statusLabel" />
</template>
The design tokens are configured through CSS custom properties in frontend/app/assets/css/main.css. Nuxt UI reads --ui-primary, --ui-radius, and other tokens to theme all components consistently.
Extending Composables
Composables are the reactive data layer between API modules and components. They follow a consistent pattern.
The useAuthenticatedAsyncData Wrapper
All authenticated data fetching must use useAuthenticatedAsyncData — a wrapper around Nuxt's useAsyncData that forces server: false and guards against unauthenticated calls:
import type { Ref, ComputedRef } from 'vue'
import type { AsyncDataOptions } from '#app'
import { clearNuxtData, useAsyncData, useAuthStore } from '#imports'
type KeysOf<T> = Array<T extends T ? (keyof T extends string ? keyof T : never) : never>
export function useAuthenticatedAsyncData<T>(
key: string | (() => string) | Ref<string> | ComputedRef<string>,
handler: () => Promise<T>,
options?: Omit<AsyncDataOptions<T, T, KeysOf<T>, undefined>, 'server'>
) {
clearNuxtData(key as string)
return useAsyncData<T, unknown, T, KeysOf<T>, undefined>(
key as string,
async () => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
return null as unknown as T
}
return handler()
},
{
...options,
server: false,
} as AsyncDataOptions<T, T, KeysOf<T>, undefined>
)
}
Why this wrapper exists:
- Auth cookies are not available during SSR — Sanctum's cookies are not sent in server-side requests. Without
server: false,useAsyncDatasilently returnsnullduring SSR, serializes it into the payload, and the client never re-fetches. - Auth guard prevents stale calls — During logout, clearing auth state can trigger reactive watchers that would fire API calls against an invalidated session.
- Error clearing — Stale cached errors from previous navigations (e.g., a 401 from before login) are cleared before the fresh handler executes.
useAsyncData for authenticated endpoints. Always use useAuthenticatedAsyncData. For public endpoints (e.g., the public plan catalog), use useAsyncData directly.Adding a New Composable
Follow this pattern for any new composable:
import { readonly, computed } from 'vue'
export function useMyFeature() {
const api = useMyFeatureApi()
const { data, pending, error, refresh } = useAuthenticatedAsyncData(
'my-feature:data', // Namespaced key
() => api.getData()
)
// Derived computed state
const items = computed(() => data.value?.data ?? [])
const isEmpty = computed(() => items.value.length === 0)
return {
items: readonly(items), // Readonly refs
isEmpty: readonly(isEmpty),
isLoading: readonly(pending),
error: readonly(error),
refresh, // Actions are not readonly
}
}
Key rules:
- Use namespaced keys for
useAuthenticatedAsyncData(e.g.,billing:subscription,tasks:list) - Return readonly refs for state — only expose mutation functions
- One composable per concern —
useTasks()for the list,useTaskActions()for create/update/delete
See Composables & Stores for the full composable patterns.
Pinia Stores
Stores are for truly global state that persists across route navigations. The boilerplate uses stores for:
useAuthStore— User session, tokens, permissionsuseTenantStore— Current tenant contextuseEntitlementsStore— Feature entitlements and quotasuseSettingsStore— User and tenant settings
When to Use a Store vs a Composable
| Use a store when... | Use a composable when... |
|---|---|
| State is shared across many unrelated components | State belongs to a single feature |
| State must survive route navigations | State can be re-fetched on navigation |
| Multiple features need to write to the same state | Only one feature writes the state |
| You need a single source of truth (auth, tenant) | Data is scoped to a page or component |
Creating a Store
Use the composition API pattern with defineStore:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Task } from '../types'
export const useTaskStore = defineStore('tasks', () => {
// ─── State ──────────────────────────────────────────────────────────
const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
// ─── Getters ────────────────────────────────────────────────────────
const pendingTasks = computed(() =>
tasks.value.filter(t => t.status === 'pending')
)
const taskCount = computed(() => tasks.value.length)
// ─── Actions ────────────────────────────────────────────────────────
async function fetchTasks() {
// Use API module to fetch and populate state
}
function $reset() {
tasks.value = []
isLoading.value = false
error.value = null
}
return { tasks, isLoading, error, pendingTasks, taskCount, fetchTasks, $reset }
})
Adding a New i18n Locale
The boilerplate ships with four locales: English, French, Spanish, and Italian.
import type { LocaleObject } from '@nuxtjs/i18n'
export const localeCodes = ['en', 'fr', 'es', 'it'] as const
export type LocaleCode = (typeof localeCodes)[number]
export const locales: LocaleObject[] = [
{ code: 'en', name: 'English', language: 'en-US', dir: 'ltr', file: 'en.json' },
{ code: 'fr', name: 'Français', language: 'fr-FR', dir: 'ltr', file: 'fr.json' },
{ code: 'es', name: 'Español', language: 'es-ES', dir: 'ltr', file: 'es.json' },
{ code: 'it', name: 'Italiano', language: 'it-IT', dir: 'ltr', file: 'it.json' },
]
To add a fifth locale (for example, German):
Step 1: Frontend Locale File
Create frontend/i18n/locales/de.json by copying en.json and translating all values.
Step 2: Register the Locale
Update frontend/i18n/locales.config.ts:
export const localeCodes = ['en', 'fr', 'es', 'it', 'de'] as const
export const locales: LocaleObject[] = [
// ... existing locales ...
{ code: 'de', name: 'Deutsch', language: 'de-DE', dir: 'ltr', file: 'de.json' },
]
Step 3: Backend Translations
Create backend/lang/de/ and add PHP translation files for each domain:
backend/lang/de/
├── auth.php
├── billing.php
├── onboarding.php
├── teams.php
├── validation.php
└── ...
Copy the structure from backend/lang/en/ and translate the values.
Step 4: Feature-Level Translations
If you have feature-level i18n files (some features store translations in their own directories), add the German translations there as well.
lang/{locale}/; frontend uses JSON files in i18n/locales/.See Internationalization for the full i18n architecture.
CSS and Tailwind v4
The boilerplate uses Tailwind CSS v4 with the @nuxt/ui integration. The main stylesheet configures source scanning, design tokens, and dark mode.
Source Scanning
Tailwind v4 needs explicit @source directives to scan files outside the default paths:
@import "tailwindcss";
@import "@nuxt/ui";
@source "../../../content/**/*";
@source "../../../features/**/*";
@source "../../../common/**/*";
@source directive. Without it, Tailwind will not detect classes used in those files.Design Tokens
The theme is configured through CSS custom properties and the @theme static block:
:root {
--ui-primary: rgb(81, 162, 255);
--ui-radius: 0.25rem;
--ui-text-dimmed: var(--ui-color-neutral-500);
--ui-text-muted: var(--ui-color-neutral-600);
--ui-text-toned: var(--ui-color-neutral-700);
--ui-text: var(--ui-color-neutral-800);
}
@theme static {
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-display: 'Clash Display', system-ui, sans-serif;
--color-brand-50: #f0f9ff;
--color-brand-100: #e0f2fe;
--color-brand-200: #bae6fd;
--color-brand-300: #7dd3fc;
--color-brand-400: #38bdf8;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a8a;
--color-brand-950: #172554;
}
To customize the brand colors, replace the --color-brand-* values with your own color scale. Nuxt UI components reference --ui-primary for the primary action color.
Dark Mode
Dark mode overrides live in the .dark selector:
.dark {
--ui-bg: var(--color-slate-950);
--ui-text-dimmed: var(--ui-color-neutral-500);
--ui-text-muted: var(--ui-color-neutral-400);
--ui-text-toned: var(--ui-color-neutral-200);
--ui-text: var(--ui-color-neutral-100);
}
Nuxt UI handles dark mode toggling. Your custom components should use Tailwind's dark: prefix for dark-mode-specific styles.
Auto-Import and Path Aliases
Auto-Imports
Foundation composables and stores are auto-imported — you use them without explicit imports:
imports: {
dirs: [
fileURLToPath(new URL('./common/composables', import.meta.url)),
fileURLToPath(new URL('./common/utils', import.meta.url)),
fileURLToPath(new URL('./features/foundation/auth/composables', import.meta.url)),
fileURLToPath(new URL('./features/foundation/tenancy/composables', import.meta.url)),
fileURLToPath(new URL('./features/foundation/entitlements/composables', import.meta.url)),
fileURLToPath(new URL('./features/foundation/docs/settings/composables', import.meta.url)),
fileURLToPath(new URL('./features/foundation/auth/stores', import.meta.url)),
fileURLToPath(new URL('./features/foundation/tenancy/stores', import.meta.url)),
fileURLToPath(new URL('./features/foundation/entitlements/stores', import.meta.url)),
fileURLToPath(new URL('./features/foundation/docs/settings/stores', import.meta.url)),
],
},
Core and product features are not auto-imported. This is intentional — explicit imports keep dependency tracking visible:
// Foundation: auto-imported, just use it
const { currentTenant } = useCurrentTenant()
const authStore = useAuthStore()
// Core: explicit import required
import { useSubscription } from '@core/docs/billing'
import { useTeamMembers } from '@core/team'
Path Aliases
Four aliases are configured for clean imports:
alias: {
'@foundation': fileURLToPath(new URL('./features/foundation', import.meta.url)),
'@core': fileURLToPath(new URL('./features/core', import.meta.url)),
'@product': fileURLToPath(new URL('./features/product', import.meta.url)),
'@common': fileURLToPath(new URL('./common', import.meta.url)),
},
Always import through barrel exports and aliases:
// ✅ Correct
import { useTasks, type Task } from '@core/tasks'
// ❌ Wrong — never import internals
import { useTasksApi } from '@core/tasks/api/tasks.api'
TypeScript Configuration
TypeScript strict mode is enabled. The config includes feature and common directories:
typescript: {
strict: true,
tsConfig: {
include: ['../common/**/*', '../features/**/*'],
exclude: ['../**/__tests__/**', '../**/*.test.ts', '../**/*.spec.ts'],
},
},
See Vertical Slices for the complete frontend architecture.