diff --git a/.changeset/fresh-shallows-navigate.md b/.changeset/fresh-shallows-navigate.md
new file mode 100644
index 000000000000..10c57b644436
--- /dev/null
+++ b/.changeset/fresh-shallows-navigate.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': minor
+---
+
+feat: add shallow routing to `goto` and deprecate `pushState` and `replaceState`
diff --git a/.changeset/late-lands-watch.md b/.changeset/late-lands-watch.md
new file mode 100644
index 000000000000..dee8903df837
--- /dev/null
+++ b/.changeset/late-lands-watch.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': minor
+---
+
+feat: preserve page state set through `goto(..., { state, persistState: true })` across reloads
diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md
index 09dbd8e4f178..a8180fcc7e94 100644
--- a/documentation/docs/30-advanced/67-shallow-routing.md
+++ b/documentation/docs/30-advanced/67-shallow-routing.md
@@ -1,41 +1,120 @@
---
-title: Shallow routing
+title: Page state & shallow routing
---
-As you navigate around a SvelteKit app, you create _history entries_. Clicking the back and forward buttons traverses through this list of entries, re-running any `load` functions and replacing page components as necessary.
+As you navigate around a SvelteKit app, you create _history entries_. Clicking the back and forward buttons traverses through this list of entries, re-running any `load` functions, replacing page components, and updating the scroll position and focused element as necessary.
-Sometimes, it's useful to create history entries _without_ navigating. For example, you might want to show a modal dialog that the user can dismiss by navigating back. This is particularly valuable on mobile devices, where swipe gestures are often more natural than interacting directly with the UI. In these cases, a modal that is _not_ associated with a history entry can be a source of frustration, as a user may swipe backwards in an attempt to dismiss it and find themselves on the wrong page.
+Sometimes, it's useful to create history entries _without_ performing a full navigation. We call this _shallow routing_.
-SvelteKit makes this possible with the [`pushState`]($app-navigation#pushState) and [`replaceState`]($app-navigation#replaceState) functions, which allow you to associate state with a history entry without navigating. For example, to implement a history-driven modal:
+For example, you might want to show a modal dialog that the user can dismiss by navigating back. This is particularly valuable on mobile devices, where swipe gestures are often more natural than interacting directly with the UI. In these cases, a modal that is _not_ associated with a history entry can be a source of frustration, as a user may swipe backwards in an attempt to dismiss it and find themselves on the wrong page.
+
+SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, which allows you to associate state with a history entry without navigating with the `shallow: true` option:
```svelte
+
+
{#if page.state.showModal}
history.back()} />
{/if}
```
+State is globally accessible via the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`).
+
The modal can be dismissed by navigating back (unsetting `page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically.
-## API
+You can also update the contents of the browser's URL bar during a shallow navigation:
+
+```js
+import { goto } from '$app/navigation';
+const state = { active: true };
+// ---cut---
+goto('/another/page', {
+ state,
+ shallow: true
+});
+```
+
+> [!NOTE] If the user reloads, they will land on `/another/page`, _not_ the currently rendered page — see [Caveats](#Caveats).
+
+Regardless of whether you choose to update the visible URL or not, [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'goto'` and `navigation.shallow === true`.
-The first argument to `pushState` is the URL, relative to the current URL. To stay on the current URL, use `''`.
+Once shallow routing is active, `page.shallow` becomes a `{ url, params, route }` object describing the page that _would_ be rendered if the user were to navigate there (which would happen if, for example, they reloaded the page). `page.url`, `page.params` and `page.route` continue to describe the page that is currently rendered.
+
+```svelte
+
+
-The second argument is the new page state, which can be accessed via the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`).
+
The user-visible URL is {page.shallow?.url.href ?? page.url.href}
+
The actual page you're on is {page.url.href}
+
+
+```
+
+A regular `goto` call without `shallow: true`, or a standard link click, exits shallow routing.
+
+> [!LEGACY]
+> In SvelteKit 2 this functionality was achieved using `pushState` and `replaceState`, which are now deprecated. Use `goto` with `shallow: true` instead, and use the `replace` option when replacing the current history entry.
+
+## Routing options
+
+By default, the above examples create a new navigation entry in the history stack. If you don't want that, you can replace the existing navigation entry instead:
+
+```js
+import { goto } from '$app/navigation';
+const url = new URL('https://example.com');
+const state = { showModal: true };
+// ---cut---
+goto(url, {
+ state,
+ replace: true
+});
+```
+
+By default, state set with `goto` is not restored after a reload. To change that, use `persistState`:
+
+```js
+import { goto } from '$app/navigation';
+const url = new URL('https://example.com');
+const state = { showModal: true };
+// ---cut---
+goto(url, {
+ state,
+ persistState: true
+});
+```
+
+Shallow navigations preserve the current scroll position and focused element by default. You can opt out of either behavior with `noScroll: false` or `keepFocus: false`:
+
+```js
+import { goto } from '$app/navigation';
+const url = new URL('https://example.com');
+// ---cut---
+goto(url, {
+ shallow: true,
+ noScroll: false,
+ keepFocus: false
+});
+```
-To set page state without creating a new history entry, use `replaceState` instead of `pushState`.
+> [!NOTE]
+> `page.state` is only populated after JavaScript loads, which can cause flickering UI. Use it carefully.
## Loading data for a route
@@ -46,7 +125,7 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c
```svelte
+
navigation test finish
+
active: {page.state.active ?? false}
diff --git a/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/on-navigate/[x]/+page.svelte b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/on-navigate/[x]/+page.svelte
index 72b5dd0a36e5..105d41c4b27f 100644
--- a/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/on-navigate/[x]/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/on-navigate/[x]/+page.svelte
@@ -9,6 +9,7 @@
/** @type {Omit} */
let type;
+ let shallow = false;
let called_return = false;
@@ -16,6 +17,7 @@
from = navigation.from;
to = navigation.to;
type = navigation.type;
+ shallow = navigation.shallow;
});
onNavigate(() => {
@@ -25,5 +27,7 @@
});
-