Advanced Patterns and Navigation Control
Welcome to Part 12 of our comprehensive SvelteKit routing series. At this point, you’ve built solid fundamentals and explored core patterns. Now we’re entering territory that truly separates production-grade applications from basic projects.
This article isn’t just about showing you more features—it’s about understanding when and why to use them. Each pattern we’ll cover solves specific architectural challenges you’ll encounter as your applications grow in complexity.
What You’ll Master
By the end of this article, you’ll understand:
- Param Matchers: How to validate routes at the routing layer (and why that’s fundamentally different from validating in load functions)
- Route Groups: The architectural thinking behind organizing layouts without polluting URLs
- Layout Breaking: When breaking out of the layout hierarchy is the right choice vs. a code smell
- Shallow Routing: The mental model behind URL state that doesn’t trigger full navigations
- Navigation Lifecycle: How to intercept, modify, and respond to every phase of navigation
More importantly, you’ll understand the decision-making process for choosing between these patterns.
Don't Use These Just Because You CanAdvanced routing isn’t about using every feature, it’s about choosing the right tool for each problem. A well-architected app uses these patterns sparingly but strategically.
Param Matchers: Route-Level Validation
The Problem: Defense in Depth
Let’s start with a scenario that trips up many developers. You have a user profile route:
src/routes/users/[id]/+page.svelte Your load function fetches the user by ID and returns their data. Simple enough. But what happens when someone visits /users/hello or /users/../admin?
The naive approach is to validate in your load function:
// The approach most developers start with
export const load: PageLoad = async ({ params }) => {
if (!/^\d+$/.test(params.id)) {
error(400, 'Invalid user ID')
}
// fetch user...
} This works, but it’s solving the problem at the wrong layer.
Think about it: you’re accepting the route match, spinning up the load function, potentially even hitting middleware or hooks—all for a URL that was never valid to begin with. In a high-traffic application, this adds unnecessary load. More importantly, it creates a category mismatch: “invalid URL format” isn’t a 400 Bad Request, it’s “this page doesn’t exist.”
Param matchers solve this at the routing layer itself. Invalid URLs simply don’t match the route—they fall through to other routes or 404.
Defense in DepthValidation belongs at the earliest possible layer. Database constraints catch what business logic misses. Business logic catches what API validation misses. API validation catches what routing validation misses. Each layer is a safety net for the one above it.
Creating Your First Matcher
Matchers live in src/params/ and export a match function. The function receives the URL segment and returns true if it’s valid:
// src/params/integer.ts
import type { ParamMatcher } from '@sveltejs/kit'
export const match: ParamMatcher = (param) => {
return /^\d+$/.test(param)
} Apply the matcher using = syntax in your route folder name:
src/routes/users/[id=integer]/+page.svelte Now the behavior changes fundamentally:
/users/123→ Matches,params.id = '123'/users/456789→ Matches,params.id = '456789'/users/hello→ Does NOT match, SvelteKit tries other routes/users/12.5→ Does NOT match (not an integer)
Notice the mental model shift: we’re not throwing errors for invalid inputs. We’re saying “this route simply doesn’t exist for non-integer values.”
The Routing Cascade: Why This Matters
When a URL doesn’t match a matcher, SvelteKit doesn’t immediately 404. It continues checking other routes. This enables powerful disambiguation patterns:
src/routes/
├── users/
│ ├── [id=integer]/+page.svelte ← /users/123
│ └── [username]/+page.svelte ← /users/johndoe Both routes have dynamic parameters, but they serve completely different purposes. The matcher creates automatic priority:
- Try
[id=integer]first (matchers have higher specificity) - If that doesn’t match, fall through to
[username]
This is a game-changer for API design. You can have /users/123 for user IDs and /users/johndoe for usernames without any conditional logic in your code. The router handles disambiguation automatically.
Matcher PriorityThis pattern mirrors how you’d design a REST API. The routing layer becomes your first line of input validation, and your load functions can trust that they’re receiving well-formed parameters. This is the Single Responsibility Principle applied to routing.
Building a Practical Matcher Library
In production applications, you’ll want a collection of reusable matchers. Here are battle-tested patterns:
Integer Matcher
// src/params/integer.ts
import type { ParamMatcher } from '@sveltejs/kit'
export const match: ParamMatcher = (param) => {
return /^\d+$/.test(param)
} UUID Matcher
// src/params/uuid.ts
import type { ParamMatcher } from '@sveltejs/kit'
// Matches UUID v1-5 format
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export const match: ParamMatcher = (param) => {
return UUID_REGEX.test(param)
} Why this matters: UUIDs are notoriously easy to typo. Without validation, a user bookmarking /products/550e8400-e29b-41d4-a716-44665544000 (missing last digit) would see a database error instead of a clean 404.
Slug Matcher
// src/params/slug.ts
import type { ParamMatcher } from '@sveltejs/kit'
export const match: ParamMatcher = (param) => {
// Lowercase letters, numbers, hyphens
// Must start with letter, 3-100 chars
return /^[a-z][a-z0-9-]{2,99}$/.test(param)
} Design decision: We require starting with a letter to prevent slugs like 123-product which could be confused with ID-based routes.
Date Matcher
// src/params/date.ts
import type { ParamMatcher } from '@sveltejs/kit'
export const match: ParamMatcher = (param) => {
// YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(param)) {
return false
}
// Verify it's actually a valid date (not 2025-02-30)
const date = new Date(param + 'T00:00:00Z')
return !isNaN(date.getTime()) && param === date.toISOString().split('T')[0]
} Why the extra validation? Regex alone would accept 2025-02-30 (February 30th doesn’t exist). The Date constructor validates actual calendar logic.
UTC Time in Date MatchersNotice we use UTC time (
T00:00:00Z). Without this, date parsing behavior varies by server timezone, leading to “works in development, breaks in production” bugs.
Type-Safe Matchers
Here’s where matchers become truly powerful. You can create matchers that not only validate strings but also narrow TypeScript types:
// src/params/category.ts
import type { ParamMatcher } from '@sveltejs/kit'
const categories = ['electronics', 'clothing', 'books', 'home', 'sports'] as const
export type Category = (typeof categories)[number]
export const match = ((param: string): param is Category => {
return categories.includes(param as Category)
}) satisfies ParamMatcher The magic happens in your load function:
// src/routes/shop/[category=category]/+page.ts
import type { PageLoad } from './$types'
import type { Category } from '$params/category'
export const load: PageLoad = async ({ params }) => {
// TypeScript knows params.category is 'electronics' | 'clothing' | 'books' | 'home' | 'sports'
// NOT just string!
const category: Category = params.category
} Type-Safe MatchersType predicates (
param is Category) are an underused TypeScript feature. Combined withsatisfies, you get both runtime validation and compile-time type narrowing. This is defense in depth at the type level.
The Hard Constraint: Matchers Are Universal
This is the critical limitation that catches many developers:
Matchers run in BOTH server and browser. Your matcher code ships to the client for client-side navigation. This means:
| Allowed in Matchers | Not Allowed in Matchers |
|---|---|
| Pure string validation | Database queries |
| RegEx operations | Filesystem access |
| Simple synchronous logic | $lib/server/* imports |
| Environment variables (unless public) |
// AVOID: This will break client-side navigation
import { db } from '$lib/server/database'
export const match: ParamMatcher = async (param) => {
const user = await db.users.findUnique({ where: { id: param } })
return !!user
}
// PREFERRED: Validate format, check existence in load function
export const match: ParamMatcher = (param) => {
return /^[a-f0-9]{24}$/.test(param) // MongoDB ObjectId format
} The rule of thumb: Matchers validate format, load functions validate existence.
Matcher LimitationsIf you find yourself wanting database access in a matcher, you’re trying to solve the wrong problem. The question isn’t “does this user exist?” but “is this a valid user ID format?” Existence checks belong in load functions where you have full server capabilities.
Route Groups: Architectural Organization Without URL Pollution
Route groups solve one of the most common growing pains in SvelteKit applications: how do you organize your layouts and code without forcing that organization into your URLs?
The Problem: Layouts Want to Own URLs
Imagine you’re building a SaaS product. You have three distinct sections:
- Marketing pages (
/,/about,/pricing) — Public, marketing layout with big hero sections - App pages (
/dashboard,/settings,/profile) — Authenticated, app layout with sidebar - Auth pages (
/login,/register,/forgot-password) — Public, minimal centered layout
The natural SvelteKit instinct is to create layout hierarchies:
src/routes/
├── +layout.svelte ← Root layout
├── marketing/
│ ├── +layout.svelte ← Marketing layout
│ ├── +page.svelte ← /marketing (wait, we wanted /)
│ └── about/+page.svelte ← /marketing/about (not /about!)
├── app/
│ ├── +layout.svelte
│ └── dashboard/+page.svelte ← /app/dashboard (not /dashboard!) The URLs are polluted with your organizational structure. Users see /app/dashboard when they should see /dashboard.
Route Groups: Organization Without URLs
Route groups use parentheses to create layout boundaries that are invisible to URLs:
src/routes/
├── (marketing)/
│ ├── +layout.svelte ← Marketing layout
│ ├── +page.svelte ← / (the homepage!)
│ ├── about/
│ │ └── +page.svelte ← /about
│ └── pricing/
│ └── +page.svelte ← /pricing
├── (app)/
│ ├── +layout.svelte ← App layout with sidebar
│ ├── +layout.server.ts ← Auth protection for all app routes
│ ├── dashboard/
│ │ └── +page.svelte ← /dashboard
│ └── settings/
│ └── +page.svelte ← /settings
└── (auth)/
├── +layout.svelte ← Minimal centered layout
├── login/
│ └── +page.svelte ← /login
└── register/
└── +page.svelte ← /register The parentheses tell SvelteKit: “this is organizational, not a URL segment.” Users see /dashboard, not /(app)/dashboard.
Mental Model: Layout IslandsRoute groups are about separation of concerns. Each group can have its own layout, its own layout load function, its own error boundaries. You’re not just organizing files—you’re creating independent mini-applications within your app.
The Authentication Pattern: One Check, Many Routes
One of the most powerful uses of route groups is centralized authentication. Instead of checking auth in every load function, you do it once at the group level:
// src/routes/(app)/+layout.server.ts
import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals, url }) => {
// This runs for ALL routes in the (app) group
// /dashboard, /settings, /profile, /billing - all protected automatically
if (!locals.user) {
// Preserve where they were trying to go for post-login redirect
const redirectTo = encodeURIComponent(url.pathname + url.search)
redirect(303, `/login?redirectTo=${redirectTo}`)
}
// User is authenticated - make their data available to all child routes
return {
user: {
id: locals.user.id,
name: locals.user.name,
email: locals.user.email,
avatar: locals.user.avatar
}
}
} Why this is better than per-route checks:
- DRY: One auth check covers unlimited routes
- Impossible to forget: New routes in
(app)are automatically protected - Single source of truth: Auth logic changes in one place
- Data availability: User data flows to all child routes automatically
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import type { LayoutProps } from './$types'
import Sidebar from '$lib/components/Sidebar.svelte'
import Header from '$lib/components/Header.svelte'
let { data, children }: LayoutProps = $props()
</script>
<div class="app-layout">
<Sidebar user={data.user} />
<div class="main-area">
<Header user={data.user} />
<main>
{@render children()}
</main>
</div>
</div>
<style>
.app-layout {
display: grid;
grid-template-columns: 250px 1fr;
min-height: 100vh;
}
.main-area {
display: flex;
flex-direction: column;
}
main {
flex: 1;
padding: 2rem;
}
</style> Every page in (app) now has access to data.user because layout data flows down to children.
Scaling with Route GroupsThis pattern scales beautifully. Need role-based access? Create
(admin)and(user)groups. Need subscription tiers? Create(free),(pro),(enterprise)groups. The file system becomes your authorization architecture.
Homepage Placement: The Root Page Question
You can place +page.svelte directly in a route group to create the root page:
src/routes/
├── (marketing)/
│ ├── +layout.svelte
│ └── +page.svelte ← This IS the homepage (/)
└── (app)/
└── dashboard/
└── +page.svelte ← /dashboard This keeps your homepage with its marketing siblings, using the marketing layout.
The Conflict Trap: One Group Per URL
Here’s a mistake that will give you cryptic build errors:
src/routes/
├── (marketing)/
│ └── +page.svelte ← /
└── (app)/
└── +page.svelte ← Also / - CONFLICT! Only one group can claim a given URL path. Route groups affect layouts, not URL resolution. Two groups can’t both have a page at the same URL.
Group ConflictThink of route groups as “layout containers,” not “URL namespaces.” The URL space is flat—groups just let you apply different layouts to different parts of that flat space.
Breaking Out of Layouts: The Escape Hatch
Layouts nest. That’s their power and, occasionally, their problem. Sometimes a deeply nested page needs to escape its layout hierarchy entirely.
Understanding Why You’d Want This
Consider an enterprise dashboard with this structure:
src/routes/
├── +layout.svelte ← Root: nav, footer
├── (app)/
│ ├── +layout.svelte ← App chrome: sidebar
│ └── dashboard/
│ ├── +layout.svelte ← Dashboard nav: tabs, breadcrumbs
│ └── reports/
│ ├── +layout.svelte ← Reports: toolbar, filters
│ └── [id]/
│ └── +page.svelte ← Uses ALL 4 layouts! For /dashboard/reports/123, the user sees:
Root layout → App layout → Dashboard layout → Reports layout → Page content That’s potentially four layers of navigation, sidebars, and chrome. But what if you need to:
- Embed a report in an iframe on a client’s website (no chrome at all)
- Print a report (just the content, no navigation)
- Show a fullscreen presentation view (app auth, but no distracting UI)
You need to break out of the layout chain.
The @ Syntax: Choosing Your Layout Ancestry
The @ symbol in filenames specifies where to “reset” the layout chain:
+page@.svelte ← Reset to root layout only
+page@(app).svelte ← Reset to (app) layout (keep auth, skip dashboard/reports chrome)
+page@dashboard.svelte ← Reset to dashboard layout (skip just reports chrome) Real-World Example: Embeddable Reports
src/routes/
├── +layout.svelte
├── (app)/
│ ├── +layout.svelte
│ └── dashboard/
│ ├── +layout.svelte
│ └── reports/
│ ├── +page.svelte ← Full app chrome - internal use
│ └── [id]/
│ ├── +page.svelte ← Full app chrome - viewing a report
│ └── embed/
│ └── +page@.svelte ← ROOT ONLY - embeddable! The embed page breaks out to root layout only:
<!-- src/routes/(app)/dashboard/reports/[id]/embed/+page@.svelte -->
<script lang="ts">
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<!-- No navigation, no sidebar, no header, no footer -->
<!-- Clean slate for iframe embedding -->
<div class="embedded-report">
<h1>{data.report.title}</h1>
<ReportChart data={data.report.chartData} />
</div>
<style>
.embedded-report {
padding: 1rem;
font-family: system-ui;
/* iframe-friendly styles */
}
</style> Now /dashboard/reports/123/embed shows just the report content—perfect for <iframe src="/dashboard/reports/123/embed">.
Layout Breaking IntentThe embed pattern is incredibly useful for integrations. Customers can embed your reports in their dashboards, Notion pages, or internal tools. One page serves two completely different UX contexts.
When NOT to Use Layout Breaking
Layout breaking is a power tool. Like all power tools, it can cause damage when misused.
Signs you might be overusing @:
- Multiple pages in the same folder using different
@resets - Complex mental model of “which page uses which layouts”
- Team members confused about layout inheritance
Consider alternatives first:
<!-- Alternative: Conditional layout content -->
<!-- src/routes/(app)/dashboard/+layout.svelte -->
<script lang="ts">
import { page } from '$app/state'
let { data, children } = $props()
// Hide sidebar for specific routes
const isMinimalMode = $derived(
page.url.pathname.includes('/embed') ||
page.url.pathname.includes('/fullscreen') ||
page.url.pathname.includes('/print')
)
</script>
{#if isMinimalMode}
<main class="minimal">
{@render children()}
</main>
{:else}
<Sidebar user={data.user} />
<main class="with-sidebar">
{@render children()}
</main>
{/if} When to use @ vs conditional layouts:
Use @ when… | Use conditional layouts when… |
|---|---|
| The page is truly standalone | It’s just hiding some UI elements |
You need different <head> handling | The page still belongs in the same context |
| Performance matters (less layout code) | You want to animate between modes |
| The page might be rendered without JavaScript | You need state preserved during mode switch |
Layout Breaking IntentLayout breaking is about identity, not appearance. An embed page isn’t “the dashboard without a sidebar”, it’s a fundamentally different page that happens to show similar content. If you’re just toggling visibility, use conditionals.
Shallow Routing: URL State Without Page Transitions
Shallow routing is one of SvelteKit’s most elegant features, but it’s also one of the most misunderstood. Let’s build the mental model from first principles.
The Problem: Modals, Filters, and Shareable State
Consider a photo gallery. When a user clicks a thumbnail:
Without shallow routing:
- Full page navigation to
/photos/123 - Browser scroll position lost
- Gallery component unmounts and remounts
- Animation state lost
- Feels slow and jarring
What users actually want:
- Modal opens over the gallery
- URL updates to
/photos/123(so they can share the link!) - Back button closes the modal
- Gallery stays mounted underneath
This is the “modal URL” problem—you want URL-based state without URL-based navigation.
The Mental Model: History Without Hydration
pushState and replaceState modify the browser’s history stack and URL bar without triggering SvelteKit’s navigation system. No load functions run. No layouts remount. Just URL and state changes.
Think of it as “cosmetic navigation” — the URL changes for sharing and bookmarking purposes, but your app doesn’t actually navigate. The critical insight is that your app supports two different experiences for the same URL depending on how the user arrived:
- Clicked from within the app: The gallery is already rendered;
pushStateadds a history entry so the back button works, and the modal opens over the existing page. - Landed directly from a shared link: The actual
+page.sveltefor/photos/123renders with full server-side data loading, showing the photo in its own standalone context.
Same URL, two different experiences, both correct. This is progressive enhancement applied to URL state.
<script lang="ts">
import { pushState } from '$app/navigation'
import { page } from '$app/state'
// The gallery data - stays loaded
let photos = $state([
/* ... */
])
function openPhoto(id: string) {
// Update URL and store state, but DON'T navigate
pushState(`/photos/${id}`, { selectedPhotoId: id })
}
function closePhoto() {
// Go back in history (closes modal, restores previous URL)
history.back()
}
// Derive modal visibility from state
const selectedPhoto = $derived(
page.state.selectedPhotoId ? photos.find((p) => p.id === page.state.selectedPhotoId) : null
)
</script>
<!-- Gallery stays rendered -->
<div class="gallery">
{#each photos as photo}
<button onclick={() => openPhoto(photo.id)}>
<img src={photo.thumbnail} alt={photo.title} />
</button>
{/each}
</div>
<!-- Modal overlays when photo selected -->
{#if selectedPhoto}
<div class="modal-backdrop" onclick={closePhoto}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<img src={selectedPhoto.fullSize} alt={selectedPhoto.title} />
<h2>{selectedPhoto.title}</h2>
<button onclick={closePhoto}>Close</button>
</div>
</div>
{/if} The URL shows /photos/123 but we never left the gallery page. User shares the link, recipient lands on the actual /photos/123 route with full page content.
Shallow Routing IntentShallow routing is about intent. The URL
/photos/123means “I’m looking at photo 123.” But how I got there matters for UX. Clicking from the gallery? Show a modal. Landing from a shared link? Show the full page. Same URL, different experiences.
Advanced Pattern: Preloading Data for Modals
The previous example assumes the gallery already has all photo data. But what if the modal needs additional data (high-res image, comments, metadata)?
SvelteKit’s preloadData lets you fetch route data without navigating:
<script lang="ts">
import { preloadData, pushState, goto } from '$app/navigation'
import { page } from '$app/state'
import PhotoDetail from './[id]/+page.svelte'
async function openPhoto(id: string, event: MouseEvent) {
// Don't interfere with ctrl+click (open in new tab)
if (event.metaKey || event.ctrlKey || event.shiftKey) return
event.preventDefault()
const href = `/photos/${id}`
// Fetch the data that /photos/[id] would load
const result = await preloadData(href)
if (result.type === 'loaded' && result.status === 200) {
// Success: open modal with preloaded data
pushState(href, { photoData: result.data })
} else if (result.type === 'redirect') {
// The route redirected (maybe photo was deleted)
goto(result.location)
} else {
// Error loading data: fall back to full navigation
goto(href)
}
}
</script>
<!-- Links work normally for accessibility, JS enhances them -->
{#each photos as photo}
<a
href="/photos/{photo.id}"
onclick={(e) => openPhoto(photo.id, e)}
data-sveltekit-preload-data="hover"
>
<img src={photo.thumbnail} alt={photo.title} />
</a>
{/each}
{#if page.state.photoData}
<div class="modal-backdrop" onclick={() => history.back()}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<!-- Render the actual page component with preloaded data! -->
<PhotoDetail data={page.state.photoData} />
</div>
</div>
{/if} Accessibility and SEONotice we use
<a>tags, not<button>. This is critical for accessibility and SEO. Screen readers announce links correctly, search engines can crawl them, and ctrl+click opens new tabs. JavaScript enhances, it doesn’t replace.
replaceState: Update Without History
Sometimes you want to update the URL without creating a new history entry. Filters are the classic example—each keystroke in a search box shouldn’t create a back-button step.
<script lang="ts">
import { replaceState } from '$app/navigation'
import { page } from '$app/state'
function updateFilters(key: string, value: string) {
const url = new URL(page.url)
if (value) {
url.searchParams.set(key, value)
} else {
url.searchParams.delete(key)
}
// Update URL without creating history entry
replaceState(url, page.state)
}
</script>
<input
type="search"
value={page.url.searchParams.get('q') ?? ''}
oninput={(e) => updateFilters('q', e.currentTarget.value)}
placeholder="Search..."
/>
<select onchange={(e) => updateFilters('sort', e.currentTarget.value)}>
<option value="">Sort by...</option>
<option value="newest">Newest first</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
</select> Use pushState when: The state change is a “destination” (opening a modal, viewing an item)
Use replaceState when: The state change is incremental (filtering, sorting, form input)
Navigation Lifecycle Hooks: Total Control
SvelteKit gives you hooks to intercept every phase of navigation. This is where you implement “unsaved changes” warnings, page transition animations, analytics, and custom loading indicators.
The Three Hooks: When They Fire
User clicks link
↓
┌─────────────────────────┐
│ beforeNavigate() │ ← Can CANCEL navigation
│ "Should we leave?" │
└────────────┬────────────┘
↓
┌─────────────────────────┐
│ onNavigate() │ ← Can DELAY navigation
│ "Prepare for change" │ (View Transitions live here)
└────────────┬────────────┘
↓
[ Navigation happens ]
[ DOM updates ]
↓
┌─────────────────────────┐
│ afterNavigate() │ ← React to completed navigation
│ "Navigation finished" │ (Analytics, scroll, cleanup)
└─────────────────────────┘ beforeNavigate: The Guardian
This hook runs first and can cancel navigation entirely. Perfect for “unsaved changes” protection:
<script lang="ts">
import { beforeNavigate } from '$app/navigation'
let hasUnsavedChanges = $state(false)
let formContent = $state('')
beforeNavigate((navigation) => {
if (!hasUnsavedChanges) return // Let navigation proceed
if (navigation.willUnload) {
// User is leaving the site entirely (closing tab, external link)
// cancel() here triggers browser's native "Leave site?" dialog
navigation.cancel()
} else {
// Internal navigation - show custom confirmation
if (!confirm('You have unsaved changes. Leave anyway?')) {
navigation.cancel()
}
}
})
function handleInput(e: Event) {
formContent = (e.target as HTMLTextAreaElement).value
hasUnsavedChanges = true
}
function handleSave() {
// Save logic...
hasUnsavedChanges = false
}
</script>
<form>
<textarea oninput={handleInput} value={formContent}></textarea>
<button type="button" onclick={handleSave}>Save</button>
</form> Unsaved ChangesBe sparing with
cancel(). Users hate being trapped. Only block navigation when there’s genuinely unsaved work, and always provide a clear way to discard changes and leave.
afterNavigate: Analytics and Cleanup
Runs after navigation completes and the DOM has updated. This is where you track page views and handle scroll behavior:
<script lang="ts">
import { afterNavigate } from '$app/navigation'
afterNavigate((navigation) => {
// Track page views with context
analytics.track('page_view', {
from: navigation.from?.url.pathname ?? '(entry)',
to: navigation.to?.url.pathname,
type: navigation.type,
duration: performance.now() // Time since page load
})
// Custom scroll behavior based on navigation type
if (navigation.type === 'link' || navigation.type === 'goto') {
// User intentionally navigated - smooth scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 'popstate' (back/forward) - browser handles scroll restoration
})
</script> Popstate ScrollDon’t fight the browser on
popstatescroll. Users expect back/forward to return them to their previous scroll position. Breaking this expectation is a common UX mistake.
Conclusion
Advanced routing patterns in SvelteKit reveal the framework’s true flexibility—the ability to break conventions when needed while maintaining the benefits of convention-based routing. Param matchers provide type-safe parameter validation at the routing layer, route groups organize complex applications without URL pollution, layout breaking enables standalone pages when the hierarchy doesn’t fit, shallow routing creates shareable modal URLs, and navigation hooks provide lifecycle control for guards, preparation, and analytics.
Mastering these patterns means knowing when to use them and when to stick with the defaults. Most applications thrive on SvelteKit’s conventions, but production apps inevitably encounter cases where the standard patterns need augmentation. By understanding param matchers for API route versioning, route groups for authenticated sections, shallow routing for modal states, and navigation hooks for analytics integration, you build applications that handle complexity elegantly without fighting the framework. These aren’t escape hatches—they’re power tools that complement the routing fundamentals.
Key Takeaways
- Param matchers validate route parameters at the routing layer using
src/params/*.jsfiles, providing type-safe parameter validation before components render - Route groups organize layouts using parentheses like
(authenticated)to create layout hierarchies without affecting URLs, perfect for protected routes - Layout breaking with
@syntax allows pages to bypass parent layouts:+page@.svelterenders directly under root, escaping the normal layout hierarchy - Shallow routing updates URLs without navigation using
pushStateandreplaceStatefor modal dialogs, filters, or tabs where full navigation would lose component state beforeNavigateenables navigation guards for unsaved changes warnings, authentication checks, or conditional redirects before navigation completesonNavigateprepares for page changes - perfect for View Transitions, scroll handling, or loading states that need to start before the new page rendersafterNavigatetracks completed navigation for analytics, focus management, or cleanup operations that should occur after the new page is visible- Param matchers enable custom validation - create matchers like
integer,slug, oruuidto ensure routes only match valid parameter formats
See Also
- Official SvelteKit Documentation - Advanced Routing
- Param Matchers - Custom parameter validation
- Route Groups - Layout organization patterns
- Navigation Hooks - beforeNavigate, onNavigate, afterNavigate
- Shallow Routing - pushState and replaceState
- Breaking Layouts - @ reset syntax
- Expert Routing - Production architecture patterns