SvelteKit Navigation

In many SPA frameworks, navigation is something you wire up manually. You import a <Link> component, you register routes with a router object, and you call imperative navigate() functions whenever the URL needs to change programmatically. Miss any of these and either the browser does a full reload or nothing happens.

SvelteKit takes a fundamentally different position: the platform already knows how navigation works. The browser has been navigating between documents for thirty years. Standard <a> tags, the history API, and form submissions are all first-class web primitives. SvelteKit’s job is to enhance them, not replace them.

enhancement, not replacement

SvelteKit’s philosophy of progressive enhancement means your navigation works even when JavaScript fails. This isn’t just about edge cases—it’s about building robust applications. Always start with standard HTML, then enhance with JavaScript.


The Standard Anchor Tag

To link to another page in SvelteKit, you just use standard HTML:

<!-- Standard HTML works perfectly -->
<a href="/about">About Us</a>
<a href="/blog">Blog</a>
<a href="/contact">Contact</a>

This is better for several reasons:

  • Accessibility: Screen readers and assistive technologies understand <a> tags perfectly
  • Resilience: If JavaScript fails to load, the link still works (it just performs a full page reload)
  • Simplicity: You don’t need to import a special component just to make a link
  • SEO: Search engines understand and follow standard links

Client-Side Navigation

Even though you write standard HTML, SvelteKit upgrades these links automatically. When JavaScript is loaded and a user clicks an internal link:

  1. Interception: SvelteKit’s client-side router catches the click event
  2. Prevention: It prevents the browser’s default full-page reload
  3. Fetching: It fetches only the data and code needed for the next page
  4. Swapping: It swaps the current page component for the new one, keeping layouts intact

This upgrade is transparent and progressive. If JavaScript hasn’t loaded yet or fails entirely, the link still works — it just performs a full-page reload. Your navigation degrades gracefully rather than breaking. That’s a meaningful difference: the failure mode of a standard link is a slightly slower page load, not a broken page.


Preloading

You can make your app feel even faster by telling SvelteKit to start loading the next page before the user even clicks.

Preloading on Hover

Add the data-sveltekit-preload-data attribute to your <body> tag (in src/app.html):

<!-- src/app.html -->
<body data-sveltekit-preload-data="hover">
	<div style="display: contents">%sveltekit.body%</div>
</body>

Now, when a user hovers their mouse over a link, SvelteKit begins fetching the data for that page immediately. By the time the user clicks (usually 100-200ms later), the data is often already there, making the transition feel instantaneous.

Preloading Options

ValueBehavior
hoverPreload when mouse hovers over link
tapPreload when user starts pressing (touch or mouse)
offDisable preloading

You can also control preloading on individual links:

<!-- Always preload this important link on hover -->
<a href="/pricing" data-sveltekit-preload-data="hover">Pricing</a>

<!-- Don't preload this link (maybe it's expensive) -->
<a href="/reports" data-sveltekit-preload-data="off">Reports</a>

<!-- Only preload the code, not the data -->
<a href="/dashboard" data-sveltekit-preload-code>Dashboard</a>
Preload Code Only

Use data-sveltekit-preload-code for routes with heavy JavaScript bundles. This downloads the code on hover but waits to fetch the data until click, balancing performance with data costs.


Building a Navigation Component

Let’s build a reusable navigation component that highlights the current page:

<!-- src/lib/components/Navigation.svelte -->
<script lang="ts">
	import { page } from '$app/state'
</script>

<nav>
	<a href="/" aria-current={page.url.pathname === '/' ? 'page' : undefined}> Home </a>
	<a href="/about" aria-current={page.url.pathname === '/about' ? 'page' : undefined}> About </a>
	<a href="/blog" aria-current={page.url.pathname.startsWith('/blog') ? 'page' : undefined}>
		Blog
	</a>
	<a href="/contact" aria-current={page.url.pathname === '/contact' ? 'page' : undefined}>
		Contact
	</a>
</nav>

<style>
	nav {
		display: flex;
		gap: 1rem;
		padding: 1rem;
		background: var(--surface-2);
	}

	a {
		text-decoration: none;
		color: var(--text);
		padding: 0.5rem 1rem;
		border-radius: 4px;
		transition: background-color 0.2s;
	}

	a:hover {
		background: var(--surface-3);
	}

	/* aria-current is the accessible way to style active links */
	a[aria-current='page'] {
		background: var(--brand);
		color: white;
	}
</style>

The page object from $app/state (new in Svelte 5) gives you information about the current page, including the URL. We use this to highlight the active navigation item.


Programmatic Navigation with goto()

Sometimes you need to navigate in response to an action—after a form submission, when a timer expires, or when some condition is met. Use the goto function:

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

	async function handleLogin() {
		const success = await login()

		if (success) {
			// Navigate to dashboard
			goto('/dashboard')
		}
	}
</script>

<button onclick={handleLogin}>Log In</button>

goto() Options

import { goto } from '$app/navigation'

// Basic navigation
goto('/about')

// Replace history entry (back button won't return here)
goto('/wizard/step-2', { replaceState: true })

// Prevent scroll reset
goto('/blog/page/2', { noScroll: true })

// Invalidate all load functions and refetch
goto('/dashboard', { invalidateAll: true })

Redirects in Load Functions

For server-side redirects, use the redirect helper in your load functions:

// src/routes/admin/+page.server.ts
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
	if (!locals.user) {
		// 303 = redirect after form submission
		// 307 = temporary redirect
		// 308 = permanent redirect
		redirect(303, '/login')
	}

	return {
		user: locals.user
	}
}

Links to external sites work normally and won’t be intercepted:

<!-- External links open normally -->
<a href="https://svelte.dev">Svelte Docs</a>

<!-- Force a full page reload for internal link -->
<a href="/legacy-page" data-sveltekit-reload>Legacy Page</a>

<!-- Open in new tab -->
<a href="https://github.com" target="_blank" rel="noopener">GitHub</a>

You can listen to navigation events for analytics, loading indicators, or other side effects:

<script lang="ts">
	import { beforeNavigate, afterNavigate } from '$app/navigation'

	beforeNavigate(({ from, to, cancel }) => {
		// Runs before leaving current page
		if (hasUnsavedChanges && !confirm('Discard changes?')) {
			cancel()
		}
	})

	afterNavigate(({ from, to, type }) => {
		// Runs after arriving at new page
		// type can be 'link', 'popstate', 'goto', etc.
		analytics.track('page_view', { path: to?.url.pathname })
	})
</script>
When to Use Navigation Hooks

Use beforeNavigate to warn users about unsaved changes, and afterNavigate for analytics tracking or scroll restoration logic.


The page State Object

The page object from $app/state contains useful information:

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

<p>Current path: {page.url.pathname}</p>
<p>Search params: {page.url.searchParams.toString()}</p>
<p>Route ID: {page.route.id}</p>
<p>Route params: {JSON.stringify(page.params)}</p>

{#if page.error}
	<p>Error: {page.error.message}</p>
{/if}

{#if page.data.user}
	<p>Logged in as: {page.data.user.name}</p>
{/if}

State Preservation (Snapshots)

When a user navigates away from a page and clicks “back”, they expect the page to be exactly as they left it — scroll position restored, form inputs intact. SvelteKit handles scroll restoration automatically, but reactive component state (like text typed into a search box) does not survive a full component unmount and remount by default. Snapshots solve this.

A snapshot is a pair of functions: capture runs when the component unmounts (preserving state into session history), and restore runs when the component remounts from history (reinstating that state).

In Svelte 5, snapshot is exported from the regular instance <script> using export const. This is one of the non-prop exports that Svelte 5 preserves — export let for declaring props was replaced by $props(), but export const for exposing values on the component instance still works. The capture and restore functions close over the reactive search state in the same script block:

<script lang="ts">
	import type { Snapshot } from './$types'

	let search = $state('')

	export const snapshot: Snapshot<string> = {
		capture: () => search,
		restore: (value) => {
			search = value
		}
	}
</script>

<input bind:value={search} placeholder="Search..." />

When capture() is called before navigating away, it reads the current value of search. When restore(value) is called on back-navigation, it writes back to search and Svelte’s reactivity updates the input automatically. The state is stored in the browser’s session history entry, so it survives back-navigation but is discarded when the tab is closed.

When to Use Snapshots

Snapshots are particularly useful for search interfaces, filters, pagination state, and any ephemeral UI state that enhances user experience when navigating back.


Common Patterns

Create a reusable function for determining if a link is active:

// src/lib/utils/navigation.ts
export function isActive(pathname: string, href: string, exact = false): boolean {
	if (exact) {
		return pathname === href
	}
	return pathname === href || pathname.startsWith(href + '/')
}
<script lang="ts">
	import { page } from '$app/state'
	import { isActive } from '$lib/utils/navigation'
</script>

<a href="/blog" aria-current={isActive(page.url.pathname, '/blog') ? 'page' : undefined}> Blog </a>
<script lang="ts">
	import { page } from '$app/state'

	// Convert /blog/posts/my-article to breadcrumb segments
	let segments = $derived(
		page.url.pathname
			.split('/')
			.filter(Boolean)
			.map((segment, index, arr) => ({
				name: segment.replace(/-/g, ' '),
				href: '/' + arr.slice(0, index + 1).join('/')
			}))
	)
</script>

<nav aria-label="Breadcrumb">
	<ol>
		<li><a href="/">Home</a></li>
		{#each segments as segment, i}
			<li>
				{#if i === segments.length - 1}
					<span aria-current="page">{segment.name}</span>
				{:else}
					<a href={segment.href}>{segment.name}</a>
				{/if}
			</li>
		{/each}
	</ol>
</nav>

Conclusion

SvelteKit’s navigation system transforms the traditional multi-page application experience into something that feels like a single-page app while preserving the web’s foundational benefits. By automatically upgrading standard <a> tags to client-side navigation, it provides instant transitions without requiring developers to learn new link components or routing APIs. The intelligent preloading system makes navigation feel instantaneous, while the goto() function and navigation hooks provide programmatic control when needed.

Mastering navigation in SvelteKit means understanding the balance between automatic enhancement and manual control. Standard links provide the best user experience for most cases, preloading eliminates perceived latency, and navigation events enable analytics and guards without compromising the core navigation flow. Combined with snapshot preservation for component state and the reactive page store for URL-based derived state, SvelteKit provides everything needed to build applications that feel fast, responsive, and native-like while remaining fundamentally web-based.

Key Takeaways

  • Client-side navigation is automatic - standard <a href="/path"> tags are intercepted and upgraded to SPA-style navigation without full page reloads
  • Preloading eliminates perceived latency using data-sveltekit-preload-data on links to fetch route data on hover, making navigation feel instant
  • goto() enables programmatic navigation for form submissions, redirects after actions, or conditional routing based on application state
  • redirect() in load functions handles server-side redirects during SSR or data loading, throwing a redirect response that SvelteKit processes automatically
  • page from $app/state provides reactive URL state with url, params, route, status, and data properties accessible from any component without prop drilling
  • Navigation hooks enable lifecycle control - beforeNavigate for guards/confirmations, onNavigate for View Transitions, afterNavigate for analytics/tracking
  • Snapshots preserve transient state across navigation using snapshot export, capturing form inputs, scroll positions, or component state that shouldn’t be lost
  • Preload strategies offer granular control - hover, tap, viewport, or off on individual links, with global defaults in app.html via data attributes

See Also