Skip to content

Layouts and the page shell

The two-layer layout chain: the base HTML shell, the header/footer chrome, and the Livewire slot short-circuit that makes both page types work.

7 min readUpdated Aug 13, 2026
On this page

Layouts and the page shell#

Every page a theme renders — a controller page, a Livewire auth screen, a full-page store component — is drawn inside the same HTML document. That document is your layout, and a Salieno theme ships it as two files that form a chain:

  • layouts/app.blade.php — the base HTML shell: <!doctype html>, <head>, meta and asset links, the dark-mode boot, and the body wrapper. It contains no header or footer.
  • layouts/frontend.blade.php@extendses the app layout and fills it with the visible chrome: header, a <main> landmark, and footer.

layouts/app.blade.php is one of the three required files a theme must ship (alongside sections.json and theme.json — see /theme-development/theme-anatomy). Get this pair right and every page type inherits a correct, consistent shell for free.

The two-section chain#

The chain nests two named sections. The base yields an outer section for the whole page body; the frontend layout fills that outer section with chrome and yields an inner section for the page's own content.

LayerFileProvidesYields
Base shelllayouts/app.blade.php<html>, <head>, <body>@yield('app')
Chromelayouts/frontend.blade.phpheader + <main> + footer@yield('content') (inside <main>)
Pagee.g. home.blade.phpthe actual pagefills @section('content')

A controller page extends layouts.frontend and defines @section('content'). The frontend layout wraps that in header/footer and re-emits it as @section('app'). The base shell drops @yield('app') into <body>. One document, three clean layers.

The base shell — layouts/app.blade.php#

Keep the base focused on the document: <head> contents, asset links, the mode boot, and the body wrapper. Reference your own CSS and JS through `themeAsset()` so they get an mtime cache-buster (?v=<mtime>) — uploaded themes ship precompiled assets that aren't fingerprinted by a bundler, so this is what forces browsers and Cloudflare off a stale copy after an update. See /theme-development/theme-assets for the full asset rules.

blade
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
      dir="{{ in_array(app()->getLocale(), ['ar','fa','he','ur'], true) ? 'rtl' : 'ltr' }}"
      class="scroll-smooth">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ gs()->siteName(__($pageTitle ?? '')) }}</title>
    <meta name="description" content="{{ $pageDescription ?? gs('site_description') }}">
    <meta name="theme-color" content="{{ themeMeta('colors.primary', '#111114') }}">
    <link rel="canonical" href="{{ url()->current() }}">
    <link rel="icon" type="image/png" href="{{ siteFavicon() }}">

    {{-- Your theme's precompiled CSS — themeAsset() adds the ?v=<mtime> cache-buster --}}
    <link rel="stylesheet" href="{{ themeAsset('css/theme.css') }}">

    {{-- Dark-mode boot: set the class BEFORE first paint so there's no flash --}}
    <script nonce="{{ csp_nonce() }}">
        (function () {
            var m = localStorage.getItem('theme');
            if (m === 'dark' || (!m && matchMedia('(prefers-color-scheme: dark)').matches)) {
                document.documentElement.classList.add('dark');
            }
        })();
    </script>

    @livewireStyles
    @stack('style')
</head>

<body class="font-sans antialiased bg-[var(--theme-bg-light)] text-[var(--theme-text-light)]
             dark:bg-[var(--theme-bg-dark)] dark:text-[var(--theme-text-dark)]">

    <a href="#main-content" class="skip-link">{{ __('Skip to content') }}</a>

    {{-- The Livewire slot short-circuit — see below --}}
    @if(isset($slot))
        {{ $slot }}
    @else
        @yield('app')
    @endif

    @livewireScripts
    <script src="{{ themeAsset('js/theme.js') }}" nonce="{{ csp_nonce() }}"></script>
    @stack('script')
</body>
</html>

Notes on the shell:

  • `$pageTitle` / `$pageDescription` are the variables controllers pass in. Feed the title through gs()->siteName(...) so it picks up the operator's configured site name suffix. The reference theme layers full SEO (Open Graph, Twitter, JSON-LD) on top of this — copy it from salieno/layouts/app.blade.php when you want the complete set; it is not required for a working theme.
  • `csp_nonce()` must be on every inline <script>/<style> — Core sends a strict Content-Security-Policy and a nonce-less inline script is blocked. This is not optional.
  • The dark-mode boot runs inline in `<head>`, before paint, to avoid a light-then-dark flash. The mode switcher and the token system are covered in /theme-development/theme-dark-mode.

The Livewire slot short-circuit — why it exists#

Look again at the body:

blade
@if(isset($slot))
    {{ $slot }}
@else
    @yield('app')
@endif

This one conditional is what lets the same base layout serve both Blade-inherited pages and full-page Livewire components.

  • A controller page reaches the layout through @extends(...) / @section(...). No $slot is in scope, so the shell renders @yield('app') and the normal section inheritance produces the page.
  • A full-page Livewire component (the auth screens in /theme-development/theme-auth, or a store page) is handed to its layout as a $slot variable — that is Livewire's component-layout contract. When Livewire uses your layouts.app directly as the layout, $slot is in scope and the shell renders it.

Without the @if(isset($slot)) branch, a Livewire component pointed at your layout would render an empty page — @yield('app') would be blank because no @section('app') was defined. The short-circuit is therefore mandatory in the base shell. Keep it exactly as shown.

The chrome — layouts/frontend.blade.php#

The frontend layout extends the base and supplies everything visible around the page body. This is where header and footer live, so every controller page gets them without repeating markup.

blade
@extends(activeTheme() . 'layouts.app')

@section('app')
    @include(activeTheme() . 'partials.header')

    {{-- Focusable main landmark: the skip-link and SPA navigation move focus here --}}
    <main id="main-content" tabindex="-1" aria-label="{{ __('Main content') }}"
          class="focus:outline-none">
        @yield('content')
    </main>

    @include(activeTheme() . 'partials.footer')
@endsection

That is the real reference frontend.blade.php, nearly verbatim. Two things to keep:

  • `activeTheme()` returns the string "themes.{name}.", so activeTheme() . 'layouts.app' resolves to your theme's app layout and partials.header / partials.footer resolve to your own partials. Never hardcode themes.salieno. — always concatenate activeTheme() so the view names track whichever theme is active. (activeTemplate() is the deprecated alias; use activeTheme().)
  • `<main id="main-content" tabindex="-1">` is the focus target for the skip-link in the base shell. Keep the id and tabindex="-1" so keyboard and screen-reader users can jump past the header.

Header and footer are ordinary partials you author however you like — they are not part of the required file set. What matters is that <main> yields content, because that is the section every page fills.

How a controller page uses the chain#

FrontendController::themeView('home') renders themeViewName('home')themes.{name}.home. That page extends the frontend layout and defines content:

blade
{{-- themes/{name}/home.blade.php --}}
@extends(activeTemplate() . 'layouts.frontend')

@section('content')
    @include(activeTemplate() . 'partials.render-sections')
@endsection

The controller also passes $pageTitle/$pageDescription/$seoContents into the view; the base shell reads them from <head>. That is the entire contract: extend layouts.frontend, fill @section('content'), and the two-layer shell does the rest. The render-sections include and the section pipeline are covered in /theme-development/theme-sections; the full list of pages a complete theme must ship is in /theme-development/theme-pages.

The store bridge — full-page components inside your chrome#

Some storefront pages are full-page Livewire components rather than controller views — for example the product comparison page, App\Livewire\Store\ComparePage. These need the same header/footer chrome as controller pages, but Livewire hands their content in as $slot, which the base shell's short-circuit would render bare (no chrome).

Core solves this with a small, theme-transparent bridge layout, resources/views/components/layouts/store.blade.php, which the component selects with #[Layout('components.layouts.store')]. You do not write or override this file — it ships with Core — but understanding it explains how those pages end up inside your theme:

blade
@php
    // Capture Livewire's $slot into our own variable, remember the #[Title]…
    $storePageContent = $slot;
    $pageTitle = $pageTitle ?? ($title ?? '');
    // …then UNSET $slot so the base shell does NOT take its short-circuit.
    unset($slot);
@endphp

@extends(activeTheme() . 'layouts.frontend')

@section('content')
    {{ $storePageContent }}
@endsection

The trick is deliberate: because the base shell renders {{ $slot }} whenever $slot is in scope, the bridge captures the slot, then `unset($slot)` before extending layouts.frontend. With $slot gone, the normal @yield('app') → header/<main>/footer → @yield('content') inheritance runs, and the component's content lands inside your theme's chrome exactly like a controller page. Because it extends activeTheme() . 'layouts.frontend', it automatically uses whichever theme is active — including yours, with no wiring on your part. Your only obligation is to ship a correct layouts.frontend.

Checklist for a correct layout pair#

  • layouts/app.blade.php ships (it is required), contains the full <head>, and its <body> uses the @if(isset($slot)) {{ $slot }} @else @yield('app') @endif short-circuit.
  • Every inline <script>/<style> carries nonce="{{ csp_nonce() }}".
  • The dark-mode class is set inline in <head> before paint.
  • Own CSS/JS are linked via themeAsset(...), not a bare asset(...).
  • layouts/frontend.blade.php extends activeTheme() . 'layouts.app', fills @section('app') with header + <main id="main-content">@yield('content')</main> + footer.
  • All internal view/partial references concatenate activeTheme() — no hardcoded theme name.

Next: /theme-development/theme-assets — how CSS, JavaScript and images are shipped, published, cache-busted, and security-screened.

layoutsbladelivewiretheme-development
Was this article helpful?
Still stuck?Contact support
Layouts and the page shell · Salieno Docs