The Three URL Inputs

A load function that always returns the same data regardless of context is not particularly useful. Real applications need to respond to the current URL: which blog post is being read, which page of a product listing is active, which category is selected, which search query was entered. All of that information is already present in the URL — SvelteKit’s job is to hand it to your load function in a structured, typed form.

Every load function receives several request/event APIs (such as fetch, parent, depends, and untrack) plus three URL-specific inputs. This article focuses on those URL-specific inputs because each answers a different question:

  • what resource is being requested
  • how does the user want to view it
  • which type of route is currently active

Understanding all three, and knowing which to reach for in which situation, is what separates load functions that merely work from load functions that work efficiently and correctly under all navigation conditions.

For the non-URL load APIs, see Fetch in Load Functions, Using Parent Data in Load Functions, Cookies and Headers in Server Load, and Rerunning Load Functions and Dependency Tracking.

This article introduces each input, explains a subtle but important constraint around hash fragments that catches developers by surprise, and brings everything together in a complete product listing with category filtering and pagination. You will see why URL-driven state is preferable to component memory for anything that should be shareable, bookmarkable, or server-rendered.


The URL as Input

Imagine building a product listing page where users can filter by category and page through results. The question is not whether to use reactive state to track the current filter selection, of course you will. The question is where that state lives.

Component-level reactive state is the right home for ephemeral, session-local UI interactions: whether a dropdown is open, which accordion panel is expanded, what the user has typed but not yet submitted. This kind of state has no meaning outside the current session and no reason to exist in a URL.

Filter selections and pagination positions are a different category of state entirely. They define a specific, meaningful view of your data that should survive a page refresh, one that a colleague should be able to open directly from a shared link, one that the browser back button should be able to restore, and one that search engines should be able to index as a distinct URL.

When state like this lives only in component memory, it fails all of those requirements silently. The user refreshes: the filter is gone. They share the URL: the recipient sees the default view. They press back: the pagination resets. Bots crawl: only the first unfiltered page is ever discovered.

The solution is to encode view-defining state in the URL from the start, then read it back in the load function on every navigation. A URL like /products?category=tools&page=2 is bookmarkable, shareable, crawler-visible, and history-aware at zero extra cost. Those properties come for free from the browser’s handling of URLs, not from any framework magic.

The only work required is knowing how to read query parameters correctly inside a load function, which is exactly what this article covers.


The Three Inputs

Every load function receives three URL-related inputs, all derived from the same source, the current request URL, but each exposing a different slice of it.

params

params answers the question: which specific resource is being requested?

It contains the resolved values of every dynamic segment in the route. When a route is defined as src/routes/blog/[slug] and the user visits /blog/my-first-post, SvelteKit extracts slug from the URL and places it in params as params.slug = 'my-first-post'. The type of params is generated automatically from the route directory names, so TypeScript knows exactly which keys are valid for any given route.

url

url answers the question: how does the user want to view it?

It is a standard web URL instance, the full URL already parsed, with typed properties for pathname, origin, hostname, and search parameters. You never split strings or call new URL() manually; it arrives ready to read. The most important property for data loading is url.searchParams, which gives structured access to the query string.

route

route answers the question: what kind of page is this?

It carries information about the route structure itself, independent of the specific values in the current URL. Its route.id property is the route pattern relative to src/routes, with dynamic segment names in their original bracket form. For the same URL above, route.id would be /blog/[slug], while params.slug would be 'my-first-post'. The distinction matters when you need to reason about which type of page is active — for analytics, logging, or conditional logic — rather than which specific resource.

Putting those three together, a URL like /shop/tools?page=2&q=hammer#featured maps to the inputs like this:

/shop/tools?page=2&q=hammer#featured
      │      │             │
      │      │             └── url.hash → "#featured"
      │      │                 (empty string on server — browser only)
      │      └── url.searchParams.get('page') → "2"
      │          url.searchParams.get('q')    → "hammer"
      └── params.category → "tools"
          (from route pattern /shop/[category])

url.pathname  → "/shop/tools"
route.id      → "/shop/[category]"   (the pattern, not the value)
Loading diagram...

One thing to notice immediately: url.hash is annotated as empty on the server. Hash fragments, means everything after # in a URL, are stripped by the browser before the HTTP request is sent. The server simply never receives them. Because load functions run on the server during SSR, url.hash will always be an empty string in that context. This is not a SvelteKit limitation; it is how the web protocol works. The practical implications and the correct workaround are covered in Step 4 of the implementation section.


Before the implementation steps, a quick orientation for which input to reach for:

Loading diagram...

Implementation

Step 1: Reading Dynamic Route Segments with params

The most common use of URL data in a load function is reading route parameters. Any directory name in src/routes surrounded by square brackets defines a dynamic segment, and its current value is available in params under the same name.

// src/routes/blog/[slug]/+page.server.ts

import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ params, fetch }) => {
	// params.slug is typed as string because the route directory
	// is named [slug]. SvelteKit generates this type for you from
	// the route structure; no manual type annotation is needed.
	const response = await fetch(`/api/posts/${params.slug}`)

	if (!response.ok) {
		error(404, 'Post not found')
	}

	const post = await response.json()

	return { post }
}

The TypeScript type of params is generated by SvelteKit based on the route directory names. For src/routes/blog/[slug], it is { slug: string }. For src/routes/shop/[category]/[productId], it is { category: string; productId: string }. This type safety is available immediately through the ./$types import and requires no manual work on your part.

SvelteKit also supports rest parameters for routes that need to capture multiple path segments as a single value. A directory named [...path] will match any number of segments after that point in the URL and collect them as a single string.

// src/routes/docs/[...path]/+page.server.ts

import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ params, fetch }) => {
	// For the URL /docs/sveltekit/routing/overview,
	// params.path would be 'sveltekit/routing/overview'.
	// Splitting on '/' gives the individual segments.
	const segments = params.path.split('/')

	// The path is passed as a query string so the API endpoint
	// can handle variable-depth routes without its own routing logic.
	const response = await fetch(`/api/docs?path=${encodeURIComponent(params.path)}`)

	if (!response.ok) {
		error(404, `Documentation page not found: ${params.path}`)
	}

	const page = await response.json()

	return {
		page,
		// Pass the segments back so the component can render breadcrumbs.
		breadcrumbs: segments
	}
}

Rest parameters are particularly useful for documentation systems, file browsers, and any route where the depth of the URL hierarchy is variable rather than fixed.

Step 2: Reading Query Parameters with url.searchParams

Where params represents the identity of the resource being requested, query parameters represent the optional configuration of how you want to view it. Filters, sort orders, page numbers, and search terms are all natural fits for query parameters.

SvelteKit exposes them through url.searchParams, which is a standard URLSearchParams instance. The key method is .get(name), which returns the string value of that parameter or null if it is absent from the URL.

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

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

export const load: PageServerLoad = async ({ url, fetch }) => {
	// .get() returns string | null. Use nullish coalescing to supply
	// a safe default when the parameter is not present in the URL.
	const category = url.searchParams.get('category')
	const search = url.searchParams.get('q')
	const page = parseInt(url.searchParams.get('page') ?? '1', 10)
	const pageSize = 24

	// Forward the validated query parameters to the API endpoint.
	// The API is responsible for filtering, pagination, and counting.
	const apiParams = new URLSearchParams()
	if (category) apiParams.set('category', category)
	if (search) apiParams.set('q', search)
	apiParams.set('page', String(page))
	apiParams.set('pageSize', String(pageSize))

	const response = await fetch(`/api/products?${apiParams}`)
	const { products, totalCount } = await response.json()

	return {
		products,
		pagination: {
			page,
			pageSize,
			totalCount,
			totalPages: Math.ceil(totalCount / pageSize)
		},
		// Return the active filter values so the component can reflect
		// them in its UI without re-parsing the URL on the client side.
		activeFilters: { category, search }
	}
}

There is an important nuance in how SvelteKit tracks query parameter access for its load function rerun system. When your load function calls url.searchParams.get('category'), SvelteKit registers a dependency on that specific parameter. If the user changes the category and navigates to a new URL, SvelteKit knows to rerun this load function because something it depends on changed. If the user changes an unrelated parameter that this load function never reads, SvelteKit will not rerun it unnecessarily.

This precision is only possible when you use searchParams.get(), searchParams.getAll(), or searchParams.has() for individual parameters. If you instead read url.search as a raw string and parse it manually, SvelteKit creates a broad dependency on the entire query string, which means any query parameter change triggers a rerun even for ones this function does not care about. Always use searchParams.get() for individual parameters; it is both more readable and more efficient.

Step 3: Using route.id for Analytics and Conditional Logic

The route object carries information about the route structure rather than the specific URL values. Its route.id property is the route path relative to src/routes, expressed with dynamic segment names in their original bracket form.

// src/routes/admin/[section]/[itemId]/+page.server.ts

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

export const load: PageServerLoad = async ({ route, params }) => {
	// For the URL /admin/users/42:
	// route.id is '/admin/[section]/[itemId]'
	// params.section is 'users'
	// params.itemId is '42'
	console.log(route.id) // '/admin/[section]/[itemId]'
	console.log(params.section) // 'users'
	console.log(params.itemId) // '42'

	return {}
}

The practical value of route.id is in shared utilities that need to identify which route is active without caring about the specific values in that route. Analytics and logging are the clearest examples. When you track page views, you typically want two pieces of data: the structural route (which tells you the kind of page visited, such as a blog post or a product page) and the specific resource (which tells you exactly which post or product). Aggregating by route.id lets you answer “how many times was the blog post view visited in total” because every blog post shares the same route.id of /blog/[slug], even though each has a different params.slug.

// src/lib/server/analytics.ts

// A thin wrapper that posts view events to an internal analytics endpoint.
// Keeping this in $lib/server/ ensures it is never bundled for the browser.
export async function trackView(
	fetch: typeof globalThis.fetch,
	routeId: string | null,
	params: Record<string, string>
): Promise<void> {
	// routeId identifies the type of page: '/blog/[slug]', '/products/[id]', etc.
	// params records which specific resource was viewed.
	await fetch('/api/analytics/views', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({
			route: routeId ?? 'unknown',
			params,
			visitedAt: new Date().toISOString()
		})
	})
}
// src/routes/blog/[slug]/+page.server.ts

import type { PageServerLoad } from './$types'
import { trackView } from '$lib/server/analytics'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ route, params, fetch }) => {
	// Run analytics tracking and the post query concurrently.
	// Neither depends on the result of the other, so there is no
	// reason to wait for one before starting the other.
	const [postResponse] = await Promise.all([
		fetch(`/api/posts/${params.slug}`),
		trackView(fetch, route.id, params)
	])

	if (!postResponse.ok) {
		error(404, 'Post not found')
	}

	const post = await postResponse.json()

	return { post }
}

Running analytics tracking in parallel with the main data fetch keeps the page load time unaffected by the analytics write. The post query and the analytics insert start at the same time and the load function waits for both to complete before returning.

Step 4: Why url.hash Is Unavailable on the Server

Before building the complete example, it is worth understanding the hash constraint explicitly rather than discovering it through a puzzling bug.

When a browser makes an HTTP request, the hash portion of the URL, everything after the # character, is intentionally stripped before the request is sent. This is specified behaviour that dates back to the original design of HTTP. The server never receives the hash; it only exists in the browser. The original purpose of hashes was to point to anchor elements within an already-loaded document, so there was no need to send them to the server at all.

Because SvelteKit load functions run on the server during SSR, url.hash will always be an empty string in that context. It is populated correctly when the load function runs in the browser during client-side navigation, but you cannot rely on it during the first render.

// AVOID: This will silently return '' during SSR.
// The tab will appear correct after client-side hydration but not
// during the initial server render, causing a hydration mismatch.

export const load: PageLoad = async ({ url }) => {
	const activeTab = url.hash.replace('#', '') || 'overview'
	return { activeTab }
}

The correct approach is to either convert hash-based state to a query parameter (which the server can see), or handle the hash in the component after mount using $app/state.

<!-- PREFERRED: Read hash state in the component using $app/state.
     page.url.hash is reactive and updates on client-side navigation.
     It will be empty during SSR, so provide a sensible default. -->

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

	let { data }: PageProps = $props()

	// $derived keeps activeTab in sync with navigation changes.
	// On the server and on first render it defaults to 'overview'.
	let activeTab = $derived(page.url.hash.replace('#', '') || 'overview')
</script>

<div class="tabs">
	<a href="#overview" class:active={activeTab === 'overview'}>Overview</a>
	<a href="#reviews" class:active={activeTab === 'reviews'}>Reviews</a>
	<a href="#specs" class:active={activeTab === 'specs'}>Specs</a>
</div>

If the tab selection controls which data is loaded from the server rather than just which section of already-loaded content is shown, then use a query parameter (?tab=reviews) rather than a hash, so the server can read it during SSR.

Step 5: A Complete Filterable Product Listing

With all three inputs understood, here is a complete example that puts them together. The route is /products, and it supports filtering by category and navigating through paginated results. All state lives in the URL so every view is shareable and every navigation is tracked by SvelteKit’s dependency system.

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

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

// Validation helpers keep the load function readable and
// protect against arbitrary user input reaching the API.
const VALID_SORT_FIELDS = ['name', 'price', 'rating'] as const
type SortField = (typeof VALID_SORT_FIELDS)[number]

function parseSortField(value: string | null): SortField {
	if (value && (VALID_SORT_FIELDS as readonly string[]).includes(value)) {
		return value as SortField
	}
	return 'name'
}

function parsePage(value: string | null): number {
	const parsed = parseInt(value ?? '1', 10)
	// Guard against NaN, zero, and negative page numbers
	// that could arise from malformed or manipulated URLs.
	return Number.isFinite(parsed) && parsed > 0 ? parsed : 1
}

const PAGE_SIZE = 24

export const load: PageServerLoad = async ({ url, fetch }) => {
	// Read and validate all relevant parameters using get() for precise
	// dependency tracking on each individual parameter.
	const category = url.searchParams.get('category')
	const search = url.searchParams.get('q')
	const sortBy = parseSortField(url.searchParams.get('sort'))
	const page = parsePage(url.searchParams.get('page'))

	// Build the outgoing API params from the validated values only.
	// This means the API never receives raw, unvalidated user input.
	const apiParams = new URLSearchParams()
	if (category) apiParams.set('category', category)
	if (search && search.trim().length >= 2) apiParams.set('q', search.trim())
	apiParams.set('sort', sortBy)
	apiParams.set('page', String(page))
	apiParams.set('pageSize', String(PAGE_SIZE))

	// Fetch the product list and the category list for the filter UI
	// in parallel — neither depends on the other.
	const [productsRes, categoriesRes] = await Promise.all([
		fetch(`/api/products?${apiParams}`),
		fetch('/api/categories')
	])

	const { products, totalCount } = await productsRes.json()
	const categories = await categoriesRes.json()

	return {
		products,
		categories,
		pagination: {
			page,
			pageSize: PAGE_SIZE,
			totalCount,
			totalPages: Math.ceil(totalCount / PAGE_SIZE)
		},
		// Returning the parsed and validated filter values means the
		// component does not need to re-read or re-parse the URL itself.
		activeFilters: { category, search, sortBy }
	}
}
<!-- src/routes/products/+page.svelte -->

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

	let { data }: PageProps = $props()

	// Build a URL for a target page number while preserving the
	// current filter state. Because the function closes over
	// the reactive data prop, it always reflects the latest values.
	function buildPageUrl(targetPage: number): string {
		const params = new URLSearchParams()

		if (data.activeFilters.category) {
			params.set('category', data.activeFilters.category)
		}
		if (data.activeFilters.search) {
			params.set('q', data.activeFilters.search)
		}

		params.set('sort', data.activeFilters.sortBy)
		params.set('page', String(targetPage))

		return `/products?${params.toString()}`
	}
</script>

<div class="products-page">
	<!--
		Using method="GET" means the browser navigates to a new URL
		when the form is submitted, updating the query string.
		SvelteKit intercepts this navigation, sees that the URL changed,
		and reruns the load function with the new searchParams values.
		No custom JavaScript is needed for the filter mechanism itself.
	-->
	<form method="GET" class="filters">
		<select name="category">
			<option value="">All Categories</option>
			{#each data.categories as cat (cat.slug)}
				<option value={cat.slug} selected={cat.slug === data.activeFilters.category}>
					{cat.name}
				</option>
			{/each}
		</select>

		<input
			type="search"
			name="q"
			placeholder="Search products..."
			value={data.activeFilters.search ?? ''}
		/>

		<select name="sort">
			<option value="name" selected={data.activeFilters.sortBy === 'name'}> Name </option>
			<option value="price" selected={data.activeFilters.sortBy === 'price'}> Price </option>
			<option value="rating" selected={data.activeFilters.sortBy === 'rating'}> Rating </option>
		</select>

		<button type="submit">Apply Filters</button>
	</form>

	<div class="product-grid">
		{#each data.products as product (product.id)}
			<a href="/products/{product.slug}" class="product-card">
				<img src={product.thumbnailUrl} alt={product.name} />
				<h2>{product.name}</h2>
				<p class="price">${product.price}</p>
				<p class="rating">{product.rating} stars</p>
			</a>
		{/each}

		{#if data.products.length === 0}
			<p class="empty-state">
				No products match your current filters. Try adjusting your search or category.
			</p>
		{/if}
	</div>

	{#if data.pagination.totalPages > 1}
		<nav class="pagination" aria-label="Product listing pages">
			{#if data.pagination.page > 1}
				<a href={buildPageUrl(data.pagination.page - 1)}>Previous</a>
			{/if}

			<span>
				Page {data.pagination.page} of {data.pagination.totalPages}
				({data.pagination.totalCount} products)
			</span>

			{#if data.pagination.page < data.pagination.totalPages}
				<a href={buildPageUrl(data.pagination.page + 1)}>Next</a>
			{/if}
		</nav>
	{/if}
</div>

The filter form uses method="GET" so that submitting it performs a standard browser navigation to the same route with updated query parameters. SvelteKit intercepts that navigation, detects that url.searchParams changed, and reruns the load function. The page updates with new data without a full page reload and without any custom JavaScript event handlers managing the filter state.

The pagination links work the same way: each one is a plain anchor pointing to a URL that encodes the current filters plus the new page number, so every page of results is its own distinct, navigable URL.


Common Mistakes and Anti-Patterns

Passing Raw URL Values Directly to the Database

Every query parameter in the URL can be set to any arbitrary string by any user. Passing those values without validation directly into a database query allows unexpected input to reach your data layer.

// AVOID: sortBy could be anything the user puts in the URL.
// Forwarding it directly to the API without validation is unsafe —
// the server receiving it may trust the value implicitly.

export const load: PageServerLoad = async ({ url, fetch }) => {
	const sortBy = url.searchParams.get('sort')
	const res = await fetch(`/api/products?sort=${sortBy}`)
	return { products: await res.json() }
}
// PREFERRED: Validate against an explicit allowlist first.
// The API only ever receives one of three known-safe sort values.

const VALID_SORT_FIELDS = ['name', 'price', 'rating'] as const
type SortField = (typeof VALID_SORT_FIELDS)[number]

function parseSortField(value: string | null): SortField {
	if (value && (VALID_SORT_FIELDS as readonly string[]).includes(value)) {
		return value as SortField
	}
	return 'name'
}

export const load: PageServerLoad = async ({ url, fetch }) => {
	const sortBy = parseSortField(url.searchParams.get('sort'))
	const res = await fetch(`/api/products?sort=${sortBy}`)
	return { products: await res.json() }
}

Small validation utility functions like parseSortField and parsePage also make load functions easier to unit test, since the parsing logic is isolated and can be exercised independently of the rest of the load function.

Reading url.search Instead of url.searchParams.get()

Reading url.search as a raw string creates a dependency on the entire query string; using url.searchParams.get('key') creates a dependency only on that specific key. In applications where multiple load functions or components update different query parameters, imprecise dependencies cause load functions to rerun more often than they need to.

// AVOID: Any query parameter change triggers a rerun of this
// load function, even parameters it never uses.

export const load: PageLoad = async ({ url }) => {
	const allParams = new URLSearchParams(url.search) // broad dependency
	const category = allParams.get('category')
	return { category }
}

// PREFERRED: Only a change to 'category' triggers a rerun.
// Changes to 'sort', 'page', or anything else are ignored.

export const load: PageLoad = async ({ url }) => {
	const category = url.searchParams.get('category') // precise dependency
	return { category }
}

Expecting url.hash to Work in a Load Function

This mistake surfaces as a subtle SSR mismatch. The load function returns a default value for hash-dependent state on the server because url.hash is always empty there; after hydration the client reruns the load function and gets a different value because url.hash is now populated. The result is a flash of incorrect content and a hydration warning in the browser console.

The solution is to move hash-dependent logic out of the load function entirely and handle it in the component using $app/state’s reactive page object, or to convert hash-based state to a query parameter that the server can read.


Performance and Scaling Considerations

SvelteKit’s fine-grained dependency tracking for url.searchParams is one of its quieter but most useful performance features. Because each searchParams.get() call registers a dependency on only that specific key, SvelteKit has precise information about what each load function cares about. During client-side navigation, it can compare only the relevant parts of the URL to decide whether a load function needs to rerun, rather than comparing entire URL strings.

This means that in a page with several independent load functions, each one will rerun only when the specific parameters it reads actually change. A pagination load function that reads only page will not rerun when the user changes the search term; a category-filter load function that reads only category will not rerun when the user changes the sort order. For complex pages with multiple data dependencies, this precision keeps navigation fast and avoids redundant server requests.

The Promise.all pattern shown in the complete example is worth highlighting as a separate performance consideration. Fetching products, total count, and category list in a single Promise.all call sends all three database queries simultaneously rather than sequentially. The total wait time is determined by the slowest of the three queries rather than the sum of all three. This pattern should be the default whenever multiple independent data requests are needed in a single load function.


When NOT to Use This Pattern

URL-driven state is the right tool for data that defines a meaningful, shareable view: which page, which category, which search term. It is not the right tool for ephemeral UI state that only matters for the current session and has no meaning to anyone else. Whether a dropdown is open, whether an accordion panel is expanded, or what text a user has typed but not yet submitted are all examples of state that belongs in the component as reactive variables, not in the URL.

The practical test is whether a URL containing that state would be useful to someone else. A URL encoding page 3 of a filtered product search is genuinely useful to another user. A URL encoding which accordion section a user has open is just noise.

For search inputs specifically, triggering a full URL navigation and load function rerun on every keystroke would produce a jarring experience. The conventional solution is to use a method="GET" form with a submit button, as in the example above, so navigation happens only when the user explicitly applies their search. Alternatively, debounce the input so navigation only triggers after a short pause in typing.


Conclusion

The params, url, and route inputs give load functions everything needed to respond intelligently to the current URL. Route parameters in params identify the specific resource being requested, typed precisely from the route directory structure. Query parameters through url.searchParams carry optional view configuration like filters and page numbers, with precise dependency tracking per parameter. The route.id property in route exposes the structural route pattern for analytics and conditional logic that needs to reason about which kind of page is active rather than which specific resource.

The most important discipline is treating all URL values as untrusted user input and validating them before they reach the database. Beyond that, using searchParams.get() instead of reading url.search as a raw string is a small habit that keeps SvelteKit’s dependency tracking accurate and navigation efficient.


Key Takeaways

params contains the resolved values of dynamic route segments, typed precisely from the route directory structure; it is the right place to read resource identity. url.searchParams.get() reads individual query parameters and registers precise dependencies per key; reading url.search as a raw string creates a broader dependency and should be avoided. url.hash is always an empty string during SSR because browsers strip hash fragments before sending HTTP requests; hash-dependent logic belongs in the component, not the load function. route.id exposes the route pattern in bracket notation, independent of the specific URL values, and is useful for analytics and logging. All values arriving from the URL are untrusted user input and must be validated before reaching database queries or business logic.


What’s Next

Now that you have a solid understanding of what load functions receive as input, it is time to look carefully at how they make outbound requests. The fetch provided by the load event is not the same as the global browser fetch; it has meaningful enhancements that solve real problems in server-rendered applications, from credential forwarding to hydration consistency. The next article, Fetch in Load Functions, covers exactly what those enhancements are and how to use them correctly.


Further Reading