Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e612de2
breaking: adjust how `pushState/replaceState` behave
dummdidumm Jul 17, 2026
20e2079
Update packages/kit/test/apps/basics/test/client.test.js
dummdidumm Jul 17, 2026
b4d7f15
silence warning for my own sanity
dummdidumm Jul 17, 2026
02c1322
Update packages/kit/src/runtime/client/client.js
dummdidumm Jul 17, 2026
854f18e
Merge branch 'version-3' into shallow-routing-goto
dummdidumm Jul 20, 2026
80c1422
goto
dummdidumm Jul 20, 2026
f8238a9
docs tweaks
dummdidumm Jul 21, 2026
b5eaf96
fix #16457 / fix #15618
dummdidumm Jul 21, 2026
0e9d3a8
remove `goto(null, ...)`
dummdidumm Jul 22, 2026
8ee6d07
remove goto('', { shallow: true }) special-case of not triggering nav…
dummdidumm Jul 22, 2026
44d62fb
doc tweaks
dummdidumm Jul 22, 2026
9412cfc
oops
dummdidumm Jul 22, 2026
0e8a5d2
gah
dummdidumm Jul 22, 2026
a4d4bb2
gah
dummdidumm Jul 22, 2026
969531c
make tests more robust
dummdidumm Jul 22, 2026
1ffe464
Preserve focus option on popstate; take keepfocus/noscroll options in…
dummdidumm Jul 23, 2026
f1d36ab
Merge branch 'version-3' into shallow-routing-goto
dummdidumm Jul 23, 2026
d33a324
handle noscroll/keepfocus in popstate better
dummdidumm Jul 24, 2026
13cd906
Merge branch 'version-3' into shallow-routing-goto
Rich-Harris Jul 25, 2026
9e64986
tweak
Rich-Harris Jul 25, 2026
372bccb
disallow shallow routing to external pathnames — i don't see any good…
Rich-Harris Jul 25, 2026
7f93468
hopefully fix docs
Rich-Harris Jul 25, 2026
95557e7
tweaks
Rich-Harris Jul 25, 2026
e961515
more fixes
Rich-Harris Jul 25, 2026
a12b1c0
apply same internal route restriction to pushState/replaceState
Rich-Harris Jul 25, 2026
e2bb97e
no need for untrack, call is after an await
Rich-Harris Jul 25, 2026
ef4d65f
try again
Rich-Harris Jul 25, 2026
d0ef3a1
tweak docs
Rich-Harris Jul 25, 2026
39ed9b3
drive-by
Rich-Harris Jul 25, 2026
d41809b
regenerate
Rich-Harris Jul 25, 2026
fb7f126
gah come on
Rich-Harris Jul 25, 2026
db68cbd
fix
Rich-Harris Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-shallows-navigate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add shallow routing to `goto` and deprecate `pushState` and `replaceState`
5 changes: 5 additions & 0 deletions .changeset/late-lands-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: preserve page state set through `goto(..., { state, persistState: true })` across reloads
109 changes: 94 additions & 15 deletions documentation/docs/30-advanced/67-shallow-routing.md
Original file line number Diff line number Diff line change
@@ -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
<!--- file: +page.svelte --->
<script>
import { pushState } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Modal from './Modal.svelte';

function showModal() {
pushState('', {
showModal: true
goto('', {
state: { showModal: true },
shallow: true
});
}
</script>

<button onclick={showModal}>open</button>

{#if page.state.showModal}
<Modal close={() => 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
<!--- file: +page.svelte --->
<script>
import { goto } from '$app/navigation';
import { page } from '$app/state';
</script>

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`).
<p>The user-visible URL is {page.shallow?.url.href ?? page.url.href}</p>
<p>The actual page you're on is {page.url.href}</p>

<button onclick={() => goto('/shallow', { shallow: true })}>enter shallow route</button>
```

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

Expand All @@ -46,7 +125,7 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c
```svelte
<!--- file: src/routes/photos/+page.svelte --->
<script>
import { preloadData, pushState, goto } from '$app/navigation';
import { preloadData, goto } from '$app/navigation';
import { page } from '$app/state';
import Modal from './Modal.svelte';
import PhotoPage from './[id]/+page.svelte';
Expand Down Expand Up @@ -74,7 +153,7 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c
const result = await preloadData(href);

if (result.type === 'loaded' && result.status === 200) {
pushState(href, { selected: result.data });
goto(href, { shallow: true, state: { selected: result.data } });
} else {
// something bad happened! try navigating
goto(href);
Expand All @@ -96,6 +175,6 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c

## Caveats

During server-side rendering, `page.state` is always an empty object. The same is true for the first page the user lands on — if the user reloads the page (or returns from another document), state will _not_ be applied until they navigate.
Shallow routing requires JavaScript. Be mindful when using it and try to think of sensible fallback behaviour in case JavaScript isn't available.

Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available.
The server has no awareness of history state. As such, `page.state` is an empty object during SSR, and a reload will navigate directly to the prior `page.shallow.url` if it exists. In other words, if `page.shallow.url.pathname` is `/photos/123` before the reload, after the reload `page.url.pathname` will be `/photos/123` and `page.shallow` will be `null`, regardless of the `persistState` option. (This only applies to the initial page load, to avoid a flickering UI when the client app starts.)
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ A `string[]` array of prerendered pages. Empty during dev. Use [`prerendered`]($

## version

The value of [`config.version.name`](configuration#version), used for populating caches. Use [`version`]($app/env#version) from `$app/env` instead.
The value of [`config.version.name`](configuration#version), used for populating caches. Use [`version`]($app-env#version) from `$app/env` instead.
56 changes: 55 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,47 @@ export interface NavigationTarget<
scroll: { x: number; y: number } | null;
}

export interface GotoOptions {
/**
* If `true`, replaces the current history entry rather than creating a new one.
* @default false
*/
replace?: boolean;
/** @deprecated Use `replace` instead. */
replaceState?: boolean;
/**
* If `true`, updates the URL and `page.state` without navigating.
* @default false
*/
shallow?: boolean;
/**
* If `true`, preserves the browser's scroll position.
* @default false, or true when `shallow` is true
*/
noScroll?: boolean;
/**
* If `true`, keeps the currently focused element focused.
* @default false, or true when `shallow` is true
*/
keepFocus?: boolean;
/**
* If `true`, reruns all `load` functions and queries of the page.
* @default false
*/
refreshAll?: boolean;
/** Causes any `load` functions to rerun if they depend on one of the URLs. */
invalidate?: Array<string | URL | ((url: URL) => boolean)>;
/** @deprecated Use `refreshAll` instead. */
invalidateAll?: boolean;
/** An optional object that will be available as `page.state`. */
state?: App.PageState;
/**
* If `true`, `page.state` will be restored after a full page reload.
* @default false
*/
persistState?: boolean;
}

/**
* - `enter`: The app has hydrated/started
* - `form`: The user submitted a `<form method="GET">`
Expand All @@ -1258,6 +1299,8 @@ export interface NavigationBase {
* - `popstate`: Navigation was triggered by back/forward navigation
*/
type: NavigationType;
/** Whether this is a shallow navigation. */
shallow: boolean;
/**
* Where navigation was triggered from
*/
Expand Down Expand Up @@ -1426,9 +1469,20 @@ export interface Page<
*/
data: App.PageData & Record<string, any>;
/**
* The page state, which can be manipulated using the [`pushState`](https://svelte.dev/docs/kit/$app-navigation#pushState) and [`replaceState`](https://svelte.dev/docs/kit/$app-navigation#replaceState) functions from `$app/navigation`.
* The page state, which can be manipulated using [`goto`](https://svelte.dev/docs/kit/$app-navigation#goto) from `$app/navigation`.
*/
state: App.PageState;
/**
* Information about the target of the current shallow navigation, or `null` if no shallow navigation has occurred.
*/
shallow: {
/** Parameters of the target route, or `null` if the URL does not resolve to a route. */
params: AppLayoutParams<'/'> | null;
/** Info about the target route, or `null` if the URL does not resolve to a route. */
route: { id: AppRouteId } | null;
/** The normalized URL passed to `goto(..., { shallow: true })`. */
url: ReadonlyURL;
} | null;
/**
* Filled only after a form submission. See [form actions](https://svelte.dev/docs/kit/form-actions) for more info.
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/runtime/app/state/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const page = {
get route() {
return _page.route;
},
get shallow() {
return _page.shallow;
},
get state() {
return _page.state;
},
Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/runtime/app/state/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { BROWSER } from 'esm-env';
* A read-only reactive object with information about the current page, serving several use cases:
* - retrieving the combined `data` of all pages/layouts anywhere in your component tree (also see [loading data](https://svelte.dev/docs/kit/load))
* - retrieving the current value of the `form` prop anywhere in your component tree (also see [form actions](https://svelte.dev/docs/kit/form-actions))
* - retrieving the page state that was set through `goto`, `pushState` or `replaceState` (also see [goto](https://svelte.dev/docs/kit/$app-navigation#goto) and [shallow routing](https://svelte.dev/docs/kit/shallow-routing))
* - retrieving metadata such as the URL you're on, the current route and its parameters, and whether or not there was an error
* - retrieving the page state that was set through `goto` (also see [goto](https://svelte.dev/docs/kit/$app-navigation#goto) and [shallow routing](https://svelte.dev/docs/kit/shallow-routing))
* - retrieving metadata such as the URL you're on, the current route and its parameters, the target of a shallow navigation, and whether or not there was an error
*
* ```svelte
* <!--- file: +layout.svelte --->
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/runtime/app/state/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export const page = {
get route() {
return (DEV ? context_dev('page.route') : context()).page.route;
},
get shallow() {
return (DEV ? context_dev('page.shallow') : context()).page.shallow;
},
get state() {
return (DEV ? context_dev('page.state') : context()).page.state;
},
Expand Down
Loading