All Patterns, One Project

Sixteen articles have covered the SvelteKit data loading system from first principles to advanced patterns. Each article demonstrated one concept in focused isolation: URL parameters in one place, parallel fetching in another, streaming in a third. Isolation is useful for learning. It is not how production applications work.

Real applications stack these patterns together. A single page might read from URL search parameters to decide what to fetch, run three requests in parallel, stream a fourth because it depends on the result of one of those three, read user context from a parent layout load, enforce types from top to bottom with generated $types, and re-run specific load functions when the user changes a filter. That is not exotic — it is a moderately complex list-detail interface.

This article builds exactly that using the PokéAPI — a free, open, no-authentication-required REST API with rich enough data to exercise every pattern naturally. There is nothing to install beyond SvelteKit itself. The project has four routes: a dedicated project homepage, a generation-filtered paginated Pokémon list, a detail page with parallel fetching and streamed evolution data, and an error boundary for invalid Pokémon names.

Every section references the earlier article where the pattern was first introduced in depth. This capstone is also a map of the series.


What Gets Built and Why Each Part Matters

Before writing a line of code, it is worth making the feature-to-concept mapping explicit. Each piece was chosen because it creates a natural occasion to use a specific loading pattern.

The project homepage at / is a dedicated landing page that explains what the capstone demonstrates, maps each feature to the loading concept behind it, and lists the five PokéAPI endpoints in use. It exists as its own route with its own visual identity — it is never collapsed or hidden. The grid lives on the same URL but is reached by scrolling, not by routing.

The generation filter uses the generation list loaded in the root layout, passed to the list page via parent(). Changing the filter navigates to a new URL, which re-runs the list page’s load function via the URL dependency SvelteKit tracks automatically. This covers the parent data pattern from Using Parent Data in Load Functions and the URL-driven loading from Using URL Data in Load Functions.

The pagination reads ?page= from the URL and converts it to a limit and offset for the PokéAPI request. This is the URL search parameter pattern from Using URL Data in Load Functions.

The detail page navigation uses a URL-encoding pattern to preserve list context. Each card link on the list page encodes three extra search parameters: prev (the name of the previous Pokémon on the same page), next (the name of the next one), and returnTo (the full list URL including page and generation). The detail page reads these from the URL through its server load and renders a back link that returns to the exact page the user came from, plus prev/next buttons to browse within the current slice. Using stores for this would create a dependency on shared mutable state that is undefined on direct URL access, invisible on the server, and lost on page refresh. The URL is durable, shareable, and readable in the server load — a user can bookmark a detail page, share it, or navigate to it directly and the back link still works correctly.

The detail page fetches core stats and species data in parallel using Promise.all, since neither depends on the other. This is parallel loading from Parallel Loading and Avoiding Waterfalls. The evolution chain requires the URL embedded in the species response, making it a natural streaming candidate, covered in Streaming Data with Promises.

The error boundary handles unknown Pokémon names via error(404, ...), surfacing the nearest +error.svelte, as covered in Errors and Redirects in Load Functions.

TypeScript is applied throughout using generated $types. This is the system from TypeScript and $types in SvelteKit Load Functions.

A Refresh button demonstrates programmatic invalidation: re-running the list load without a URL change, using depends() and invalidate() from Rerunning Load Functions and Dependency Tracking.


The PokéAPI Endpoints in Use

The application uses five PokéAPI endpoints, all free, no API key required, all returning JSON. The base URL for every request is https://pokeapi.co/api/v2.

/generation/?limit=40 returns the list of all generations. The root layout fetches this once to populate the filter navigation. /pokemon?limit={n}&offset={n} returns a paginated slice of all Pokémon. /generation/{id} returns all species for a specific generation, used when the generation filter is active. /pokemon/{name} returns base stats, types, abilities, and sprites. /pokemon-species/{name} returns flavor text, capture rate, habitat color, and the evolution chain URL.

These five endpoints, combined, exercise every loading pattern in the series.


Type Definitions

Defining the shapes up front keeps every load function and component free of any. PokéAPI responses are consistent but deeply nested. Precise types for the fields the application actually uses are more valuable than typing the entire response.

// src/lib/types/pokemon.ts

// ─── Shared primitives ────────────────────────────────────────────────────────

export interface NamedResource {
	name: string
	url: string
}

// ─── Generation ───────────────────────────────────────────────────────────────

export interface GenerationListResponse {
	count: number
	results: NamedResource[]
}

export interface Generation {
	id: number
	name: string
	pokemon_species: NamedResource[]
}

// Shape stored in layout data after normalisation.
export interface GenerationMeta {
	id: number
	name: string
	label: string
}

// ─── Pokémon list ─────────────────────────────────────────────────────────────

export interface PokemonListResponse {
	count: number
	next: string | null
	previous: string | null
	results: NamedResource[]
}

// Normalised shape used by both list page and card components.
export interface PokemonEntry {
	id: number
	name: string
	sprite: string
}

// ─── Pokémon detail ───────────────────────────────────────────────────────────

export interface PokemonStat {
	base_stat: number
	stat: NamedResource
}

export interface PokemonType {
	slot: number
	type: NamedResource
}

export interface PokemonAbility {
	ability: NamedResource
	is_hidden: boolean
	slot: number
}

export interface PokemonSprites {
	front_default: string | null
	other: {
		'official-artwork': {
			front_default: string | null
		}
	}
}

export interface Pokemon {
	id: number
	name: string
	height: number
	weight: number
	base_experience: number
	stats: PokemonStat[]
	types: PokemonType[]
	abilities: PokemonAbility[]
	sprites: PokemonSprites
}

// ─── Species & evolution ──────────────────────────────────────────────────────

export interface PokemonSpecies {
	id: number
	name: string
	capture_rate: number
	base_happiness: number | null
	varieties: Array<{
		is_default: boolean
		pokemon: NamedResource
	}>
	flavor_text_entries: Array<{
		flavor_text: string
		language: NamedResource
		version: NamedResource
	}>
	evolution_chain: { url: string }
	habitat: NamedResource | null
	color: NamedResource
}

export interface EvolutionLink {
	is_baby: boolean
	species: NamedResource
	evolves_to: EvolutionLink[]
}

export interface EvolutionChain {
	id: number
	chain: EvolutionLink
}

// Flat stage shape rendered by the component.
export interface EvolutionStage {
	name: string
	url: string
}

The Root Layout: Loading Generations for Navigation

The root layout at src/routes/+layout.server.ts fetches the generation list once and makes it available to every page in the application through the standard layout data merge. +layout.svelte renders the sticky generation filter strip above all page content. Because this load runs at the top of the hierarchy, it executes in parallel with the page’s own load function — neither waits for the other.

// src/routes/+layout.server.ts

import type { LayoutServerLoad } from './$types'
import type { GenerationListResponse, GenerationMeta } from '$lib/types/pokemon'

export const load: LayoutServerLoad = async ({ fetch }) => {
	const response = await fetch('https://pokeapi.co/api/v2/generation/?limit=40')

	if (!response.ok) {
		// Return an empty array so the filter simply does not render.
		return { generations: [] }
	}

	const data: GenerationListResponse = await response.json()

	const generations: GenerationMeta[] = data.results.map((gen, index) => ({
		id: index + 1,
		name: gen.name,
		label: `Gen ${toRomanNumeral(index + 1)}`
	}))

	return { generations }
}

function toRomanNumeral(n: number): string {
	const map: [number, string][] = [
		[10, 'X'],
		[9, 'IX'],
		[5, 'V'],
		[4, 'IV'],
		[1, 'I']
	]
	let result = ''
	let remaining = n
	for (const [value, symbol] of map) {
		while (remaining >= value) {
			result += symbol
			remaining -= value
		}
	}
	return result
}

toRomanNumeral is a plain pure function with no external dependencies, so it lives alongside the load function rather than in a shared utility file. The map is ordered from largest to smallest value. The inner while loop greedily subtracts each entry’s value from the running total, appending the corresponding symbol each time. The subtractive pairs IX and IV are listed as single atomic units in the map, so when remaining reaches nine, the loop emits IX in one step rather than building it from smaller symbols. This avoids any special-case post-processing. PokéAPI currently has nine generations, so the practical range is I–IX, but the function handles up to XII without changes.

The SvelteKit fetch destructured from the load event is not the global fetch. On the server it propagates cookies and credentials; on the client it deduplicates in-flight requests and participates in SvelteKit’s internal request coalescing. Using the global fetch bypasses all of this silently — no error is thrown, but the request behaves differently in ways that are hard to diagnose. This distinction is covered in depth in Fetch in Load Functions. Using the event’s fetch in every load function is a non-negotiable habit.


The Root Layout Component

The layout renders a sticky generation filter strip. The active generation is read from page.url.searchParams and the derived value updates reactively whenever the URL changes after a client-side navigation.

<!-- src/routes/+layout.svelte -->

<script lang="ts">
	import { page } from '$app/state'
	import '../app.css'
	import type { LayoutProps } from './$types'
	const { data, children }: LayoutProps = $props()

	const activeGeneration = $derived(page.url.searchParams.get('generation') ?? '')
</script>

<div class="poke-layout">
	{#if data.generations.length > 0}
		<nav class="gen-bar" aria-label="Filter by generation">
			<a href="/" class="gen-pill" class:active={activeGeneration === ''}>All</a>
			{#each data.generations as gen (gen.id)}
				<a
					href="/?generation={gen.id}"
					class="gen-pill"
					class:active={activeGeneration === String(gen.id)}
				>
					{gen.label}
				</a>
			{/each}
		</nav>
	{/if}

	{@render children()}
</div>

<style>
	.poke-layout {
		display: flex;
		flex-direction: column;
	}

	/* sticky horizontal strip with pill toggles */
	.gen-bar {
		position: sticky;
		top: 0;
		z-index: 40;
		display: flex;
		align-items: center;
		gap: 0.25rem;
		padding: 0.5rem var(--size-5);
		background-color: color-mix(in oklch, var(--bg), transparent 8%);
		border-bottom: 1px solid var(--border-default);
		backdrop-filter: blur(12px);
		-webkit-backdrop-filter: blur(12px);
		overflow-x: auto;
		scrollbar-width: none;
	}

	.gen-bar::-webkit-scrollbar {
		display: none;
	}

	.gen-pill {
		font-family: var(--font-monospace-code);
		font-size: 0.7rem;
		font-weight: 500;
		color: color-mix(in oklch, var(--text), transparent 42%);
		text-decoration: none;
		padding: 0.22rem 0.7rem;
		border-radius: 9999px;
		border: 1px solid transparent;
		white-space: nowrap;
		flex-shrink: 0;
		transition:
			color 120ms ease,
			border-color 120ms ease,
			background-color 120ms ease;
	}

	.gen-pill:hover {
		color: var(--text);
		background-color: color-mix(in oklch, var(--text), transparent 92%);
	}

	.gen-pill.active {
		color: var(--brand);
		background-color: color-mix(in oklch, var(--brand), transparent 88%);
		border-color: color-mix(in oklch, var(--brand), transparent 60%);
	}
</style>

page is imported from $app/state, not from $app/stores. This distinction matters. In Svelte 5, $app/state exposes page as a reactive object whose properties are tracked using runes internally. Reading page.url.searchParams inside $derived is sufficient — Svelte tracks that property access and re-runs the derived value whenever the URL changes after a navigation. There is no $page subscription syntax, no subscribe() call, and no cleanup to manage.

In Svelte 4, the equivalent required import { page } from '$app/stores' and then reading $page.url.searchParams in templates, where the leading $ was the store auto-subscription shorthand. In Svelte 5, that entire model is replaced by $app/state. The $app/stores module still exists for backward compatibility, but $app/state is the correct import for any new Svelte 5 code.

The ../app.css import at the top of the script block is the project’s global stylesheet. It provides the CSS custom properties — --bg, --text, --brand, --border-default, --size-*, --r-*, and the font-face declarations — that every component in this capstone references. In a real project this file lives at src/app.css; the relative path reflects the layout file’s position one directory inside src/routes/.


The Project Homepage

The / route serves as both the project homepage and the Pokémon list. The page is split into two visually distinct sections: a hero landing block that explains what is being built and why, and the Pokémon grid below it. The hero is always visible on the unfiltered first page — it is part of the route’s identity, not a collapsible banner. Once the user starts filtering or paginating, a compact heading takes its place.

// src/routes/+page.ts

import type {
	Generation,
	GenerationMeta,
	PokemonEntry,
	PokemonListResponse,
	PokemonSpecies
} from '$lib/types/pokemon'
import { error } from '@sveltejs/kit'
import type { PageLoad } from './$types'

const PAGE_SIZE = 20

export const load: PageLoad = async ({ fetch, url, parent, depends }) => {
	depends('app:pokemon-list')

	const pageParam = url.searchParams.get('page')
	const generationParam = url.searchParams.get('generation')
	const currentPage = Math.max(1, parseInt(pageParam ?? '1', 10) || 1)
	const offset = (currentPage - 1) * PAGE_SIZE
	const generationId = generationParam ? parseInt(generationParam, 10) : null

	// Start the API fetch before awaiting parent so both run concurrently.
	const listPromise = generationId
		? fetchByGeneration(fetch, generationId, offset)
		: fetchAllPokemon(fetch, PAGE_SIZE, offset)

	const { generations } = await parent()

	let pokemon: PokemonEntry[]
	let total: number

	try {
		;({ pokemon, total } = await listPromise)
	} catch {
		error(500, 'Failed to load Pokémon list from PokéAPI')
	}

	const totalPages = Math.ceil(total / PAGE_SIZE)
	const activeGen: GenerationMeta | null = generationId
		? (generations.find((g) => g.id === generationId) ?? null)
		: null

	return { pokemon, currentPage, totalPages, total, activeGen, generationId }
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

async function fetchAllPokemon(
	fetchFn: typeof globalThis.fetch,
	limit: number,
	offset: number
): Promise<{ pokemon: PokemonEntry[]; total: number }> {
	const response = await fetchFn(
		`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`
	)
	if (!response.ok) throw new Error(`PokéAPI /pokemon returned ${response.status}`)

	const data: PokemonListResponse = await response.json()

	const pokemon: PokemonEntry[] = data.results.map((entry) => {
		const id = Number(entry.url.split('/').filter(Boolean).at(-1))
		return { id, name: entry.name, sprite: officialArtworkUrl(id) }
	})

	return { pokemon, total: data.count }
}

async function fetchByGeneration(
	fetchFn: typeof globalThis.fetch,
	generationId: number,
	offset: number
): Promise<{ pokemon: PokemonEntry[]; total: number }> {
	const response = await fetchFn(`https://pokeapi.co/api/v2/generation/${generationId}`)
	if (!response.ok)
		throw new Error(`PokéAPI /generation/${generationId} returned ${response.status}`)

	const generation: Generation = await response.json()
	const sorted = [...generation.pokemon_species].sort((a, b) => a.name.localeCompare(b.name))
	const page = sorted.slice(offset, offset + PAGE_SIZE)
	const pokemon = await Promise.all(page.map((entry) => resolvePokemonEntry(fetchFn, entry)))

	return { pokemon, total: sorted.length }
}

async function resolvePokemonEntry(
	fetchFn: typeof globalThis.fetch,
	entry: { name: string; url: string }
): Promise<PokemonEntry> {
	const fallbackId = Number(entry.url.split('/').filter(Boolean).at(-1))
	const response = await fetchFn(entry.url)

	if (!response.ok) {
		return { id: fallbackId, name: entry.name, sprite: officialArtworkUrl(fallbackId) }
	}

	const species: PokemonSpecies = await response.json()
	const defaultVariety =
		species.varieties.find((variety) => variety.is_default) ?? species.varieties[0]
	const pokemonUrl = defaultVariety?.pokemon.url
	const pokemonId = pokemonUrl ? Number(pokemonUrl.split('/').filter(Boolean).at(-1)) : fallbackId
	const pokemonName = defaultVariety?.pokemon.name ?? entry.name

	return { id: pokemonId, name: pokemonName, sprite: officialArtworkUrl(pokemonId) }
}

function officialArtworkUrl(id: number): string {
	return `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`
}

The concurrent list-and-parent pattern

listPromise is assigned before parent() is awaited. This is deliberate. await parent() suspends the current load function until the layout load resolves. If the API fetch were initiated after that await, the two operations would run sequentially: first wait for the layout, then start the network request. By creating the Promise first, both operations are in flight simultaneously — the API request is already running while the load function is suspended waiting for the layout data. await listPromise later only blocks if the API response has not yet arrived, which on most connections it already has by the time parent() finishes.

The latency saving compounds quickly. If the layout load takes 80ms and the API call takes 120ms, sequential execution costs 200ms total; the concurrent pattern costs 120ms — the duration of the slower operation. This pattern applies to any universal load function that needs both parent data and an external API response where neither depends on the other. It is covered directly in Using Parent Data in Load Functions.

Why generation results need per-entry resolution

fetchAllPokemon works cleanly in a single request: the /pokemon endpoint returns paginated entries whose resource URLs embed the numeric ID. Extracting that ID and constructing the sprite URL is a local operation — no extra network calls.

fetchByGeneration is different because PokéAPI’s generation structure is organised around species, not individual Pokémon forms. The /generation/{id} endpoint returns a pokemon_species list where each entry points to a species resource, not a Pokémon resource. A species like Charizard encompasses multiple varieties — the base form, Mega Charizard X, and Mega Charizard Y — each with a distinct numeric ID and sprite URL. If the species ID were used directly for the sprite, the result would be wrong for any Pokémon whose primary form has a different ID than the species record.

resolvePokemonEntry solves this by fetching each species resource and reading its varieties array to find the entry marked is_default. That entry’s pokemon.url gives the correct Pokémon resource URL, from which the true numeric ID and canonical name are extracted. The fallback path — using the species ID directly when the fetch fails — is correct for the vast majority of Pokémon but may be wrong for a small number of alternate-form species. For a reference application this is an acceptable trade-off.

The Promise.all in fetchByGeneration fires up to twenty resolution requests simultaneously. Waiting for each entry sequentially would multiply network round-trip time by the page size, turning a single page load into a waterfall of up to twenty sequential requests. The burst of parallel requests is the right trade-off for a personal or low-traffic project; a production deployment with meaningful traffic would cache the generation endpoint server-side and avoid the per-entry resolution entirely.

error() as a TypeScript terminal

The try/catch around await listPromise calls error(500, ...) on failure. error() from @sveltejs/kit has a return type of never — TypeScript treats any code path through it as terminal, the same way it treats throw. This is why pokemon and total are declared with let before the try block without initializers, and TypeScript does not complain that they might be unassigned when they are read afterward. The compiler’s control-flow analysis understands that if error() executes, the function never reaches the return statement.

The component renders two layouts from a single URL. The full hero is shown on the unfiltered first page; a compact heading takes its place once the user starts filtering or paginating. Both states share the same grid and pagination beneath them.

<!-- src/routes/+page.svelte -->

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

	const { data }: PageProps = $props()

	const isDefaultView = $derived(data.currentPage === 1 && data.generationId === null)
	const gridTitle = $derived(data.activeGen ? `Generation ${data.activeGen.label}` : 'All Pokémon')
	const hasPrev = $derived(data.currentPage > 1)
	const hasNext = $derived(data.currentPage < data.totalPages)

	let refreshing = $state(false)

	async function refresh() {
		refreshing = true
		await invalidate('app:pokemon-list')
		refreshing = false
	}

	function buildPageUrl(p: number): string {
		const params = new URLSearchParams()
		params.set('page', String(p))
		if (data.generationId) params.set('generation', String(data.generationId))
		return `/?${params.toString()}`
	}

	// Encode prev, next, and returnTo directly into each card's href at render
	// time. The detail page reads these from the URL via its server load and uses
	// them to render a back link and prev/next navigation — no stores needed.
	function buildCardUrl(index: number): string {
		const p = data.pokemon[index]
		const prevPoke = index > 0 ? data.pokemon[index - 1] : null
		const nextPoke = index < data.pokemon.length - 1 ? data.pokemon[index + 1] : null
		const params = new URLSearchParams()
		if (prevPoke) params.set('prev', prevPoke.name)
		if (nextPoke) params.set('next', nextPoke.name)
		params.set('returnTo', buildPageUrl(data.currentPage))
		return `/${p.name}?${params.toString()}`
	}

	const features = [
		{
			label: 'Universal Load',
			description: 'Pokémon list runs in +page.ts so the browser re-fetches directly on navigation.'
		},
		{
			label: 'URL Search Params',
			description:
				'?page= and ?generation= drive every list state; the load function reads url.searchParams.'
		},
		{
			label: 'Parent Data',
			description: 'The generation list is loaded once in the layout and accessed via parent().'
		},
		{
			label: 'Parallel Load',
			description: 'Detail page fetches /{name} and /pokemon-species/{name} simultaneously.'
		},
		{
			label: 'Streaming',
			description: 'The evolution chain is returned as an unresolved Promise and streamed.'
		},
		{
			label: 'Error Boundary',
			description: 'Unknown Pokémon names call error(404) and surface the nearest +error.svelte.'
		},
		{
			label: 'TypeScript $types',
			description: 'Every load function and component uses route-relative types from ./$types.'
		},
		{
			label: 'invalidate()',
			description:
				'The Refresh button calls invalidate("app:pokemon-list") to re-run only the list load.'
		},
		{
			label: 'URL-encoded context',
			description:
				'Card hrefs encode prev, next, and returnTo so the detail page can navigate without shared state.'
		}
	]

	const endpoints = [
		{ path: '/generation/?limit=40', purpose: 'Layout — generation filter navigation' },
		{ path: '/?limit=N&offset=N', purpose: 'List page — paginated Pokémon grid' },
		{ path: '/generation/{id}', purpose: 'List page — species list for a specific generation' },
		{ path: '/{name}', purpose: 'Detail page — stats, types, abilities, sprites' },
		{ path: '/pokemon-species/{name}', purpose: 'Detail page — flavor text, evolution chain URL' }
	]
</script>

<svelte:head>
	<title>{isDefaultView ? 'Pokédex — SvelteKit Capstone' : `${gridTitle} — Pokédex`}</title>
</svelte:head>

<div class="page">
	{#if isDefaultView}
		<section class="hero">
			<div class="hero-body">
				<p class="kicker">SvelteKit · Data Loading Capstone · Article 17 of 17</p>
				<h1>Pokédex</h1>
				<p class="hero-description">
					A working reference implementation that ties every SvelteKit data loading pattern together
					in a single project. Browse Pokémon, read the code, and follow the links to the article
					that introduced each concept.
				</p>
				<div class="hero-actions">
					<a href="#grid" class="btn btn-primary">Browse Pokémon</a>
					<a
						href="https://pokeapi.co"
						target="_blank"
						rel="noopener noreferrer"
						class="btn btn-outline"
					>
						PokéAPI docs ↗
					</a>
				</div>
			</div>
		</section>

		<section class="section">
			<h2 class="section-title">Patterns Demonstrated</h2>
			<p class="section-description">
				Each feature was chosen because it creates a natural occasion to use a specific data loading
				pattern.
			</p>
			<ul class="feature-grid">
				{#each features as f (f.label)}
					<li class="feature-card">
						<div class="feature-card-header">
							<span class="feature-badge">{f.label}</span>
						</div>
						<p class="feature-desc">{f.description}</p>
					</li>
				{/each}
			</ul>
		</section>

		<section class="section">
			<h2 class="section-title">API Endpoints in Use</h2>
			<p class="section-description">
				All five endpoints are free, require no API key, and return JSON. Base URL:
				<code class="inline-code">https://pokeapi.co/api/v2</code>
			</p>
			<div class="endpoint-table">
				{#each endpoints as ep (ep.path)}
					<div class="endpoint-row">
						<code class="endpoint-path">{ep.path}</code>
						<span class="endpoint-purpose">{ep.purpose}</span>
					</div>
				{/each}
			</div>
		</section>

		<div class="sep" aria-hidden="true"></div>
	{/if}

	<!-- ── Grid header ───────────────────────────────────────── -->
	<div id="grid" class="grid-header">
		<div class="grid-title-row">
			<h2 class="grid-title">{gridTitle}</h2>
			<span class="grid-count">{data.total.toLocaleString()} total</span>
		</div>
		<p class="grid-subtitle">
			Showing {data.pokemon.length} of {data.total.toLocaleString()}
			{data.activeGen ? `in ${data.activeGen.label}` : 'across all generations'}
		</p>
	</div>

	<!-- ── Grid controls ──────────────────────────────────────── -->
	<div class="grid-controls">
		<button
			onclick={refresh}
			disabled={refreshing}
			class="refresh-btn"
			aria-label="Refresh Pokémon list"
		>
			{refreshing ? 'Refreshing…' : '↻ Refresh'}
		</button>
	</div>

	<!-- ── Pokémon grid ──────────────────────────────────────── -->
	<ul class="poke-grid">
		{#each data.pokemon as p, i (p.id)}
			<li>
				<a href={buildCardUrl(i)} class="poke-card">
					<div class="poke-sprite-wrap">
						<img src={p.sprite} alt={p.name} width="80" height="80" loading="lazy" />
					</div>
					<div class="poke-card-footer">
						<span class="poke-number">#{String(p.id).padStart(3, '0')}</span>
						<span class="poke-name">{p.name}</span>
					</div>
				</a>
			</li>
		{/each}
	</ul>

	<!-- ── Pagination ───────────────────────────────────────── -->
	{#if data.totalPages > 1}
		<nav class="pagination" aria-label="Pagination">
			{#if hasPrev}
				<a href={buildPageUrl(data.currentPage - 1)} class="page-btn">← Previous</a>
			{:else}
				<span class="page-btn" aria-disabled="true">← Previous</span>
			{/if}

			<span class="page-info">Page {data.currentPage} of {data.totalPages}</span>

			{#if hasNext}
				<a href={buildPageUrl(data.currentPage + 1)} class="page-btn">Next →</a>
			{:else}
				<span class="page-btn" aria-disabled="true">Next →</span>
			{/if}
		</nav>
	{/if}
</div>

<style>
	.page {
		max-width: 1100px;
		margin-inline: auto;
		padding: var(--size-8) var(--size-5) var(--size-9);
	}

	/* ─── Hero ─────────────────────────────────────────────── */
	.hero {
		padding: var(--size-9) 0 var(--size-8);
		border-bottom: 1px solid var(--border-default);
		margin-bottom: var(--size-8);
	}

	.hero-body {
		max-width: 680px;
	}

	.kicker {
		font-family: var(--font-monospace-code);
		font-size: 0.68rem;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		color: var(--brand);
		margin: 0 0 var(--size-4);
		line-height: 1;
	}

	h1 {
		font-family: title;
		font-size: clamp(2.75rem, 6vw, 4.5rem);
		font-weight: 900;
		letter-spacing: -0.04em;
		line-height: 1;
		color: var(--text);
		margin: 0 0 var(--size-5);
		max-inline-size: unset;
	}

	.hero-description {
		font-family: light;
		font-size: var(--font-size-fluid-1);
		color: color-mix(in oklch, var(--text), transparent 35%);
		line-height: 1.75;
		max-width: 58ch;
		margin: 0 0 var(--size-6);
	}

	.hero-actions {
		display: flex;
		align-items: center;
		gap: var(--size-3);
		flex-wrap: wrap;
	}

	/* ─── Shared button base ────────────────── */
	.btn {
		display: inline-flex;
		align-items: center;
		font-family: semibold;
		font-size: 0.875rem;
		font-weight: 500;
		text-decoration: none;
		border-radius: var(--r-m);
		padding: 0.5rem 1.25rem;
		border: 1px solid transparent;
		transition:
			opacity 120ms ease,
			background-color 120ms ease,
			border-color 120ms ease,
			box-shadow 120ms ease;
	}

	.btn-primary {
		color: oklch(98% 0 0);
		background-color: var(--brand);
		border-color: var(--brand);
	}
	.btn-primary:hover {
		opacity: 0.88;
		box-shadow: 0 0 0 3px color-mix(in oklch, var(--brand), transparent 72%);
	}

	.btn-outline {
		color: var(--text);
		background-color: transparent;
		border-color: var(--border-default);
	}
	.btn-outline:hover {
		background-color: color-mix(in oklch, var(--text), transparent 94%);
		border-color: color-mix(in oklch, var(--text), transparent 60%);
	}

	/* ─── Content sections ──────────────────────────────────── */
	.section {
		margin-bottom: var(--size-8);
	}

	.section-title {
		font-family: title;
		font-size: var(--font-size-fluid-3);
		font-weight: 700;
		letter-spacing: -0.02em;
		color: var(--text);
		margin: 0 0 var(--size-2);
		max-inline-size: unset;
	}

	.section-description {
		font-family: light;
		font-size: var(--font-size-fluid-0);
		color: color-mix(in oklch, var(--text), transparent 35%);
		line-height: 1.7;
		max-width: 68ch;
		margin: 0 0 var(--size-5);
	}

	/* ─── Feature cards ───────────────────────── */
	.feature-grid {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
		gap: var(--size-3);
		list-style: none;
		padding: 0;
		margin: 0;
	}

	.feature-card {
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		background-color: color-mix(in oklch, var(--bg), black 4%);
		padding: var(--size-4) var(--size-5);
		transition:
			border-color 140ms ease,
			box-shadow 140ms ease;
	}
	.feature-card:hover {
		border-color: color-mix(in oklch, var(--brand), transparent 55%);
		box-shadow: 0 0 0 1px color-mix(in oklch, var(--brand), transparent 80%);
	}

	.feature-card-header {
		margin-bottom: var(--size-2);
	}

	/* Badge */
	.feature-badge {
		display: inline-flex;
		align-items: center;
		font-family: var(--font-monospace-code);
		font-size: 0.68rem;
		font-weight: 600;
		color: var(--brand);
		background-color: color-mix(in oklch, var(--brand), transparent 88%);
		border: 1px solid color-mix(in oklch, var(--brand), transparent 65%);
		border-radius: 9999px;
		padding: 0.15rem 0.6rem;
	}

	.feature-desc {
		font-family: light;
		font-size: 0.825rem;
		color: color-mix(in oklch, var(--text), transparent 30%);
		line-height: 1.6;
		margin: 0;
	}

	/* ─── Endpoint table ─────────────────────── */
	.endpoint-table {
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		overflow: hidden;
	}

	.endpoint-row {
		display: grid;
		grid-template-columns: auto 1fr;
		gap: var(--size-5);
		align-items: center;
		padding: var(--size-3) var(--size-5);
		border-bottom: 1px solid var(--border-default);
		transition: background-color 100ms ease;
	}
	.endpoint-row:last-child {
		border-bottom: none;
	}
	.endpoint-row:hover {
		background-color: color-mix(in oklch, var(--bg), black 3%);
	}

	.endpoint-path {
		font-family: var(--font-monospace-code);
		font-size: 0.76rem;
		color: var(--brand);
		white-space: nowrap;
		background-color: color-mix(in oklch, var(--brand), transparent 92%);
		padding: 0.15rem 0.5rem;
		border-radius: var(--r-base);
	}

	.endpoint-purpose {
		font-family: light;
		font-size: 0.825rem;
		color: color-mix(in oklch, var(--text), transparent 30%);
	}

	.inline-code {
		font-family: var(--font-monospace-code);
		font-size: 0.85em;
		color: var(--brand);
		background-color: color-mix(in oklch, var(--brand), transparent 90%);
		padding: 0.1em 0.4em;
		border-radius: var(--r-base);
	}

	.sep {
		height: 1px;
		background-color: var(--border-default);
		margin-bottom: var(--size-8);
	}

	/* ─── Grid header ────────────────────────────────────────── */
	.grid-header {
		margin-bottom: var(--size-4);
	}

	.grid-title-row {
		display: flex;
		align-items: center;
		gap: var(--size-3);
		margin-bottom: var(--size-1);
		flex-wrap: wrap;
	}

	.grid-title {
		font-family: title;
		font-size: var(--font-size-fluid-3);
		font-weight: 700;
		letter-spacing: -0.02em;
		color: var(--text);
		margin: 0;
		max-inline-size: unset;
	}

	.grid-count {
		font-family: var(--font-monospace-code);
		font-size: 0.7rem;
		color: color-mix(in oklch, var(--text), transparent 50%);
		font-variant-numeric: tabular-nums;
	}

	/* ─── Grid controls ─────────────────────────────────────── */
	.grid-controls {
		display: flex;
		justify-content: flex-end;
		margin-bottom: var(--size-3);
	}

	/* Ghost Button for refresh */
	.refresh-btn {
		font-family: var(--font-monospace-code);
		font-size: 0.7rem;
		color: color-mix(in oklch, var(--text), transparent 42%);
		background-color: transparent;
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		padding: 0.25rem 0.7rem;
		cursor: pointer;
		transition:
			background-color 120ms ease,
			color 120ms ease;
	}
	.refresh-btn:hover:not(:disabled) {
		background-color: color-mix(in oklch, var(--text), transparent 93%);
		color: var(--text);
	}
	.refresh-btn:disabled {
		opacity: 0.4;
		cursor: default;
	}

	.grid-subtitle {
		font-family: var(--font-monospace-code);
		font-size: 0.7rem;
		color: color-mix(in oklch, var(--text), transparent 45%);
		margin: 0;
	}

	/* ─── Pokémon grid ───────────────────────────────────────── */
	.poke-grid {
		list-style: none;
		padding: 0;
		margin: 0 0 var(--size-8);
		display: grid;
		grid-template-columns: repeat(4, minmax(0, 1fr));
		gap: var(--size-3);
	}

	@media (max-width: 900px) {
		.poke-grid {
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}
	}
	@media (max-width: 600px) {
		.poke-grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}
	}

	/* Card: shell > sprite area > footer */
	.poke-card {
		display: flex;
		flex-direction: column;
		text-decoration: none;
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		background-color: color-mix(in oklch, var(--bg), black 4%);
		overflow: hidden;
		transition:
			border-color 140ms ease,
			transform 140ms ease,
			box-shadow 140ms ease;
	}
	.poke-card:hover {
		border-color: color-mix(in oklch, var(--brand), transparent 50%);
		transform: translateY(-2px);
		box-shadow:
			0 4px 12px color-mix(in oklch, var(--brand), transparent 80%),
			0 0 0 1px color-mix(in oklch, var(--brand), transparent 70%);
	}

	.poke-sprite-wrap {
		display: flex;
		align-items: center;
		justify-content: center;
		background-color: color-mix(in oklch, var(--bg), white 3%);
		padding: var(--size-4);
		aspect-ratio: 1;
	}
	.poke-sprite-wrap img {
		width: 80px;
		height: 80px;
		object-fit: contain;
		transition: transform 220ms ease;
	}
	.poke-card:hover .poke-sprite-wrap img {
		transform: scale(1.1);
	}

	.poke-card-footer {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 0.1rem;
		padding: var(--size-2) var(--size-3) var(--size-3);
		border-top: 1px solid var(--border-default);
	}

	.poke-number {
		font-family: var(--font-monospace-code);
		font-size: 0.62rem;
		color: color-mix(in oklch, var(--text), transparent 52%);
		font-variant-numeric: tabular-nums;
	}

	.poke-name {
		font-family: semibold;
		font-size: 0.8rem;
		color: var(--text);
		text-transform: capitalize;
		text-align: center;
	}

	/* ─── Pagination ────────────────────── */
	.pagination {
		display: flex;
		align-items: center;
		justify-content: center;
		gap: var(--size-3);
		padding-top: var(--size-6);
		border-top: 1px solid var(--border-default);
	}

	.page-btn {
		font-family: var(--font-monospace-code);
		font-size: 0.78rem;
		color: var(--text);
		text-decoration: none;
		padding: 0.4rem 0.9rem;
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		background-color: transparent;
		transition:
			background-color 120ms ease,
			border-color 120ms ease;
	}
	a.page-btn:hover {
		background-color: color-mix(in oklch, var(--text), transparent 93%);
		border-color: color-mix(in oklch, var(--text), transparent 55%);
	}
	span.page-btn {
		opacity: 0.35;
		cursor: default;
	}

	.page-info {
		font-family: var(--font-monospace-code);
		font-size: 0.72rem;
		color: color-mix(in oklch, var(--text), transparent 45%);
		font-variant-numeric: tabular-nums;
		min-width: 10ch;
		text-align: center;
	}
</style>

Key choice in the grid loop

The {#each} loop uses (p.id) as its key rather than the array index. Svelte uses this key to match existing DOM nodes against incoming data during a re-render. When the list changes — because the user paginated or invalidated — Svelte diffs by key and moves, updates, or destroys nodes accordingly. Using a numeric Pokémon ID that is stable, unique, and present on every PokemonEntry means Svelte correctly identifies which cards are new and which can be patched in place. Using the array index as a key instead would cause incorrect node reuse when the list content changes at any position other than the end, resulting in stale content appearing in the wrong card.

buildCardUrl and the URL-context encoding

buildCardUrl(i) is called once per card at render time. The loop index i is available because {#each data.pokemon as p, i (p.id)} exposes it as the second binding. Edge cards simply omit the missing parameter — the first card on the page has no prev, the last has no next. The full current-page URL including ?page= and ?generation= is encoded as returnTo, giving the detail page everything it needs to render navigation without any shared mutable state.

depends('app:pokemon-list') registers a custom invalidation key. url.searchParams is also read directly, which registers an automatic URL dependency — SvelteKit re-runs the load whenever those params change.


The Detail Page: Parallel Fetching, Streaming, and Context Navigation

The detail page is where the most patterns converge. Three API calls are needed, and their dependency structure shapes the entire load function design.

The core Pokémon data at /pokemon/{name} and the species data at /pokemon-species/{name} are fully independent. Both start simultaneously with Promise.all. The evolution chain requires the URL embedded in the species response and cannot start until that fetch completes — a legitimate sequential dependency. This is exactly the kind of data that benefits from streaming: the page renders immediately with all core data visible while the evolution chain resolves in the background.

// src/routes/[name]/+page.server.ts

import type {
	EvolutionChain,
	EvolutionLink,
	EvolutionStage,
	Pokemon,
	PokemonSpecies
} from '$lib/types/pokemon'
import { error } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ params, fetch, url }) => {
	// Read navigation context that the list page encoded into the card's href.
	// prev and next are Pokémon names (strings matching the [name] route segment).
	// returnTo is the full list URL including ?page= and ?generation= if present.
	// Falling back to '/' means direct URL navigation still returns to the list page.
	const prev = url.searchParams.get('prev')
	const next = url.searchParams.get('next')
	const returnTo = url.searchParams.get('returnTo') ?? '/'

	// Parallel: /pokemon/{name} and /pokemon-species/{name} are fully independent.
	// Both start simultaneously — neither fetch depends on the other.
	const [initialPokemonResponse, speciesResponse] = await Promise.all([
		fetch(`https://pokeapi.co/api/v2/pokemon/${params.name}`),
		fetch(`https://pokeapi.co/api/v2/pokemon-species/${params.name}`)
	])
	let pokemonResponse = initialPokemonResponse
	const species: PokemonSpecies | null = speciesResponse.ok ? await speciesResponse.json() : null

	if (!pokemonResponse.ok && pokemonResponse.status === 404 && species?.varieties.length) {
		const defaultVariety =
			species.varieties.find((variety) => variety.is_default) ?? species.varieties[0]

		if (defaultVariety?.pokemon.name && defaultVariety.pokemon.name !== params.name) {
			pokemonResponse = await fetch(
				`https://pokeapi.co/api/v2/pokemon/${defaultVariety.pokemon.name}`
			)
		}
	}

	if (!pokemonResponse.ok) {
		if (pokemonResponse.status === 404) {
			error(404, `No Pokémon found with the name "${params.name}".`)
		}
		error(500, 'Failed to load Pokémon data from PokéAPI')
	}

	const pokemon: Pokemon = await pokemonResponse.json()

	// evolutionChain is returned as an unresolved Promise.
	// SvelteKit streams it to the client separately from the initial HTML.
	// The page renders immediately; the evolution section fills in when it resolves.
	const evolutionChain: Promise<EvolutionStage[] | null> = species
		? fetchEvolutionChain(fetch, species.evolution_chain.url)
		: Promise.resolve(null)

	const flavorText = species
		? (species.flavor_text_entries
				.find((e) => e.language.name === 'en')
				?.flavor_text?.replace(/\f/g, ' ')
				?.replace(/\n/g, ' ') ?? null)
		: null

	return { pokemon, species, flavorText, evolutionChain, prev, next, returnTo }
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

async function fetchEvolutionChain(
	fetchFn: typeof globalThis.fetch,
	url: string
): Promise<EvolutionStage[] | null> {
	const response = await fetchFn(url)
	if (!response.ok) return null
	const chain: EvolutionChain = await response.json()
	return flattenEvolutionChain(chain.chain)
}

// Walk the chain recursively, following the first branch at each node.
// Branching Pokémon (e.g. Eevee) have multiple evolves_to entries;
// this renders only the first branch as a linear sequence.
function flattenEvolutionChain(link: EvolutionLink): EvolutionStage[] {
	const stages: EvolutionStage[] = [{ name: link.species.name, url: link.species.url }]
	if (link.evolves_to.length > 0) {
		stages.push(...flattenEvolutionChain(link.evolves_to[0]))
	}
	return stages
}

Why the evolution chain is streamed

The evolution chain URL is embedded inside the species response. It cannot be fetched until /pokemon-species/{name} resolves, making it a third sequential step after an already-parallel pair. Awaiting it before returning from the load function would delay the initial HTML render by the combined time of the species fetch plus the evolution fetch — often 200–400ms of additional latency before the user sees anything.

Instead, fetchEvolutionChain(fetch, ...) is called without await and its Promise is placed directly in the return object. SvelteKit serialises unresolved Promises into the initial server response and streams their resolved values to the client as they arrive. The component receives the Promise immediately, and {#await} handles the progressive rendering entirely in the template. No $state flags, no $effect wiring, no manual lifecycle management — the framework takes care of it.

The component and its three-state await

<!-- src/routes/[name]/+page.svelte -->

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

	const { data }: PageProps = $props()

	const displayName = $derived(
		data.pokemon.name.charAt(0).toUpperCase() + data.pokemon.name.slice(1)
	)

	const heightM = $derived((data.pokemon.height / 10).toFixed(1))
	const weightKg = $derived((data.pokemon.weight / 10).toFixed(1))
	const statTotal = $derived(data.pokemon.stats.reduce((s, a) => s + a.base_stat, 0))
	const standardAbilities = $derived(data.pokemon.abilities.filter((a) => !a.is_hidden))
	const hiddenAbility = $derived(data.pokemon.abilities.find((a) => a.is_hidden) ?? null)
	const artwork = $derived(data.pokemon.sprites.other['official-artwork'].front_default)

	// Build a sibling URL that carries returnTo forward so each subsequent
	// detail page still knows where "back" goes.
	function siblingUrl(name: string, prevName: string | null, nextName: string | null): string {
		const params = new URLSearchParams()
		if (prevName) params.set('prev', prevName)
		if (nextName) params.set('next', nextName)
		params.set('returnTo', data.returnTo)
		return `/${name}?${params.toString()}`
	}
</script>

<svelte:head>
	<title>{displayName} — Pokédex</title>
</svelte:head>

<div class="detail-page">
	<!-- ── Context navigation bar ─────────────────────────────── -->
	<!--
		returnTo: full list URL with ?page= and ?generation= preserved.
		prev/next: Pokémon names from the same page slice, null at edges.
		siblingUrl() carries returnTo forward so chained nav still returns
		to the original list page.
	-->
	<nav class="context-nav" aria-label="Pokédex navigation">
		<a href={data.returnTo} class="nav-btn nav-back">← Back to list</a>

		<div class="sibling-nav">
			{#if data.prev}
				<a
					href={siblingUrl(data.prev, null, data.pokemon.name)}
					class="nav-btn"
					aria-label="Previous Pokémon: {data.prev}">{data.prev}</a
				>
			{:else}
				<span class="nav-btn nav-disabled" aria-hidden="true">← prev</span>
			{/if}

			{#if data.next}
				<a
					href={siblingUrl(data.next, data.pokemon.name, null)}
					class="nav-btn"
					aria-label="Next Pokémon: {data.next}">{data.next}</a
				>
			{:else}
				<span class="nav-btn nav-disabled" aria-hidden="true">next →</span>
			{/if}
		</div>
	</nav>

	<!-- ── Hero header ──────────────────────────────────────── -->
	<header class="poke-header">
		{#if artwork}
			<div class="artwork-card">
				<img src={artwork} alt={displayName} width="475" height="475" />
			</div>
		{/if}

		<div class="poke-identity">
			<p class="poke-dex-num">#{String(data.pokemon.id).padStart(4, '0')}</p>
			<h1 class="poke-name">{displayName}</h1>

			<div class="type-row" role="list" aria-label="Types">
				{#each data.pokemon.types as t (t.slot)}
					<span class="type-badge" data-type={t.type.name} role="listitem">
						{t.type.name}
					</span>
				{/each}
			</div>

			{#if data.flavorText}
				<p class="flavor-text">{data.flavorText}</p>
			{/if}
		</div>
	</header>

	<!-- ── Data cards ───────────────────────────────────────── -->
	<div class="cards-grid">
		<!-- Stats card -->
		<section class="card">
			<div class="card-header">
				<h2 class="card-title">Base Stats</h2>
				<span class="card-badge">BST {statTotal}</span>
			</div>
			<div class="card-content">
				<dl class="stat-list">
					{#each data.pokemon.stats as s (s.stat.name)}
						<div class="stat-row">
							<dt class="stat-label">{s.stat.name}</dt>
							<dd class="stat-num">{s.base_stat}</dd>
							<dd class="stat-track" aria-hidden="true">
								<div
									class="stat-fill"
									style="width: {Math.min(100, (s.base_stat / 255) * 100)}%"
								></div>
							</dd>
						</div>
					{/each}
				</dl>
			</div>
		</section>

		<!-- Profile card -->
		<section class="card">
			<div class="card-header">
				<h2 class="card-title">Profile</h2>
			</div>
			<div class="card-content">
				<dl class="profile-dl">
					<div class="profile-row">
						<dt>Height</dt>
						<dd>{heightM} m</dd>
					</div>
					<div class="profile-row">
						<dt>Weight</dt>
						<dd>{weightKg} kg</dd>
					</div>
					{#if data.species?.capture_rate != null}
						<div class="profile-row">
							<dt>Catch rate</dt>
							<dd>{data.species.capture_rate}</dd>
						</div>
					{/if}
					{#if data.species?.color}
						<div class="profile-row">
							<dt>Colour</dt>
							<dd class="cap">{data.species.color.name}</dd>
						</div>
					{/if}
					{#if data.species?.base_happiness != null}
						<div class="profile-row">
							<dt>Base happiness</dt>
							<dd>{data.species.base_happiness}</dd>
						</div>
					{/if}
				</dl>
			</div>
		</section>

		<!-- Abilities card -->
		<section class="card">
			<div class="card-header">
				<h2 class="card-title">Abilities</h2>
			</div>
			<div class="card-content">
				<ul class="ability-list">
					{#each standardAbilities as a (a.ability.name)}
						<li class="ability-item">{a.ability.name}</li>
					{/each}
					{#if hiddenAbility}
						<li class="ability-item ability-hidden">
							{hiddenAbility.ability.name}
							<span class="hidden-badge">hidden</span>
						</li>
					{/if}
				</ul>
			</div>
		</section>

		<!--
			evolutionChain is a streamed Promise. The page renders immediately
			with the core data above; this card fills in when the stream resolves.
			{#await} handles all three states: pending, resolved, rejected.
		-->
		<section class="card card-full">
			<div class="card-header">
				<h2 class="card-title">Evolution Chain</h2>
			</div>
			<div class="card-content">
				{#await data.evolutionChain}
					<p class="muted-text" aria-live="polite">Loading evolution chain…</p>
				{:then stages}
					{#if stages && stages.length > 1}
						<ol class="evo-chain">
							{#each stages as stage, i (stage.name)}
								<li class="evo-item">
									<a href="/{stage.name}" class="evo-link">
										{stage.name.charAt(0).toUpperCase() + stage.name.slice(1)}
									</a>
									{#if i < stages.length - 1}
										<span class="evo-arrow" aria-hidden="true"></span>
									{/if}
								</li>
							{/each}
						</ol>
					{:else if stages && stages.length === 1}
						<p class="muted-text">This Pokémon does not evolve.</p>
					{:else}
						<p class="muted-text">Evolution data unavailable.</p>
					{/if}
				{:catch}
					<p class="error-text">Could not load evolution chain.</p>
				{/await}
			</div>
		</section>
	</div>
</div>

<style>
	/* ─── Page shell ────────────────────────────────────────── */
	.detail-page {
		max-width: 960px;
		margin-inline: auto;
		padding: var(--size-7) var(--size-5) var(--size-9);
	}

	/* ─── Context navigation bar ────────────────────────────── */
	.context-nav {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: var(--size-3);
		margin-bottom: var(--size-7);
		padding-bottom: var(--size-4);
		border-bottom: 1px solid var(--border-default);
		flex-wrap: wrap;
	}

	.sibling-nav {
		display: flex;
		align-items: center;
		gap: var(--size-2);
	}

	/*  ghost/outline Button — shared base */
	.nav-btn {
		display: inline-flex;
		align-items: center;
		font-family: var(--font-monospace-code);
		font-size: 0.72rem;
		font-weight: 500;
		text-decoration: none;
		text-transform: capitalize;
		white-space: nowrap;
		padding: 0.3rem 0.75rem;
		border-radius: var(--r-m);
		border: 1px solid var(--border-default);
		background-color: transparent;
		color: color-mix(in oklch, var(--text), transparent 35%);
		transition:
			background-color 120ms ease,
			border-color 120ms ease,
			color 120ms ease;
	}

	a.nav-btn:hover {
		background-color: color-mix(in oklch, var(--text), transparent 93%);
		border-color: color-mix(in oklch, var(--text), transparent 55%);
		color: var(--text);
	}

	/* back button gets a brand tint on hover */
	.nav-back:hover {
		background-color: color-mix(in oklch, var(--brand), transparent 92%);
		border-color: color-mix(in oklch, var(--brand), transparent 60%);
		color: var(--brand);
	}

	.nav-disabled {
		opacity: 0.28;
		cursor: default;
		pointer-events: none;
	}

	/* ─── Pokémon header ────────────────────────────────────── */
	.poke-header {
		display: grid;
		grid-template-columns: auto 1fr;
		gap: var(--size-8);
		align-items: start;
		margin-bottom: var(--size-8);
		padding-bottom: var(--size-7);
		border-bottom: 1px solid var(--border-default);
	}

	@media (max-width: 560px) {
		.poke-header {
			grid-template-columns: 1fr;
		}
	}

	/*  Card shell for artwork */
	.artwork-card {
		width: 210px;
		aspect-ratio: 1;
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		background-color: color-mix(in oklch, var(--bg), white 3%);
		display: flex;
		align-items: center;
		justify-content: center;
		padding: var(--size-4);
		flex-shrink: 0;
	}

	@media (max-width: 560px) {
		.artwork-card {
			width: 100%;
			max-width: 240px;
		}
	}

	.artwork-card img {
		width: 100%;
		height: 100%;
		object-fit: contain;
	}

	.poke-dex-num {
		font-family: var(--font-monospace-code);
		font-size: 0.7rem;
		color: color-mix(in oklch, var(--text), transparent 54%);
		margin: 0 0 var(--size-1);
		font-variant-numeric: tabular-nums;
		line-height: 1;
	}

	.poke-name {
		font-family: title;
		font-size: clamp(2rem, 5vw, 3.25rem);
		font-weight: 900;
		letter-spacing: -0.03em;
		line-height: 1;
		color: var(--text);
		text-transform: capitalize;
		margin: 0 0 var(--size-4);
		max-inline-size: unset;
	}

	/* ─── Type badges ( Badge, type-coloured) ──────────── */
	.type-row {
		display: flex;
		gap: var(--size-2);
		flex-wrap: wrap;
		margin-bottom: var(--size-5);
	}

	.type-badge {
		display: inline-flex;
		align-items: center;
		font-family: var(--font-monospace-code);
		font-size: 0.66rem;
		font-weight: 700;
		letter-spacing: 0.06em;
		text-transform: uppercase;
		padding: 0.22rem 0.7rem;
		border-radius: 9999px;
		color: oklch(97% 0 0);
	}

	.type-badge[data-type='normal'] {
		background: oklch(68% 0.03 100);
	}
	.type-badge[data-type='fire'] {
		background: oklch(62% 0.22 32);
	}
	.type-badge[data-type='water'] {
		background: oklch(58% 0.18 245);
	}
	.type-badge[data-type='electric'] {
		background: oklch(79% 0.19 95);
		color: oklch(18% 0 0);
	}
	.type-badge[data-type='grass'] {
		background: oklch(60% 0.18 145);
	}
	.type-badge[data-type='ice'] {
		background: oklch(72% 0.1 210);
		color: oklch(18% 0 0);
	}
	.type-badge[data-type='fighting'] {
		background: oklch(45% 0.16 28);
	}
	.type-badge[data-type='poison'] {
		background: oklch(50% 0.18 310);
	}
	.type-badge[data-type='ground'] {
		background: oklch(72% 0.1 75);
		color: oklch(18% 0 0);
	}
	.type-badge[data-type='flying'] {
		background: oklch(70% 0.1 280);
	}
	.type-badge[data-type='psychic'] {
		background: oklch(62% 0.22 0);
	}
	.type-badge[data-type='bug'] {
		background: oklch(58% 0.18 120);
	}
	.type-badge[data-type='rock'] {
		background: oklch(55% 0.08 80);
	}
	.type-badge[data-type='ghost'] {
		background: oklch(40% 0.12 290);
	}
	.type-badge[data-type='dragon'] {
		background: oklch(48% 0.2 270);
	}
	.type-badge[data-type='dark'] {
		background: oklch(32% 0.04 60);
	}
	.type-badge[data-type='steel'] {
		background: oklch(65% 0.04 230);
		color: oklch(18% 0 0);
	}
	.type-badge[data-type='fairy'] {
		background: oklch(78% 0.12 355);
		color: oklch(18% 0 0);
	}

	.flavor-text {
		font-family: light;
		font-size: var(--font-size-fluid-0);
		color: color-mix(in oklch, var(--text), transparent 28%);
		line-height: 1.75;
		max-width: 48ch;
		margin: 0;
	}

	/* ─── Cards grid ─────────────────────────────────────────── */
	.cards-grid {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
		gap: var(--size-4);
	}

	/*  Card anatomy */
	.card {
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		background-color: color-mix(in oklch, var(--bg), black 4%);
		display: flex;
		flex-direction: column;
		overflow: hidden;
	}

	.card-full {
		grid-column: 1 / -1;
	}

	.card-header {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: var(--size-3);
		padding: var(--size-4) var(--size-5) 0;
	}

	.card-title {
		font-family: var(--font-monospace-code);
		font-size: 0.66rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		color: color-mix(in oklch, var(--text), transparent 44%);
		margin: 0;
	}

	.card-badge {
		font-family: var(--font-monospace-code);
		font-size: 0.66rem;
		color: var(--brand);
		font-variant-numeric: tabular-nums;
	}

	.card-content {
		padding: var(--size-4) var(--size-5) var(--size-5);
	}

	/* ─── Stat list ( Progress bar rows) ───────────────── */
	.stat-list {
		display: flex;
		flex-direction: column;
		gap: 0.55rem;
	}

	.stat-row {
		display: grid;
		grid-template-columns: 7rem 2.4rem 1fr;
		align-items: center;
		gap: var(--size-2);
	}

	.stat-label {
		font-family: var(--font-monospace-code);
		font-size: 0.66rem;
		color: color-mix(in oklch, var(--text), transparent 44%);
		text-transform: capitalize;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}

	.stat-num {
		font-family: var(--font-monospace-code);
		font-size: 0.75rem;
		font-weight: 600;
		color: var(--text);
		text-align: right;
		font-variant-numeric: tabular-nums;
	}

	.stat-track {
		height: 5px;
		background-color: color-mix(in oklch, var(--bg), white 7%);
		border-radius: 9999px;
		overflow: hidden;
	}

	.stat-fill {
		height: 100%;
		background-color: var(--brand);
		border-radius: 9999px;
		transition: width 550ms cubic-bezier(0.25, 1, 0.5, 1);
	}

	/* ─── Profile ( Table rows) ────────────────────────── */
	.profile-dl {
		display: flex;
		flex-direction: column;
	}

	.profile-row {
		display: flex;
		align-items: center;
		justify-content: space-between;
		padding: 0.55rem 0;
		border-bottom: 1px solid color-mix(in oklch, var(--border-default), transparent 20%);
	}
	.profile-row:last-child {
		border-bottom: none;
	}

	.profile-row dt {
		font-family: var(--font-monospace-code);
		font-size: 0.66rem;
		color: color-mix(in oklch, var(--text), transparent 46%);
		text-transform: uppercase;
		letter-spacing: 0.05em;
	}

	.profile-row dd {
		font-family: semibold;
		font-size: 0.85rem;
		color: var(--text);
		font-variant-numeric: tabular-nums;
	}

	.cap {
		text-transform: capitalize;
	}

	/* ─── Abilities ──────────────────────────────────────────── */
	.ability-list {
		display: flex;
		flex-direction: column;
		gap: 0.45rem;
		list-style: none;
		padding: 0;
		margin: 0;
	}

	.ability-item {
		font-family: semibold;
		font-size: 0.85rem;
		color: var(--text);
		text-transform: capitalize;
		display: flex;
		align-items: center;
		gap: var(--size-2);
	}

	.ability-hidden {
		color: color-mix(in oklch, var(--text), transparent 38%);
	}

	.hidden-badge {
		font-family: var(--font-monospace-code);
		font-size: 0.6rem;
		font-weight: 600;
		color: color-mix(in oklch, var(--text), transparent 38%);
		background-color: color-mix(in oklch, var(--bg), white 5%);
		border: 1px solid var(--border-default);
		border-radius: 9999px;
		padding: 0.1rem 0.45rem;
	}

	/* ─── Evolution chain ────────────────────────────────────── */
	.evo-chain {
		display: flex;
		align-items: center;
		flex-wrap: wrap;
		gap: var(--size-2);
		list-style: none;
		padding: 0;
		margin: 0;
	}

	.evo-item {
		display: flex;
		align-items: center;
		gap: var(--size-2);
	}

	.evo-link {
		font-family: semibold;
		font-size: 0.82rem;
		color: var(--text);
		text-decoration: none;
		text-transform: capitalize;
		padding: 0.3rem 0.85rem;
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		background-color: transparent;
		transition:
			background-color 120ms ease,
			border-color 120ms ease;
	}
	.evo-link:hover {
		background-color: color-mix(in oklch, var(--brand), transparent 90%);
		border-color: color-mix(in oklch, var(--brand), transparent 55%);
	}

	.evo-arrow {
		font-family: var(--font-monospace-code);
		font-size: 0.7rem;
		color: color-mix(in oklch, var(--text), transparent 56%);
	}

	/* ─── Utility ────────────────────────────────────────────── */
	.muted-text {
		font-family: var(--font-monospace-code);
		font-size: 0.75rem;
		color: color-mix(in oklch, var(--text), transparent 46%);
		margin: 0;
	}

	.error-text {
		font-family: var(--font-monospace-code);
		font-size: 0.75rem;
		color: oklch(63% 0.2 25);
		margin: 0;
	}
</style>

The three states of {#await}

{#await data.evolutionChain} covers three distinct outcomes without any $state variable. The content between {#await} and {:then} renders while the Promise is still pending. The :then stages block renders once it resolves with a value. The :catch block renders if it rejects. All three states are handled declaratively in the template; there are no boolean flags, no side-effectful $effect hooks, and no manual lifecycle cleanup. This is the fundamental case for streaming: the component’s rendering logic stays pure and reactive, and the progressive reveal of asynchronous data is managed by the framework rather than by hand.

The aria-live="polite" attribute on the pending paragraph is important for accessibility. Screen readers announce live regions when their content changes. Without it, a screen reader user navigating to the page would hear the evolution section heading but receive no indication that content is loading, and no announcement when it arrives.

The siblingUrl one-hop design

siblingUrl(name, prevName, nextName) builds the href for each prev/next navigation button on the detail page. It always carries data.returnTo forward, so clicking through multiple detail pages in sequence preserves the back link pointing to the original list page. The prevName and nextName arguments are explicitly set to null at the call sites: navigating to the next Pokémon passes the current Pokémon’s name as prev and null as next, because the current page does not know what comes after the next one.

This is a deliberate one-hop design. The list page renders only the twenty Pokémon in its current slice — it cannot know what comes before the first card or after the last card on the adjacent page. So prev/next knowledge extends exactly one step in each direction from any given detail page. This is the correct trade-off for a stateless, URL-encoded approach: the implementation stays simple, requires no server-side session state, and works correctly on direct URL access, page refresh, and link sharing.


The Error Boundary

When a Pokémon name in the URL does not exist in the API, error(404, ...) halts the load function and surfaces the nearest +error.svelte. The error boundary at the route level keeps the layout data — including the generation nav strip — intact so the application shell does not break.

<!-- src/routes/[name]/+error.svelte -->

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

	const status = $derived(page.status)
	const message = $derived(page.error?.message ?? 'Something went wrong.')

	// If the user arrived via a card link, returnTo is encoded in the URL.
	// This returns them to the same page/generation they came from, not always /.
	const returnTo = $derived(page.url.searchParams.get('returnTo') ?? '/')
</script>

<div class="error-page">
	<p class="error-code" aria-hidden="true">{status}</p>

	<div class="error-body">
		<h1 class="error-title">
			{status === 404 ? 'Pokémon Not Found' : 'Something Went Wrong'}
		</h1>

		<p class="error-message">{message}</p>

		{#if status === 404}
			<!--  callout / Alert pattern: left-accent border -->
			<div class="error-hint" role="note">
				<p>
					Pokémon names are lowercase and hyphenated for multi-word species. Try
					<code>mr-mime</code> or <code>tapu-koko</code> rather than their capitalised or spaced forms.
				</p>
			</div>
		{/if}

		<a href={returnTo} class="back-btn">← Back to Pokédex</a>
	</div>
</div>

<style>
	.error-page {
		max-width: 520px;
		margin-inline: auto;
		padding: var(--size-9) var(--size-5);
		display: flex;
		flex-direction: column;
		gap: var(--size-2);
	}

	/* Ghost status number — muted-foreground pattern */
	.error-code {
		font-family: title;
		font-size: clamp(5rem, 18vw, 9rem);
		font-weight: 900;
		line-height: 1;
		color: color-mix(in oklch, var(--bg), var(--text) 10%);
		margin: 0;
		letter-spacing: -0.04em;
		user-select: none;
	}

	.error-body {
		display: flex;
		flex-direction: column;
		gap: var(--size-4);
	}

	.error-title {
		font-family: title;
		font-size: var(--font-size-fluid-3);
		font-weight: 700;
		letter-spacing: -0.025em;
		line-height: 1.1;
		color: var(--text);
		margin: 0;
		max-inline-size: unset;
	}

	.error-message {
		font-family: light;
		font-size: var(--font-size-fluid-1);
		color: color-mix(in oklch, var(--text), transparent 32%);
		line-height: 1.65;
		margin: 0;
	}

	/*  Alert: left-accent border callout */
	.error-hint {
		padding: var(--size-4) var(--size-5);
		background-color: color-mix(in oklch, var(--bg), black 4%);
		border: 1px solid var(--border-default);
		border-left: 3px solid var(--brand);
		border-radius: var(--r-m);
	}

	.error-hint p {
		font-family: light;
		font-size: 0.85rem;
		color: color-mix(in oklch, var(--text), transparent 30%);
		line-height: 1.65;
		margin: 0;
		max-width: unset;
	}

	.error-hint code {
		font-family: var(--font-monospace-code);
		font-size: 0.82em;
		color: var(--brand);
		background-color: color-mix(in oklch, var(--brand), transparent 88%);
		padding: 0.1em 0.4em;
		border-radius: var(--r-base);
	}

	/*  outline Button */
	.back-btn {
		display: inline-flex;
		align-items: center;
		align-self: flex-start;
		font-family: semibold;
		font-size: 0.875rem;
		font-weight: 500;
		color: var(--text);
		text-decoration: none;
		padding: 0.5rem 1.1rem;
		border: 1px solid var(--border-default);
		border-radius: var(--r-m);
		background-color: transparent;
		transition:
			background-color 120ms ease,
			border-color 120ms ease;
	}
	.back-btn:hover {
		background-color: color-mix(in oklch, var(--text), transparent 93%);
		border-color: color-mix(in oklch, var(--text), transparent 55%);
	}
</style>

page in +error.svelte comes from $app/state — the same reactive object available throughout the application. page.status carries the HTTP status code passed to error(). page.error carries the error object whose message property holds the string passed as the second argument. Reading page.url.searchParams.get('returnTo') inside $derived keeps the back link reactive — should SvelteKit ever re-render the error boundary with different URL parameters, the link updates automatically.


TypeScript End to End

Every load function in this application uses the generated $types from its own route directory. This is worth making explicit because importing the wrong type is a silent bug: the type looks correct but carries the wrong data shape.

// Type import reference for this project

// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'

// src/routes/+layout.svelte
import type { LayoutData } from './$types'

// src/routes/+page.ts       ← universal load (public API, client re-execution)
import type { PageLoad } from './$types'

// src/routes/+page.svelte
import type { PageData } from './$types'

// src/routes/[name]/+page.server.ts    ← server-only (throws error 404)
import type { PageServerLoad } from './$types'

// src/routes/[name]/+page.svelte
import type { PageData } from './$types'

// src/routes/[name]/+error.svelte
// No explicit import needed — page from $app/state provides page.status and page.error.

The list page uses PageLoad (universal) because it calls a public, unauthenticated API and benefits from client-side re-execution on navigation. The detail page uses PageServerLoad (server-only) because it throws error(404) for unknown Pokémon and because keeping error-handling logic server-side is the safer default. The choice is not aesthetic — it determines where the code runs and what it can do.

The ./$types path is route-relative. Each route directory has its own generated module. Importing PageData from a sibling route’s $types is a silent type error that TypeScript will not catch because both types share the same name but have different shapes. The full type system is covered in TypeScript and $types in SvelteKit Load Functions.


Invalidation in Practice

The generation filter works through URL navigation: clicking a generation link changes the URL, SvelteKit detects the url.searchParams change, and re-runs the list load automatically. No extra code needed.

A “Refresh” button demonstrates programmatic invalidation: re-running the list load without changing the URL, using the custom dependency key registered with depends().

The relevant parts from the list component:

<!-- src/routes/+page.svelte — invalidation and context encoding -->
<script lang="ts">
	import { invalidate } from '$app/navigation'

	let refreshing = $state(false)

	async function refresh() {
		refreshing = true
		// Only the list load re-runs. The layout load is untouched.
		await invalidate('app:pokemon-list')
		refreshing = false
	}

	function buildCardUrl(index: number): string {
		const p = data.pokemon[index]
		const prevPoke = index > 0 ? data.pokemon[index - 1] : null
		const nextPoke = index < data.pokemon.length - 1 ? data.pokemon[index + 1] : null
		const params = new URLSearchParams()
		if (prevPoke) params.set('prev', prevPoke.name)
		if (nextPoke) params.set('next', nextPoke.name)
		params.set('returnTo', buildPageUrl(data.currentPage))
		return `/${p.name}?${params.toString()}`
	}
</script>

depends('app:pokemon-list') in the load function and invalidate('app:pokemon-list') in the component are two ends of a contract. The load function declares what it depends on; any code holding invalidate can trigger it by passing the same key. Only load functions that declared the key re-run — the root layout load is completely unaffected. invalidateAll() would re-run every load function for the current route including the layout, fetching the generation list unnecessarily on every refresh. The depends / invalidate pairing makes the scope of re-execution explicit and surgical.


Common Mistakes and Anti-Patterns

Awaiting parent() before the parallel fetches

// Avoid: parent() is awaited before the fetch starts.
// The list fetch is blocked behind the layout load unnecessarily.
export const load: PageLoad = async ({ fetch, url, parent }) => {
	const { generations } = await parent() // fetch has not started yet

	const listPromise = fetchAllPokemon(fetch, PAGE_SIZE, offset)
	const pokemon = await listPromise

	// Total time = layout load time + list fetch time (sequential)
	return { pokemon }
}
// Preferred: start the fetch before awaiting parent.
// Both run concurrently; total time equals the slower of the two.
export const load: PageLoad = async ({ fetch, url, parent }) => {
	const listPromise = fetchAllPokemon(fetch, PAGE_SIZE, offset) // starts immediately

	const { generations } = await parent() // layout load runs in parallel

	const pokemon = await listPromise // already resolving or resolved
	return { pokemon }
}

Using the global fetch instead of the event fetch

// Avoid: global fetch does not receive SvelteKit's enhancements.
// No cookie forwarding, no SSR deduplication, no relative URL resolution.
export const load: PageServerLoad = async ({ params }) => {
	const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${params.name}`)
	return { pokemon: await response.json() }
}
// Preferred: always destructure fetch from the load event.
export const load: PageServerLoad = async ({ params, fetch }) => {
	const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${params.name}`)
	return { pokemon: await response.json() }
}

Awaiting the evolution chain Promise before returning

// Avoid: awaiting the streamed Promise blocks the entire load function.
// Initial HTML is delayed by the sequential species + evolution chain fetches.
export const load: PageServerLoad = async ({ params, fetch }) => {
	const [pokemonRes, speciesRes] = await Promise.all([...])
	const pokemon: Pokemon = await pokemonRes.json()
	const species: PokemonSpecies = await speciesRes.json()

	// This blocks the load function until the third sequential request completes.
	const evolutionChain = await fetchEvolutionChain(fetch, species.evolution_chain.url)

	return { pokemon, species, evolutionChain }
}
// Preferred: return the Promise itself. SvelteKit handles the streaming.
// The page renders immediately with pokemon and species; evolutionChain streams in.
export const load: PageServerLoad = async ({ params, fetch }) => {
	const [pokemonRes, speciesRes] = await Promise.all([...])
	const pokemon: Pokemon = await pokemonRes.json()
	const species: PokemonSpecies | null = speciesRes.ok ? await speciesRes.json() : null

	return {
		pokemon,
		species,
		evolutionChain: species
			? fetchEvolutionChain(fetch, species.evolution_chain.url)
			: Promise.resolve(null)
	}
}

Forgetting to handle the pending state in {#await}

<!-- Avoid: no pending state. The section is empty on a slow connection
     and the user has no indication that content is loading. -->
{#await data.evolutionChain then stages}
	<ol></ol>
{/await}
<!-- Preferred: always handle all three states for streamed data.
     The pending state informs the user; the catch state prevents a blank
     section when the evolution API is unavailable. -->
{#await data.evolutionChain}
	<p aria-live="polite">Loading evolution chain…</p>
{:then stages}
	<ol></ol>
{:catch}
	<p>Evolution data unavailable.</p>
{/await}

Reading from $app/stores instead of $app/state in Svelte 5

<!-- Avoid: $app/stores uses the Svelte 4 store model.
     The $page prefix is the store auto-subscription shorthand, not a rune. -->
<script lang="ts">
	import { page } from '$app/stores'
	const activeGen = $derived($page.url.searchParams.get('generation') ?? '')
</script>
<!-- Preferred: $app/state exposes page as a rune-compatible reactive object.
     Property access inside $derived is sufficient — no subscription needed. -->
<script lang="ts">
	import { page } from '$app/state'
	const activeGen = $derived(page.url.searchParams.get('generation') ?? '')
</script>

Performance and Scaling Considerations

PokéAPI is public and rate-limited. Under normal usage the application makes two to four API calls per page load. A high-traffic deployment would hit rate limits without a caching layer. Adding setHeaders({ 'cache-control': 'public, max-age=3600' }) to the layout load would allow a CDN to cache the generation list for an hour; similar headers on the list and detail server loads benefit those endpoints too.

The parallel fetch structure on the detail page means total load time equals the slower of the two parallel requests, not their sum. For endpoints taking 80ms and 120ms respectively, parallel loading costs 120ms; sequential loading costs 200ms. That gap compounds with every page view.

fetchByGeneration fires up to twenty concurrent resolvePokemonEntry requests via Promise.all. This is necessary because the generation endpoint returns species resources, each of which must be individually fetched to resolve the correct Pokémon variety and sprite. Waiting for them sequentially would multiply network latency by up to twenty. The burst of parallel requests is acceptable for personal or low-traffic use; a production application with meaningful traffic would cache generation data server-side to avoid the per-entry resolution entirely.

The depends() and invalidate() pairing means a refresh button or background polling mechanism can re-fetch the list without touching the generation data. In a real application where list content changes frequently, this avoids redundant layout loads on every refresh cycle.


What’s Next

This is the final article in the SvelteKit data loading series. Everything from the basic shape of a +page.server.ts file through parallel fetching, streaming, type safety, error boundaries, and dependency-driven invalidation has now been covered in depth and connected together in a working project. The natural continuation is applying these loading patterns to authenticated, multi-user applications, which Authentication Patterns in Load Functions covers in full.


Further Reading