Skip to content

Islands, forms & CSRF ​

Static pages are shared by every visitor. Anything user-specific is loaded in the browser from a dynamic endpoint: an island.

Shared props and the signed-in user ​

Keep HandleInertiaRequests as it is, including the user shared the way the Laravel starter kits do:

php
public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'name' => config('app.name'),
        'auth' => [
            'user' => $request->user(),
        ],
        'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
    ];
}
php
// config/inertia-static.php
'public_shared_props' => ['name', 'sidebarOpen'],
  • Dynamic routes get every prop, auth.user included, as usual.
  • Static routes run without session and cookies, so $request->user() is null there, and only the props in public_shared_props are sent: auth never reaches a cached page. Never list auth, flash or anything per-user in public_shared_props.
  • Props read from cookies (sidebarOpen above) get their default value on static pages, since there are no cookies. Read the cookie in the browser when the value matters on those pages.
  • The user in a layout shared by static and dynamic pages (a header with the name, a sign-out button) comes from an island, so it is right on both:
vue
<script setup lang="ts">
import { useStaticIsland } from '@fydellys/inertia-static/vue'

const { data } = useStaticIsland<{ user: { name: string } | null }>('/api/me')
</script>

<template>
  <span v-if="data?.user">{{ data.user.name }}</span>
  <a v-else href="/login">Sign in</a>
</template>
php
// routes/web.php: a regular dynamic route
Route::get('/api/me', fn (Request $request) => ['user' => $request->user()?->only('id', 'name')]);

useStaticIsland() ​

vue
<script setup lang="ts">
import { useStaticIsland } from '@fydellys/inertia-static/vue'

const { data, loading, error, load } = useStaticIsland<{ user: { name: string } | null }>('/api/me')
</script>

<template>
  <span v-if="loading">…</span>
  <span v-else-if="data?.user">Hello, {{ data.user.name }}</span>
  <a v-else href="/login">Sign in</a>
</template>
tsx
import { useStaticIsland } from '@fydellys/inertia-static/react'

export function Greeting() {
  const { data, loading } = useStaticIsland<{ user: { name: string } | null }>('/api/me')

  if (loading) return <span>…</span>

  return data?.user ? <span>Hello, {data.user.name}</span> : <a href="/login">Sign in</a>
}
  • Fetches on mount with the visitor's cookies, never during SSR, so the cached HTML is identical for everyone.
  • Reloads after every non-GET Inertia visit (sign in, sign out, forms), even when the page component is reused. Disable it with refreshAfterMutations: false.
  • A failed request (non-2xx) sets error and puts data back to initial (e.g. a 401 after logout never leaves the previous user on screen). Call load() to fetch again; the latest call always wins.
  • Options: when ('mount' or 'visible'), buffer, immediate (default true), initial, init (extra fetch options), refreshAfterMutations. See useStaticIsland().

The endpoint is a regular dynamic route. Nothing else is needed: Laravel already answers with Cache-Control: private, which CDNs never store.

php
Route::get('/api/me', fn (Request $request) => [
    'user' => $request->user()?->only('id', 'name'),
]);

Load when visible ​

User data further down the page, loaded when it scrolls into view: the island version of Inertia's <WhenVisible>. <WhenVisible> itself reloads the static page, which never sees the signed-in user.

vue
<script setup lang="ts">
import { StaticIsland } from '@fydellys/inertia-static/vue'
</script>

<template>
  <StaticIsland url="/api/me/orders" when="visible" :buffer="200">
    <template #fallback>Loading…</template>
    <template #default="{ data }">{{ data.orders.length }} orders</template>
  </StaticIsland>
</template>
tsx
import { StaticIsland } from '@fydellys/inertia-static/react'

<StaticIsland<{ orders: Order[] }> url="/api/me/orders" when="visible" buffer={200} fallback={<span>Loading…</span>}>
  {({ data }) => <span>{data?.orders.length} orders</span>}
</StaticIsland>

See <StaticIsland>, or useStaticIsland(url, { when: 'visible' }) with its target.

Put islands in a persistent layout ​

If the layout is rendered inside each page, it is mounted again on every visit and the island fetches again on every navigation. Use an Inertia persistent layout: it is mounted once and the island loads once.

vue
<!-- Pages/About.vue -->
<script setup lang="ts">
import { Head } from '@inertiajs/vue3'
import Layout from '../Layouts/Layout.vue'

defineOptions({ layout: Layout })   // persistent: kept across visits
</script>

<template>
  <Head title="About" />
  <h1>About</h1>
</template>
tsx
// Pages/About.tsx
import { Head } from '@inertiajs/react'
import Layout from '../Layouts/Layout'

export default function About() {
  return (
    <>
      <Head title="About" />
      <h1>About</h1>
    </>
  )
}

About.layout = (page: React.ReactNode) => <Layout>{page}</Layout>   // persistent: kept across visits

Forms on static pages ​

Both Inertia ways of submitting a form work on a static page, with no CSRF code (see CSRF):

useForm / <Form> → return back()useHttp (Inertia 3) → JSON
RequestInertia visit (POST), redirectplain JSON request
Validation errorsform.errors, as on any page422 in the response
Success messageInertia::flash() (page flash)the JSON you return
Best forforms that also exist on dynamic pagesstatic pages, newsletter, contact, search
php
Route::post('/newsletter', function (Request $request) {
    $data = $request->validate(['email' => ['required', 'email']]);

    Inertia::flash('newsletter', "Subscribed {$data['email']}");   // shown on static pages too

    return back();
});

Route::post('/api/newsletter', function (Request $request) {
    $data = $request->validate(['email' => ['required', 'email']]);

    return response()->json(['message' => "Subscribed {$data['email']}"]);
});
vue
<script setup lang="ts">
import { useForm, useHttp } from '@inertiajs/vue3'

const form = useForm({ email: '' })      // form.post('/newsletter')      → form.errors / $page.flash.newsletter
const http = useHttp({ email: '' })      // http.post('/api/newsletter')  → http.errors / resolved JSON
</script>

return back(), redirect()->route(...) and logout redirects to a static page all work: the visitor gets their own validation errors and Inertia flash data, while the cached page stays identical for everyone.

Flash data shared through HandleInertiaRequests::share() (for example ->with('status', ...)) is not a public shared prop, so it does not reach static pages: use Inertia::flash().

CSRF ​

Nothing to do: Inertia forms (useForm, <Form>, router.post, useHttp) work on static pages on Laravel 12 and 13.

  • Laravel 13 accepts same-origin requests without a token.
  • Laravel 12: static pages carry no CSRF cookie, so the client fetches it from /__inertia/csrf before the first submission, and Inertia sends it. Force this on or off with INERTIA_STATIC_CSRF_COOKIE (csrf_cookie).

For your own fetch() calls, use ensureCsrfCookie().

Released under the MIT License.