Single-page applications often make the browser’s back button feel unreliable.
A user filters an orders table, opens a detail panel, visits another page, and clicks Back. The URL returns, but the search term is gone. The table jumps to the top. The detail panel disappears.
This is not a browser problem. It is a state-management problem.
Inertia 3.x gives Laravel and Vue applications a clear history model. It preserves page data, scroll positions, and selected local state across browser navigation. You still decide which state belongs in the URL, which belongs in the component, and which should never enter browser history.
This matters whether you are building a dashboard with a PHP web framework, evaluating PHP developer tools, or building a REST API with PHP and a separate frontend.
Why the back button breaks in SPAs
A traditional server-rendered page gets a new document for every navigation. The browser can restore the previous document and its scroll position when the user clicks Back.
An SPA usually keeps one document alive. JavaScript intercepts navigation, updates the URL, and changes the component tree. If the application does not save its state, the browser has little to restore beyond the address.
Common failures include:
- Filters reset after returning to a list.
- Pagination returns to page one.
- A slide-over panel closes unexpectedly.
- Form input disappears after validation.
- The browser returns to the correct URL but shows stale data.
- Long lists return to the wrong scroll position.
Inertia sits between these two models. It uses normal browser history, but stores the Inertia page object with each entry. That page object can include the component, props, remembered state, and scroll positions.
The result feels like an SPA while keeping browser navigation meaningful.

Three kinds of state
Before choosing an option, separate the state you are trying to preserve.
Component state
This is local Vue state:
- An open slide-over.
- A selected order ID.
- An unsaved form value.
- A tab selection.
- A temporary loading or expanded state.
preserveState controls whether Inertia keeps the current page component instance during a visit.
For GET requests, state is not preserved by default. You can opt in:
router.get('/orders', { search: 'acme' }, {
preserveState: true,
})
For post, put, patch, delete, and reload, Inertia enables preserveState by default. That prevents form input from disappearing after a submission or validation response.
You can also preserve state only when validation errors exist:
router.get('/orders', filters, {
preserveState: 'errors',
})
For more specific behavior, pass a callback:
router.post('/orders', form, {
preserveState: (page) => page.props.errors?.length > 0,
})
Scroll state
preserveScroll controls what happens immediately after a visit.
router.get('/orders?page=3', {}, {
preserveScroll: true,
})
This is useful when pagination or filtering should not move the user away from the current position.
Back and forward navigation work slightly differently. Inertia automatically records scroll positions and restores them when the user navigates through browser history. You do not need to manually wire a popstate handler for ordinary page scrolling.
For a scrollable table container, mark the region explicitly:
<div class="overflow-y-auto" scroll-region>
<!-- Orders table -->
</div>
See the Inertia 3.x scroll management documentation for the complete behavior.
History state
Some state must survive a component remount or a future history traversal. That is where useRemember helps.
import { useRemember } from '@inertiajs/vue3'
const state = useRemember({
search: '',
status: 'all',
selectedOrderId: null,
}, 'orders.index')
Inertia stores this state in the current history entry. When the user goes back to the page, the state is restored.
Use a unique key for each independent instance. A page with multiple order lists should not make every list share the same remembered state.
You can also use the lower-level API:
router.remember(
{ search: 'acme', selectedOrderId: 42 },
'orders.index',
)
const saved = router.restore('orders.index')
Remember only what improves navigation. Do not place passwords or sensitive tokens in remembered state.
A real example: orders with filters and a slide-over
Suppose an operations team uses an orders table with:
- Search.
- Status filters.
- Pagination.
- A slide-over detail panel.
- A long list that should retain its position.
The Laravel controller can keep the server response focused:
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OrderController extends Controller
{
public function index(Request $request): Response
{
$filters = $request->validate([
'search' => ['nullable', 'string', 'max:100'],
'status' => ['nullable', 'in:pending,paid,shipped,refunded'],
]);
$orders = Order::query()
->when($filters['search'] ?? null, function ($query, $search) {
$query->where(function ($query) use ($search) {
$query
->where('number', 'like', "%{$search}%")
->orWhere('customer_name', 'like', "%{$search}%");
});
})
->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status);
})
->latest()
->paginate(25)
->withQueryString();
return Inertia::render('Orders/Index', [
'orders' => $orders,
'filters' => $filters,
// Loaded only when explicitly requested.
'stats' => Inertia::optional(
fn () => $this->orderStats()
),
]);
}
}
The validation rules follow Laravel’s standard request validation approach. You can review the relevant Laravel validation documentation.
The Vue page can remember its local state while requesting fresh table data:
<script setup>
import { computed } from 'vue'
import { router, useRemember } from '@inertiajs/vue3'
const props = defineProps({
orders: Object,
filters: Object,
})
const state = useRemember({
search: props.filters?.search ?? '',
status: props.filters?.status ?? 'all',
selectedOrderId: null,
}, 'orders.index')
const selectedOrder = computed(() => {
return props.orders.data.find(
order => order.id === state.selectedOrderId
)
})
function applyFilters() {
router.get(route('orders.index'), {
search: state.search,
status: state.status === 'all' ? null : state.status,
}, {
only: ['orders', 'filters'],
preserveState: true,
preserveScroll: true,
replace: true,
})
}
function goToPage(url) {
router.get(url, {}, {
only: ['orders'],
preserveState: true,
preserveScroll: true,
})
}
function openOrder(order) {
state.selectedOrderId = order.id
}
function closeOrder() {
state.selectedOrderId = null
}
function refreshOrders() {
router.reload({
only: ['orders'],
})
}
</script>
Here, filter changes use replace: true. A user should not need to press Back through every intermediate search value.
Pagination uses the default history behavior. Each deliberate page change can become a history entry, so Back returns to the previous page of results.
The remembered selectedOrderId restores the slide-over when the user returns to this history entry. If the selected order is not present in the current filtered result, the UI should handle that case gracefully.

Replace versus push
Inertia visits normally add a browser history entry.
router.get('/orders?page=2')
That is the right choice for meaningful navigation, such as moving from page one to page two.
Use replace: true for transient changes:
router.get('/orders', {
search: state.search,
}, {
replace: true,
preserveState: true,
preserveScroll: true,
})
This replaces the current entry instead of adding another one.
Inertia also offers router.push() and router.replace() for client-side visits. These update the history stack without making a server request. They are useful when you already have a complete page object and want to change client-side state deliberately.
For most Laravel applications, prefer router.get() for server-backed data. It keeps filtering, authorization, pagination, and fresh records on the server.
Reload only what changed
router.reload() visits the current URL with preserveState and preserveScroll enabled.
router.reload()
That makes it useful after updating an order. The table can refresh without closing the slide-over or jumping to the top.
Use only when you know which props need fresh data:
router.reload({
only: ['orders'],
})
Use except when most props should refresh but one expensive prop should remain untouched:
router.reload({
except: ['stats'],
})
Do not treat only and preserveState as alternatives. They solve different problems:
-
onlycontrols which server props are reloaded. -
preserveStatecontrols the Vue component instance. -
preserveScrollcontrols the scroll reset. -
useRememberpersists selected local state in history.
On the Laravel side, Inertia::optional() is useful for expensive props that should load only when requested. Inertia::always() marks a prop that should be included in every response, including partial reloads. See the partial reload documentation.
Encrypt sensitive history
Inertia history entries can contain page props and remembered state. If those props include sensitive information, enable history encryption.
Globally, configure it in config/inertia.php:
return [
'history' => [
'encrypt' => true,
],
];
You can also enable it for a specific response:
use Inertia\Inertia;
public function secureReport(): Response
{
Inertia::encryptHistory();
return Inertia::render('Reports/Secure', [
'report' => $this->reportForCurrentUser(),
]);
}
Or apply the built-in middleware to a route group:
use Inertia\Middleware\EncryptHistory;
Route::middleware(EncryptHistory::class)->group(function () {
Route::get('/reports', [ReportController::class, 'index']);
});
History encryption uses the browser’s Web Crypto API and AES-GCM. It makes the stored history state unreadable as plain text in DevTools. It does not protect an application from XSS or a compromised browser session.
Avoid remembering extremely sensitive values even when encryption is enabled. For forms, exclude fields that should never enter history. On logout, consider clearing history so a user cannot recover protected pages through Back navigation.
Read the Inertia history encryption documentation for the available server-side controls.

Debug history without guessing
When Back behaves incorrectly, inspect the history stack before changing application code.
In the browser console:
console.table({
url: window.location.href,
historyLength: window.history.length,
state: window.history.state,
})
You can also observe browser history events:
window.addEventListener('popstate', (event) => {
console.log('Back or forward navigation', {
state: event.state,
url: window.location.href,
})
})
Then inspect the Network panel:
- Was the navigation a full document request?
- Did the response include
X-Inertia? - Did the request include
X-Inertia-Partial-Data? - Did the response return the prop you expected?
- Did
replace: trueoverwrite an entry you wanted to keep?
If history encryption is enabled, window.history.state may be intentionally opaque. That is expected. Log safe application state in development instead, and never add customer details or credentials to console output.
Also check the distinction between a missing server prop and a reset local ref. If the table data is correct but the panel closed, use preserveState or useRemember. If the filters are correct but the list is stale, use router.reload() or a partial reload.
Let history do its job
The back button is not a separate feature to bolt onto an Inertia application. It is part of the navigation model.
Use preserveState for component continuity. Use preserveScroll for direct visits where the current position matters. Use useRemember for local state that should return with a history entry. Use only and except to refresh server data without disturbing the rest of the page.
The community is building richer Laravel and Vue interfaces every day. When your next table, form, or dashboard respects the browser’s history, users notice the difference, even when they never think about why it works.