Skip to content
SaaS4Builders
Customization

Extending the Frontend

How to add pages, create components, extend composables, work with Nuxt UI, add locales, and customize styles in the SaaS4Builders 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:

frontend/app/pages/dashboard/tasks/index.vue
<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:

LayoutPurposeMiddleware
dashboardTenant user pages (billing, team, settings)auth, tenant
managerPlatform admin pages (tenants, catalog, analytics)auth, manager
defaultPublic 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:

frontend/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:

frontend/common/composables/useAuthenticatedAsyncData.ts
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:

  1. Auth cookies are not available during SSR — Sanctum's cookies are not sent in server-side requests. Without server: false, useAsyncData silently returns null during SSR, serializes it into the payload, and the client never re-fetches.
  2. Auth guard prevents stale calls — During logout, clearing auth state can trigger reactive watchers that would fire API calls against an invalidated session.
  3. Error clearing — Stale cached errors from previous navigations (e.g., a 401 from before login) are cleared before the fresh handler executes.
Never use raw 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 concernuseTasks() 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, permissions
  • useTenantStore — Current tenant context
  • useEntitlementsStore — Feature entitlements and quotas
  • useSettingsStore — 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 componentsState belongs to a single feature
State must survive route navigationsState can be re-fetched on navigation
Multiple features need to write to the same stateOnly 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:

frontend/features/core/tasks/stores/useTaskStore.ts
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 }
})
Most features do not need a store. Start with a composable. Promote to a store only when you find yourself passing the same data through many component layers or when multiple unrelated features need the same state.

Adding a New i18n Locale

The boilerplate ships with four locales: English, French, Spanish, and Italian.

frontend/i18n/locales.config.ts
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.

Backend and frontend translations are separate systems. Both must be updated when adding a new locale. Backend uses PHP arrays in 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:

frontend/app/assets/css/main.css
@import "tailwindcss";
@import "@nuxt/ui";

@source "../../../content/**/*";
@source "../../../features/**/*";
@source "../../../common/**/*";
If you add a new top-level directory with Vue components or utility classes, you must add a corresponding @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:

frontend/app/assets/css/main.css
: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:

frontend/app/assets/css/main.css
.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:

frontend/nuxt.config.ts
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:

frontend/nuxt.config.ts
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:

frontend/nuxt.config.ts
typescript: {
  strict: true,
  tsConfig: {
    include: ['../common/**/*', '../features/**/*'],
    exclude: ['../**/__tests__/**', '../**/*.test.ts', '../**/*.spec.ts'],
  },
},

See Vertical Slices for the complete frontend architecture.