DEV Community

Cover image for Building a Production Vue Frontend for Multi-Tenant Laravel SaaS with Inertia v2
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building a Production Vue Frontend for Multi-Tenant Laravel SaaS with Inertia v2

Most Vue + Laravel tutorials stop at "here's how to render a page with Inertia." Real SaaS apps need dynamic layouts per user type, overlay management, pagination that preserves filter state, modals that stack, and all of this without the complexity of a full state management library.

I built a complete frontend module for Kohana.io - a production CRM/ERP - and I'm extracting it into LaraFoundry, an open-source SaaS framework for Laravel.

This post covers the full frontend architecture: layout switching, overlay system, pagination + filters, modal patterns, Inertia wiring, and testing.

The Problem

A multi-tenant SaaS needs more than one layout. In Kohana.io, I needed five:

Layout When What it contains
GuestLayout Not logged in Landing page, login/register modals
AuthLayout Regular user Full app: header, pullout menus, notifications
AdminLayout Admin user Admin panel with different navigation
AuthBlockedLayout Blocked account Limited access, support tickets only
AuthDeletedLayout Deleted account Minimal UI for data requests

Each layout has a completely different component tree. Different headers, different pullout menus, different modal systems. And Inertia's persistent layouts mean they stay mounted across page navigations - you can't just swap a CSS class.

Architecture Overview

HTTP Request
  |
  v
HandleInertiaRequests middleware
  |── visitor_status: 'admin' | 'auth' | 'authBlocked' | 'authDeleted' | 'guest'
  |── layout: { layoutData, authUserData } (lazy-loaded)
  |── flash, translations, ziggy, locale, ui_settings
  |
  v
app.js - createInertiaApp()
  |── import.meta.glob('./pages/**/*.vue')     → code-split pages
  |── page.layout = page.layout || LayoutSwitcher  → default layout
  |── i18n, ZiggyVue, Head, Link               → plugins
  |
  v
LayoutSwitcher.vue
  |── reads visitor_status from Inertia props
  |── renders matching layout component
  |
  v
AuthLayout / AdminLayout / GuestLayout / etc.
  |── pullout menus, overlays, modals
  |── <slot /> → page content
Enter fullscreen mode Exit fullscreen mode

The LayoutSwitcher Pattern

The core of the system is LayoutSwitcher.vue:

<script setup>
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
import AdminLayout from '@/layouts/AdminLayout.vue';
import AuthLayout from '@/layouts/AuthLayout.vue';
import AuthBlockedLayout from '@/layouts/AuthBlockedLayout.vue';
import AuthDeletedLayout from '@/layouts/AuthDeletedLayout.vue';
import GuestLayout from '@/layouts/GuestLayout.vue';

const visitorStatus = computed(() => usePage().props.visitor_status);
</script>

<template>
    <component :is="
        visitorStatus === 'admin' ? AdminLayout
        : visitorStatus === 'auth' ? AuthLayout
        : visitorStatus === 'authBlocked' ? AuthBlockedLayout
        : visitorStatus === 'authDeleted' ? AuthDeletedLayout
        : GuestLayout
    ">
        <slot />
    </component>
</template>
Enter fullscreen mode Exit fullscreen mode

Under 40 lines. The server determines visitor_status via middleware, and the frontend renders the matching layout. No auth checks in Vue. No route guards. No conditional rendering scattered across components.

The assignment happens in app.js:

resolve: async (name) => {
    const pages = import.meta.glob('./pages/**/*.vue');
    let module = await pages[`./pages/${name}.vue`]();
    let page = module.default;
    page.layout = page.layout || LayoutSwitcher;
    return page;
}
Enter fullscreen mode Exit fullscreen mode

Every page automatically gets LayoutSwitcher unless it explicitly defines its own layout. Pages don't import layouts. Pages don't know which layout wraps them.

The Overlay System

A production SaaS layout isn't just a header and a sidebar. The authenticated layout in Kohana.io has 7 pullout panels:

  1. Mobile menu (slides from left)
  2. Right profile menu (slides from right)
  3. Notifications panel
  4. Language selector
  5. Company switcher
  6. Contragent filter drawer
  7. Product filter drawer

And they stack. Opening the language selector while the mobile menu is open creates a double overlay - two backdrop layers, independently dismissable.

State Management (No Library)

// AuthLayout.vue - all overlay state
const viewPulloutMobilemenu = ref(false);
const viewPulloutMenuRight = ref(false);
const viewPulloutNotifications = ref(false);
const viewPulloutMenuChangeLanguage = ref(false);
const viewPulloutMenuChangeCompany = ref(false);
const viewPulloutContragentFilter = ref(false);
const viewPulloutProductFilter = ref(false);
const viewOverlay = ref(false);
const viewDoubleOverlay = ref(false);
const viewModalWrapper = ref(false);
Enter fullscreen mode Exit fullscreen mode

Plain reactive refs. No Vuex store. No Pinia. No event bus.

Overlay Logic

  • Opening any pullout → shows single overlay + disables body scroll via document.body.classList.add('non-overflow')
  • Opening a second panel on top → shows double overlay
  • ESC key → dismisses top layer first using onKeyStroke from @vueuse/core
  • Clicking backdrop → closes everything
  • CSS transitions: 0.3s for single overlay, 0.1s for double (feels snappy)

Child Component Communication

Pages deep in the component tree trigger layout overlays via provide/inject:

// AuthLayout.vue (parent)
provide('showPulloutContragentFilter', showPulloutContragentFilter);
provide('showViewContragentModal', showViewContragentModal);

// ContragentsList.vue (child page)
const showFilter = inject('showPulloutContragentFilter');
Enter fullscreen mode Exit fullscreen mode

The page calls showFilter(). The layout handles z-indexes, backdrops, body scroll, and transition timing. Clean separation.

Transition Animations

.overlay-enter-active, .overlay-leave-active {
    transition: opacity 0.3s ease;
}
.overlay-enter-from, .overlay-leave-to {
    opacity: 0;
}

/* Pullout menus use GPU-accelerated transforms */
.pullout-menu__left {
    will-change: transform;
    transform: translateZ(0);
    backface-visibility: hidden;
    transition: transform 0.3s ease;
}
Enter fullscreen mode Exit fullscreen mode

CSS-only animations. GPU-accelerated for mobile. No JavaScript animation libraries.

Pagination + Filter Architecture

Backend: HasPagination Trait

Every controller that paginates uses the same trait:

trait HasPagination
{
    protected function getPaginationData($paginator): array
    {
        return [
            'current_page' => $paginator->currentPage(),
            'last_page'    => $paginator->lastPage(),
            'per_page'     => $paginator->perPage(),
            'total'        => $paginator->total(),
            'from'         => $paginator->firstItem(),
            'to'           => $paginator->lastItem(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Controller usage:

$paginated = $query->filter($filter)->paginate($perPage)->withQueryString();

return Inertia::render('warehouse/Products', [
    'products'   => ProductResource::collection($paginated),
    'pagination' => $this->getPaginationData($paginated),
]);
Enter fullscreen mode Exit fullscreen mode

.withQueryString() is critical - it preserves all filter parameters when generating pagination links.

Frontend: PagePaginator Component

The paginator component lives in the layout, not in pages. AppMainContentLayout.vue auto-renders it when pagination data exists:

const isPaginationPresent = computed(() => {
    const total = page.props.pagination?.total ?? 0;
    const perPage = page.props.pagination?.per_page ?? 0;
    return total > perPage;
});
Enter fullscreen mode Exit fullscreen mode

The component rebuilds pagination links while preserving all current query parameters:

const queryString = computed(() => {
    const url = new URL(window.location.href);
    url.searchParams.delete('page');
    const qs = url.searchParams.toString();
    return qs ? `&${qs}` : '';
});

// Smart page range: max 7 buttons + ellipsis
const pages = computed(() => {
    const total = p.value.last_page;
    const current = p.value.current_page;
    if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
    if (current <= 4) return [1, 2, 3, 4, 5, '...', total];
    if (current >= total - 3) return [1, '...', total-4, total-3, total-2, total-1, total];
    return [1, '...', current-1, current, current+1, '...', total];
});
Enter fullscreen mode Exit fullscreen mode

Each page button uses Inertia's <Link> for SPA navigation:

<Link :href="`?page=${item}&${queryString}`">{{ item }}</Link>
Enter fullscreen mode Exit fullscreen mode

Country filter active? Preserved. Sort direction? Preserved. Search query? Preserved. No state management needed.

Filter Auto-Discovery Pattern

The filter system uses method auto-discovery:

abstract class Filter
{
    public function apply(Builder $builder)
    {
        $this->builder = $builder;
        foreach ($this->request->all() as $name => $value) {
            if (method_exists($this, $name)) {
                call_user_func_array([$this, $name], [$value]);
            }
        }
        return $this->builder;
    }
}
Enter fullscreen mode Exit fullscreen mode

Request parameter country=DE → calls $filter->country('DE'). No routing config. No switch statements. Method exists = filter applied.

Concrete filter example:

class ContragentsFilter extends Filter
{
    public function search_string($value = null)
    {
        $words = preg_split('/\s+/', trim($value));
        return $this->builder->where(function ($query) use ($words) {
            foreach ($words as $word) {
                $query->whereRaw('LOWER(name) LIKE ?', ['%'.mb_strtolower($word).'%']);
            }
        });
    }

    public function sort_by($value = null)
    {
        $allowed = ['name', 'country', 'balance', 'created_at'];
        if (in_array($value, $allowed)) {
            $direction = $this->request->input('sort_direction', 'asc');
            return $this->builder->orderBy($value, $direction);
        }
        return $this->builder->orderBy('name', 'asc');
    }
}
Enter fullscreen mode Exit fullscreen mode

Whitelisted sort columns prevent SQL injection. Word-by-word search matching. Alphabetical navigation via starts_with() method. Quick filter presets for products (min_stock, has_reserved).

Frontend Filter Integration

// Inertia router navigation with state preservation
const performSearch = () => {
    const params = {};
    if (filters.value.search) params.search_string = filters.value.search;
    if (filters.value.country) params.country = filters.value.country;
    // ... all filter params

    router.get(route(routeName.value), params, {
        preserveState: true,    // Component state survives
        preserveScroll: true,   // Scroll position maintained
        replace: true,          // Single history entry
    });
};
Enter fullscreen mode Exit fullscreen mode

preserveState: true is the key. Component reactive state persists across Inertia navigations. No need to store filters in a Vuex store or local storage.

Modal & Popup System

LaraFoundry uses a hybrid approach: custom modals for complex interactions, SweetAlert2 for confirmations.

Custom Form Modal (Inertia useForm)

<script setup>
import { useForm } from '@inertiajs/vue3';

const form = useForm({ email: '', role_id: '' });

const submit = () => {
    form.post(route('my_company.employees.invite'), {
        preserveScroll: true,
        onSuccess: () => emit('invited'),
    });
};
</script>

<template>
    <Transition name="modal">
        <div class="modal-overlay" @click.self="emit('close')">
            <div class="modal">
                <form @submit.prevent="submit">
                    <InputField v-model="form.email" :class="{ 'input--error': form.errors.email }" />
                    <span v-if="form.errors.email">{{ form.errors.email }}</span>
                    <button type="submit" :disabled="form.processing">
                        {{ form.processing ? t('Sending...') : t('Send') }}
                    </button>
                </form>
            </div>
        </div>
    </Transition>
</template>
Enter fullscreen mode Exit fullscreen mode

form.processing disables the submit button. form.errors shows server-side validation. onSuccess closes the modal. Three features, zero custom code.

Async Data-Loading Modal

const loading = ref(false);
const contragent = ref(null);

watch(() => props.visible, (newVal) => {
    if (newVal && props.contragentUuid) {
        fetchContragentData();
    }
});

const fetchContragentData = async () => {
    loading.value = true;
    try {
        const response = await axios.get(route('contragents.view_data', props.contragentUuid));
        contragent.value = response.data.contragent;
    } catch (err) {
        error.value = t('Failed to load data');
    } finally {
        loading.value = false;
    }
};
Enter fullscreen mode Exit fullscreen mode

Data fetched only when modal becomes visible. Loading spinner. Error state. Tabs for different data views. ESC key to close.

SweetAlert2 for Confirmations

const result = await Swal.fire({
    title: t('Remove Employee?'),
    text: t('This action will immediately remove the employee.'),
    icon: 'error',
    showCancelButton: true,
    confirmButtonColor: '#ef4444',
});

if (result.isConfirmed) {
    router.delete(route('my_company.employees.remove', employee.id));
}
Enter fullscreen mode Exit fullscreen mode

One await. No custom component. Accessible. Styled.

Layout-Level Modal Orchestration

Generic modals are managed in the layout via provide/inject:

// AuthLayout.vue
const modal = reactive({
    view: false,
    title: t('Confirm the action'),
    content: null,
    targetUrl: null,
    modalType: 'confirmable',
});

provide('showViewContragentModal', showViewContragentModal);
Enter fullscreen mode Exit fullscreen mode

Pages inject trigger functions. The layout handles z-indexes, overlay stacking, and transition timing.

Full Inertia Wiring

Blade Template

<head>
    <title inertia>{{ config('app.name') }}</title>
    @vite(['resources/js/app.js', 'resources/css/app.css'])
    @inertiaHead
    @routes
</head>
<body>
    @inertia
</body>
Enter fullscreen mode Exit fullscreen mode

@routes dumps Ziggy routes. @inertiaHead enables dynamic head management from Vue. @inertia mounts the SPA.

app.js Entry Point

createInertiaApp({
    title: (title) => `${title} - ${appName}`,

    resolve: async (name) => {
        const pages = import.meta.glob('./pages/**/*.vue');
        let module = await pages[`./pages/${name}.vue`]();
        let page = module.default;
        page.layout = page.layout || LayoutSwitcher;
        return page;
    },

    setup({ el, App, props, plugin }) {
        const i18n = createI18n({
            legacy: false,
            locale: pageProps.locale || 'en',
            messages: { [locale]: translations },
        });

        createApp({ render: () => h(App, props) })
            .use(plugin)
            .use(i18n)
            .use(ZiggyVue)
            .component('Head', Head)
            .component('Link', Link)
            .mount(el);

        // Global translation function
        app.config.globalProperties.t = (...args) => i18n.global.t(...args);
        globalThis.t = (...args) => i18n.global.t(...args);
    },

    progress: { delay: 0, color: '#3b82f6', showSpinner: false },
});
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  • import.meta.glob - Vite code-splits every page. Only the current page is loaded.
  • Global t() function - registered on both globalProperties (Options API) and globalThis (Composition API). No imports needed anywhere.
  • Progress bar delay: 0 - appears immediately on navigation. Users see instant feedback.

Shared Props (Middleware)

public function share(Request $request): array
{
    return [
        'layout'          => fn () => $layout->getLayout(),       // lazy
        'flash'           => fn () => session messages,            // lazy
        'ziggy'           => [...(new Ziggy)->toArray()],
        'locale'          => fn () => App::getLocale(),
        'translations'    => fn () => merged translations,         // lazy
        'visitor_status'  => visitor status closure,
        'ui_settings'     => fn () => user UI preferences,         // lazy
    ];
}
Enter fullscreen mode Exit fullscreen mode

Everything wrapped in closures for lazy evaluation. Layout data is only computed when the frontend reads page.props.layout.

Vite Aliases

resolve: {
    alias: {
        '@': path.resolve(__dirname, './resources/js'),
        '@css': path.resolve(__dirname, './resources/css'),
        '@assets': path.resolve(__dirname, './public/assets'),
        'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'),
    },
}
Enter fullscreen mode Exit fullscreen mode

@/components/PagePaginator.vue instead of relative path chains.

Testing

Frontend behavior is tested through Pest feature tests that assert on Inertia props:

it('serves auth layout for authenticated users', function () {
    actingAs(User::factory()->create())
        ->get('/dashboard')
        ->assertInertia(fn ($page) =>
            $page->where('visitor_status', 'auth')
        );
});

it('returns pagination data with filter state', function () {
    Product::factory(30)->create(['company_id' => $company->id]);

    actingAs($user)
        ->get('/warehouse/products?page=2')
        ->assertInertia(fn ($page) =>
            $page->has('pagination', fn ($p) =>
                $p->where('current_page', 2)
                    ->where('per_page', 20)
                    ->where('total', 30)
            )
        );
});

it('shares flash messages via Inertia', function () {
    actingAs($user)->post('/contragents', $validData);

    actingAs($user)
        ->get('/contragents/customers')
        ->assertInertia(fn ($page) => $page->has('flash.info'));
});

it('returns contragent data for view modal', function () {
    $contragent = Contragent::factory()->create();

    actingAs($user)
        ->get(route('contragents.view_data', $contragent->uuid))
        ->assertJson(['contragent' => ['name' => $contragent->name]]);
});
Enter fullscreen mode Exit fullscreen mode

The pattern: test what the server sends. If Inertia props are correct, Vue renders correctly. This covers layout switching, pagination, filters, flash messages, and modal data.

For complex interactive components (filter watchers, URL manipulation), unit tests with Vitest would supplement this. But for the majority of frontend behavior, Pest feature tests are sufficient.

Directory Structure

resources/js/
├── app.js                          # Entry point, plugins, LayoutSwitcher default
├── layouts/
│   ├── LayoutSwitcher.vue          # Dynamic layout router
│   ├── AuthLayout.vue              # Authenticated users (7 pullout menus)
│   ├── AdminLayout.vue             # Admin panel
│   ├── GuestLayout.vue             # Public + login/register modals
│   ├── AuthBlockedLayout.vue       # Blocked users
│   ├── AuthDeletedLayout.vue       # Deleted accounts
│   └── app/                        # Sub-layout components
│       ├── AppHeaderDesktopLayout.vue
│       ├── AppMainContentLayout.vue    # Wraps content + auto-renders pagination
│       ├── AppModalLayout.vue
│       ├── AppFooterLayout.vue
│       ├── AppPulloutMobilemenuLayout.vue
│       ├── AppPulloutMenuRightLayout.vue
│       ├── AppPulloutMenuNotificationsLayout.vue
│       └── ... (7 more pullout components)
├── pages/                          # Inertia pages (auto-resolved)
├── components/                     # Reusable components
│   ├── PagePaginator.vue
│   ├── contragents/ViewContragentModal.vue
│   ├── SendTestEmailModal.vue
│   └── ui/                         # Form inputs, filters, etc.
├── composables/                    # Vue composition functions
└── store/
    └── unreadCountStore.js         # Simple ref-based store
Enter fullscreen mode Exit fullscreen mode

What's Included

Feature Implementation
Layout switching LayoutSwitcher + visitor_status prop (5 layouts)
Overlay system 7 pullout panels, double-layer stacking, ESC dismissal
Pagination HasPagination trait + PagePaginator component (auto-render)
Filters Auto-discovery pattern, alphabetical nav, quick presets
Modals Custom (useForm, async axios) + SweetAlert2 (confirmations)
State management Reactive refs + provide/inject (no Vuex/Pinia)
i18n vue-i18n v11 with Laravel translations, global t()
Routing Ziggy named routes in Vue templates
Code splitting Vite import.meta.glob per page
Testing Pest feature tests asserting Inertia props

Key Takeaway

Push complexity to the backend. Vue renders pre-computed data.

No permission checks in Vue components. No auth guards. No client-side menu filtering. No state calculations. The server sends exactly what each user type needs. The frontend renders it.

The LayoutSwitcher pattern, the overlay system, the pagination auto-rendering, the filter auto-discovery - they all follow the same principle. The backend is the source of truth. The frontend is a rendering engine.


LaraFoundry is an open-source Laravel SaaS framework, being built in public and extracted from a production CRM/ERP.

Top comments (0)