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:
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',
];
}// config/inertia-static.php
'public_shared_props' => ['name', 'sidebarOpen'],- Dynamic routes get every prop,
auth.userincluded, as usual. - Static routes run without session and cookies, so
$request->user()isnullthere, and only the props inpublic_shared_propsare sent:authnever reaches a cached page. Never listauth,flashor anything per-user inpublic_shared_props. - Props read from cookies (
sidebarOpenabove) 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:
<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>// routes/web.php: a regular dynamic route
Route::get('/api/me', fn (Request $request) => ['user' => $request->user()?->only('id', 'name')]);useStaticIsland()
<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>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
errorand putsdataback toinitial(e.g. a401after logout never leaves the previous user on screen). Callload()to fetch again; the latest call always wins. - Options:
when('mount'or'visible'),buffer,immediate(defaulttrue),initial,init(extrafetchoptions),refreshAfterMutations. SeeuseStaticIsland().
The endpoint is a regular dynamic route. Nothing else is needed: Laravel already answers with Cache-Control: private, which CDNs never store.
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.
<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>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.
<!-- 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>// 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 visitsForms 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 | |
|---|---|---|
| Request | Inertia visit (POST), redirect | plain JSON request |
| Validation errors | form.errors, as on any page | 422 in the response |
| Success message | Inertia::flash() (page flash) | the JSON you return |
| Best for | forms that also exist on dynamic pages | static pages, newsletter, contact, search |
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']}"]);
});<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/csrfbefore the first submission, and Inertia sends it. Force this on or off withINERTIA_STATIC_CSRF_COOKIE(csrf_cookie).
For your own fetch() calls, use ensureCsrfCookie().