SvelteKit goto

Aug 29, 2026 SvelteKit

goto from $app/navigation lets a SvelteKit page change the URL from JavaScript. The client router imports the next page, runs load, and updates the address bar. It does not send a 3xx, so crawlers and browsers without JavaScript never see the navigation.

If you already know the href, use an <a>. It works without JavaScript and lets the browser show the URL in the status bar on hover. If the current URL should never render, use redirect() in load, an action, or a hook.

Use goto in a click or submit handler when you only know the destination after the event. This happens with a search result or after a login fetch, as in SPA protected routes.

The examples use Svelte 5 runes and SvelteKit 2 option names. If sv create gave you SvelteKit 3, $lib may be #lib, and several goto options have different names. The SvelteKit 3 migration guide covers the changes.

The finished example has these files:

src/routes/
├── +layout.svelte
├── +page.svelte
├── login/
│   └── +page.svelte
└── products/
    ├── +page.server.ts
    └── +page.svelte

Step 1: create the project

Reuse the shop from routing if you have it. Otherwise install Node 18 or newer and run the Svelte CLI:

npx sv create shop
cd shop
npm install
npm run dev

Pick TypeScript. Skip the add-ons.

Open http://localhost:5173. Vite is the dev server.

Replace src/routes/+layout.svelte:

<script lang="ts">
  import { navigating } from '$app/state';

  let { children } = $props();
</script>

<nav>
  <a href="/">Home</a>
  <a href="/products">Products</a>
  <a href="/login">Staff</a>
</nav>

{#if navigating.to}
  <p>Opening {navigating.to.url.pathname}...</p>
{/if}

{@render children()}

navigating from $app/state is set during a client navigation, including one started by goto. When the app is idle, navigating.to is null. Products and Staff remain <a> links because their hrefs are already known.

Replace src/routes/+page.svelte:

<h1>Northside Hardware</h1>
<p>Search the catalog, or open Products.</p>

Create src/routes/products/+page.server.ts:

import type { PageServerLoad } from './$types';

const catalog = [
  { name: 'Claw hammer' },
  { name: 'Paint roller' },
  { name: 'Wood screws' }
];

export const load: PageServerLoad = ({ url }) => {
  const q = url.searchParams.get('q')?.trim() ?? '';
  const needle = q.toLowerCase();
  const products = catalog.filter((item) =>
    item.name.toLowerCase().includes(needle)
  );
  return { products, q };
};

URLSearchParams gives you the query string. An empty q matches every name because includes('') is true.

Create src/routes/products/+page.svelte:

<script lang="ts">
  let { data }: { data: { products: { name: string }[]; q: string } } =
    $props();
</script>

<h1>Products</h1>

{#if data.q}
  <p>Filter: {data.q}</p>
{/if}

{#if data.products.length === 0}
  <p>Nothing matched.</p>
{:else}
  <ul>
    {#each data.products as product (product.name)}
      <li>{product.name}</li>
    {/each}
  </ul>
{/if}

Visit http://localhost:5173/products to see all three products. Add ?q=paint in the address bar, and only the roller remains.

Step 2: search with a GET form

The home page needs a search form. Its destination is /products?q=..., which a GET form can build without goto:

Replace src/routes/+page.svelte:

<h1>Northside Hardware</h1>

<form method="GET" action="/products">
  <label>
    Search
    <input name="q" />
  </label>
  <button type="submit">Search</button>
</form>

Submit hammer and the browser opens /products?q=hammer with the claw hammer. Disable JavaScript in the inspector and submit again. The form still works, which is why a GET form is the better starting point.

There is no reason to use goto yet. Keep the GET form until you need behavior that HTML does not provide.

Step 3: goto when you need the options

Now add search to /products itself. A GET form pushes a history entry on every submit, so five corrections take five presses of the Back button to undo. The replaceState option on goto replaces the current entry instead.

For this search, the input should keep focus and the list should stay at its current scroll position. The relevant options are keepFocus and noScroll.

Replace src/routes/products/+page.svelte:

<script lang="ts">
  import { goto } from '$app/navigation';

  let { data }: { data: { products: { name: string }[]; q: string } } =
    $props();

  function search(event: SubmitEvent) {
    event.preventDefault();
    const form = event.currentTarget as HTMLFormElement;
    const next = String(new FormData(form).get('q') ?? '').trim();
    const href = next ? `/products?q=${encodeURIComponent(next)}` : '/products';
    goto(href, { keepFocus: true, noScroll: true, replaceState: true });
  }
</script>

<h1>Products</h1>

<form onsubmit={search}>
  <label>
    Search
    <input name="q" value={data.q} />
  </label>
  <button type="submit">Search</button>
</form>

{#if data.products.length === 0}
  <p>Nothing matched.</p>
{:else}
  <ul>
    {#each data.products as product (product.name)}
      <li>{product.name}</li>
    {/each}
  </ul>
{/if}

FormData reads the current input value. After load returns, value={data.q} fills the field from the URL. The example does not use bind:value, so Back and Forward cannot clash with an old $state value.

Search for paint, then screw, then clear the box and submit again. Pressing Back now skips those filters and returns to the page you visited before /products.

encodeURIComponent is required. A search for wood & nails without it splits the query string.

goto returns a Promise. It resolves when navigation finishes and rejects on failure. The search does not need to wait for it. The login example does.

Step 4: replaceState after login

A login form that calls another API with fetch cannot use redirect() because the browser has already loaded the page. Once the API returns a token, goto can open the authenticated area. This is the client redirect used in SPA protected routes.

Do not copy this approach for a real session if you have a SvelteKit server. Set an HttpOnly cookie in an action and call redirect(303) there.

This is a toy login with demo as the password. The useful part is replaceState: true, which keeps the password page out of the Back history.

Create src/routes/login/+page.svelte:

<script lang="ts">
  import { goto } from '$app/navigation';

  let password = $state('');
  let error = $state('');

  async function submit(event: SubmitEvent) {
    event.preventDefault();

    if (password !== 'demo') {
      error = 'Wrong password';
      return;
    }

    await goto('/products', { replaceState: true });
  }
</script>

<h1>Staff login</h1>

<form onsubmit={submit}>
  <label>
    Password
    <input name="password" type="password" bind:value={password} />
  </label>

  {#if error}
    <p>{error}</p>
  {/if}

  <button type="submit">Log in</button>
</form>

Open /login, type demo, and submit. The app opens /products, and Back skips the login page.

Wrong password stays on /login with the error. goto never runs.

Options

These are the Kit 2 option names from the goto docs.

replaceState replaces the current history entry. It is useful after login or when repeated query string changes should not pile up in the history.

keepFocus leaves focus where it is, which suits an in-place filter. Do not use it when navigation removes the focused element because screen reader users can lose their place. Without this option, SvelteKit focuses <body> or an autofocus element, matching a full page load. See focus management.

noScroll preserves the scroll position. By default, navigation jumps to the top or to a #hash target.

invalidateAll reruns every load on the current page. Pass it when you call goto with the same URL but still need fresh data.

invalidate is the same idea for one dependency, the same argument list as invalidate().

state sets page.state for shallow routing. Kit 2 usually handles this with pushState or replaceState. Kit 3 moves it into goto(..., { shallow: true, state }).

Kit 3 renames:

Kit 2Kit 3
replaceStatereplace
keepFocus: true and noScroll: truereset: false
invalidateAllrefreshAll

Kit 3 also rejects a goto URL that is not a route in your app.

goto vs redirect() vs <a> vs window.location

Use <a href> when the href is already in the markup.

Use goto when a click, submit, or fetch determines the destination. It only handles same-origin URLs.

Use redirect() on the server. It throws, then SvelteKit responds with a 3xx and a Location header. It works in +page.server.ts, actions, hooks.server.ts, and +server.ts. Calling goto in those files fails because $app/navigation is browser code.

For another origin, assign window.location:

window.location.href = 'https://example.com';

Do not call goto('https://example.com'). Kit 3 refuses it, while Kit 2 may treat it as an in-app path and return a 404.

If you set paths.base so the app lives under /shop, a root-relative goto('/products') misses the prefix. Prepend base from $app/paths, or use the path helper your Kit version documents.

Things that trip people up

Calling goto during SSR throws. A load in +page.ts runs on the server for the first request, so the SPA example sets ssr = false before calling goto in a layout load. If you have a server, use redirect() in +page.server.ts.

A try/catch around await goto(...) is fine because the promise can reject. Catching redirect() is different. It throws a value that SvelteKit must receive.

Call goto('.') or goto(page.url) with invalidateAll: true to refresh the current page while keeping the layout mounted. location.reload() reloads the whole document.

Do not replace a plain <a href="/products"> with <button onclick={() => goto('/products')}>. The button loses middle-click, “open in new tab”, and the no-JavaScript fallback. This mistake still turns up often in code reviews.

What to add next

The SvelteKit tutorial uses <a> for links and redirect(303) after a form POST. That is the right pair for a server-rendered app.

Before putting the login example on a network, replace it with HttpOnly cookie auth. The action then calls redirect(303). Keep goto for an SPA where a separate API owns the session.