Web Fallback

Build routes that work with and without JavaScript.

Overview

Not every request to your routes will be a Gale request. Users might:

  • Visit a URL directly (bookmarks, shared links)
  • Have JavaScript disabled
  • Be a search engine crawler
  • Use your site before JavaScript loads

Gale's web fallback system lets you handle both Gale and traditional HTTP requests from the same route.

Basic Usage

The web() Method

Use web() to specify what to return for non-Gale requests:

public function show(Post $post)
{
    return gale()
        ->state('post', $post)
        ->view('posts.detail', compact('post'))
        ->web(view('posts.detail', compact('post')));
}

When a Gale request hits this route, it receives state and DOM updates via SSE. When a regular HTTP request hits it, the user gets the full HTML page.

Shorthand: web Option

For simpler cases, use the web option on view() or html():

return gale()
    ->state('post', $post)
    ->view('posts.detail', compact('post'), web: true);

With web: true, Gale automatically returns the view for non-Gale requests.

How It Works

  1. Gale checks the request type
    Using the X-Gale header sent by Alpine Gale
  2. For Gale requests:
    Returns SSE stream with state/DOM events
  3. For regular requests:
    Returns the web fallback response (typically a full HTML view)
Progressive Enhancement
This pattern is called "progressive enhancement"—the basic experience works without JavaScript, but JavaScript enhances it when available.

Practical Patterns

List Page with Filtering

public function index(Request $request)
{
    $products = Product::query()
        ->when(request()->state('category'), fn($q, $cat) => $q->where('category', $cat))
        ->when(request()->state('search'), fn($q, $s) => $q->search($s))
        ->paginate(12);

    return gale()
        ->state('products', $products->items())
        ->state('pagination', [
            'current' => $products->currentPage(),
            'last' => $products->lastPage(),
        ])
        ->fragment('products.index', 'product-grid', compact('products'), [
            'selector' => '#product-grid',
        ])
        ->web(view('products.index', compact('products')));
}

Form Submission

public function store(Request $request)
{
    $validated = request()->validateState([
        'name' => 'required|string|max:255',
        'email' => 'required|email',
    ]);

    $contact = Contact::create($validated);

    // Gale request - update state and show success message
    if (request()->isGale()) {
        return gale()
            ->state('submitted', true)
            ->state('name', '')
            ->state('email', '')
            ->clearMessages();
    }

    // Regular form POST - redirect with flash message
    return redirect()->route('contact.index')
        ->with('success', 'Thank you for your message!');
}

Detail Page with Related Data

public function show(Product $product)
{
    $related = Product::where('category', $product->category)
        ->where('id', '!=', $product->id)
        ->limit(4)
        ->get();

    return gale()
        ->state([
            'product' => $product,
            'related' => $related,
            'quantity' => 1,
        ])
        ->view('products.show', compact('product', 'related'), web: true);
}

SEO Considerations

Web fallbacks are essential for SEO because search engine crawlers typically don't execute JavaScript.

Full HTML for Crawlers

Search engines receive complete, rendered HTML pages.

Proper URLs

Navigation updates browser history with shareable URLs.

Meta Tags

Set unique titles and descriptions in your Blade layouts.

<!-- In your layout -->
<head>
    <title>@yield('title', 'Default Title') - My App</title>
    <meta name="description" content="@yield('description', 'Default description')">

    
    <meta property="og:title" content="@yield('title')">
    <meta property="og:description" content="@yield('description')">
</head>

Testing Both Modes

Test your routes handle both request types:

// Test regular HTTP request
public function test_shows_product_page()
{
    $product = Product::factory()->create();

    $response = $this->get("/products/{$product->id}");

    $response->assertOk();
    $response->assertViewIs('products.show');
}

// Test Gale request
public function test_returns_gale_response()
{
    $product = Product::factory()->create();

    $response = $this->withHeaders(['X-Gale' => 'true'])
        ->get("/products/{$product->id}");

    $response->assertOk();
    $response->assertHeader('Content-Type', 'text/event-stream');
}

Quick Reference

Method Description
gale()->web($response) Set fallback response for non-Gale requests
->view($view, $data, web: true) Auto-fallback with the same view
->html($html, $opts, web: true) Auto-fallback with HTML
request()->isGale() Check if current request is Gale

On this page