Documentation Index
Fetch the complete documentation index at: https://mintlify.com/zrclouddev-oss/saas-starter-vue/llms.txt
Use this file to discover all available pages before exploring further.
The frontend is built with Vue 3, Inertia.js, TypeScript, and Tailwind CSS 4, providing a modern, type-safe, and reactive user interface.
Tech Stack
Vue 3
Composition API, TypeScript support, and reactive state management
Inertia.js
Server-side routing with SPA-like experience, no separate API needed
Tailwind CSS 4
Utility-first CSS with custom design system
TypeScript
Type safety for Vue components and application logic
Project Structure
The frontend code lives in resources/js/:
resources/js/
├── app.ts # Main entry point
├── ssr.ts # SSR entry point
├── components/ # Reusable components
│ ├── ui/ # shadcn-vue components
│ ├── AppShell.vue
│ ├── AppHeader.vue
│ ├── AppSidebar.vue
│ └── Breadcrumbs.vue
├── composables/ # Vue composables (hooks)
├── layouts/ # Page layouts
├── pages/ # Inertia pages
├── lib/ # Utility functions
└── types/ # TypeScript types
Inertia.js Architecture
Inertia.js bridges Laravel and Vue without building a separate API. Controllers return Inertia responses that render Vue components.
How It Works
Controller Returns Data
Laravel controller returns an Inertia response with page component and props:app/Http/Controllers/System/DashboardController.php
public function index()
{
$stats = [
'total_tenants' => Tenant::count(),
'active_tenants' => Tenant::where('status', 'Active')->count(),
'trial_tenants' => Tenant::where('status', 'Trial')->count(),
];
return Inertia::render('system/Dashboard', [
'stats' => $stats,
'recentTenants' => $this->getRecentTenants(),
]);
}
Vue Component Receives Props
The Vue component receives the data as typed props:resources/js/pages/system/Dashboard.vue
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import AppLayout from '@/layouts/AppLayout.vue';
interface DashboardProps {
stats: {
total_tenants: number;
active_tenants: number;
trial_tenants: number;
};
recentTenants: Array<{
id: string;
name: string;
status: string;
}>;
}
defineProps<DashboardProps>();
</script>
<template>
<Head title="Dashboard" />
<AppLayout>
<div class="grid gap-4 md:grid-cols-3">
<Card>
<CardTitle>Total Tenants</CardTitle>
<div class="text-2xl font-bold">{{ stats.total_tenants }}</div>
</Card>
<!-- More cards... -->
</div>
</AppLayout>
</template>
Navigation Updates Without Full Reload
Inertia intercepts links and form submissions, making XHR requests and swapping components:<template>
<!-- Inertia handles navigation -->
<Link href="/plans" class="nav-link">Plans</Link>
<!-- Forms work seamlessly -->
<form @submit.prevent="form.post('/tenants')">
<input v-model="form.name" />
<button type="submit">Create Tenant</button>
</form>
</template>
Benefits of Inertia
Controllers return data directly to Vue components. No need to build REST or GraphQL APIs.
Routes are defined in Laravel. No need to sync frontend and backend route definitions.
Type-Safe Routing with Wayfinder
Generate type-safe route helpers from Laravel routes:import { dashboard, tenants } from '@/routes';
// Type-safe navigation
router.visit(dashboard().url);
router.visit(tenants.show({ tenant: '123' }).url);
Share data across all pages via middleware:Inertia::share([
'auth.user' => fn () => auth()->user(),
'flash.message' => fn () => session('message'),
]);
Vue Components
Component Structure
Components follow the Composition API with <script setup> syntax:
resources/js/components/AppHeader.vue
<script setup lang="ts">
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
import type { User } from '@/types';
interface Props {
title?: string;
}
const props = withDefaults(defineProps<Props>(), {
title: 'Dashboard',
});
const page = usePage();
const user = computed(() => page.props.auth?.user as User);
</script>
<template>
<header class="border-b bg-background">
<div class="flex h-16 items-center px-4">
<h1 class="text-xl font-semibold">{{ title }}</h1>
<div class="ml-auto flex items-center gap-4">
<span>{{ user?.name }}</span>
</div>
</div>
</header>
</template>
UI Component Library
The project uses shadcn-vue components located in resources/js/components/ui/:
<Card>
<CardHeader>
<CardTitle>Total Tenants</CardTitle>
<CardDescription>Registered businesses</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ stats.total_tenants }}</div>
</CardContent>
</Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="tenant in tenants" :key="tenant.id">
<TableCell>{{ tenant.name }}</TableCell>
<TableCell>
<Badge :variant="getStatusVariant(tenant.status)">
{{ tenant.status }}
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
<form @submit.prevent="form.post('/tenants')">
<div class="space-y-4">
<div>
<Label for="name">Tenant Name</Label>
<Input
id="name"
v-model="form.name"
type="text"
required
/>
<InputError :message="form.errors.name" />
</div>
<Button type="submit" :disabled="form.processing">
Create Tenant
</Button>
</div>
</form>
<Dialog v-model:open="isOpen">
<DialogTrigger as-child>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Tenant</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="isOpen = false">Cancel</Button>
<Button variant="destructive" @click="deleteTenant">Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Available Components
Layout
Card, Sheet, Tabs, Separator, Accordion
Forms
Input, Textarea, Select, Checkbox, Radio, Switch
Feedback
Alert, Toast, Dialog, Popover, Tooltip
Navigation
Breadcrumb, Dropdown Menu, Navigation Menu
Data Display
Table, Badge, Avatar, Calendar
Controls
Button, Command, Context Menu, Slider
Layouts
Layouts provide consistent structure across pages:
AppLayout (System)
resources/js/layouts/AppLayout.vue
<script setup lang="ts">
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import type { BreadcrumbItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
};
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<slot />
</AppLayout>
</template>
Usage in Pages
resources/js/pages/system/Dashboard.vue
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes';
const breadcrumbs = [
{ title: 'Dashboard', href: dashboard().url },
];
</script>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<!-- Page content -->
</AppLayout>
</template>
Composables
Reusable logic extracted into composables (Vue’s equivalent of React hooks):
useAppearance
resources/js/composables/useAppearance.ts
import { ref } from 'vue';
type Theme = 'light' | 'dark' | 'system';
export function useAppearance() {
const theme = ref<Theme>('system');
const setTheme = (newTheme: Theme) => {
theme.value = newTheme;
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
};
const applyTheme = (theme: Theme) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
};
return { theme, setTheme };
}
export function initializeTheme() {
const savedTheme = localStorage.getItem('theme') as Theme || 'system';
const { setTheme } = useAppearance();
setTheme(savedTheme);
}
useTwoFactorAuth
resources/js/composables/useTwoFactorAuth.ts
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
export function useTwoFactorAuth() {
const enabling = ref(false);
const disabling = ref(false);
const enable = () => {
enabling.value = true;
router.post('/user/two-factor-authentication', {}, {
onFinish: () => (enabling.value = false),
});
};
const disable = () => {
disabling.value = true;
router.delete('/user/two-factor-authentication', {
onFinish: () => (disabling.value = false),
});
};
return { enabling, disabling, enable, disable };
}
Tailwind CSS
The project uses Tailwind CSS 4 with custom configuration:
Configuration
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
tailwindcss(),
// other plugins
],
});
Utility Classes
Layout
Typography
Colors
Dark Mode
<div class="container mx-auto px-4">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>...</Card>
</div>
</div>
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
<p class="text-sm text-muted-foreground">Welcome back</p>
<Button class="bg-primary text-primary-foreground">
Primary
</Button>
<Button variant="destructive">
Delete
</Button>
<Badge variant="secondary">
Trial
</Badge>
<div class="bg-background text-foreground">
<Card class="border-border">
<!-- Automatically adapts to theme -->
</Card>
</div>
Custom Utilities
Use clsx and tailwind-merge for conditional classes:
resources/js/lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Usage
const buttonClass = cn(
'px-4 py-2 rounded',
variant === 'primary' && 'bg-blue-500 text-white',
variant === 'secondary' && 'bg-gray-200 text-gray-900',
disabled && 'opacity-50 cursor-not-allowed'
);
Inertia provides a useForm helper for managing form state:
resources/js/pages/system/tenants/Create.vue
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { tenants } from '@/routes';
const form = useForm({
name: '',
owner_name: '',
owner_email: '',
owner_password: '',
plan_id: null,
});
const submit = () => {
form.post(tenants.store().url, {
onSuccess: () => {
// Handle success
},
});
};
</script>
<template>
<form @submit.prevent="submit">
<div class="space-y-4">
<div>
<Label for="name">Tenant Name</Label>
<Input
id="name"
v-model="form.name"
:disabled="form.processing"
/>
<InputError :message="form.errors.name" />
</div>
<Button type="submit" :disabled="form.processing">
<Loader v-if="form.processing" class="mr-2 h-4 w-4 animate-spin" />
Create Tenant
</Button>
</div>
</form>
</template>
- Automatic CSRF protection: Handled by Inertia
- Validation errors: Automatically populated in
form.errors
- Loading states: Track submission with
form.processing
- Optimistic updates: Update UI before server response
- File uploads: Support for
multipart/form-data
TypeScript
The project uses TypeScript for type safety:
resources/js/types/index.ts
export interface User {
id: string;
name: string;
email: string;
email_verified_at: string | null;
two_factor_enabled: boolean;
}
export interface Tenant {
id: string;
name: string;
owner_email: string;
status: 'Active' | 'Trial' | 'Canceled';
plan: Plan;
created_at: string;
}
export interface Plan {
id: string;
name: string;
price: number;
features: Feature[];
}
export interface BreadcrumbItem {
title: string;
href?: string;
}
Type-Safe Props
<script setup lang="ts">
import type { Tenant } from '@/types';
interface Props {
tenants: Tenant[];
total: number;
}
const props = defineProps<Props>();
// Full type safety
const activeTenants = props.tenants.filter(t => t.status === 'Active');
</script>
Build Configuration
Vite powers the frontend build process:
import { wayfinder } from '@laravel/vite-plugin-wayfinder';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import laravel from 'laravel-vite-plugin';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/js/app.ts'],
ssr: 'resources/js/ssr.ts',
refresh: true,
}),
tailwindcss(),
wayfinder({ formVariants: true }),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
});
Development
Production Build
SSR Build
Best Practices
- Keep components small and focused
- Use composition API with
<script setup>
- Extract reusable logic into composables
- Define TypeScript interfaces for props
- Use local state with
ref() and reactive()
- Share state across components with composables
- Use Inertia’s shared data for global state
- Avoid prop drilling with
provide/inject
- Define interfaces for all props
- Use TypeScript for composables
- Type Inertia page props
- Enable strict mode in
tsconfig.json
Next Steps
Backend Development
Learn about Laravel controllers and services
Testing
Write tests for Vue components and pages