Introduction
The Serialization Challenge
When building full-stack applications with SvelteKit, you frequently need to pass data between the server and client. By default, SvelteKit uses devalue to serialize data returned from load functions, form actions, and remote functions. While devalue handles many types beyond JSON (like Date, Map, Set, BigInt, RegExp, and even cyclical references), it cannot automatically serialize custom class instances.
This is where the Transport hook comes in. Available since SvelteKit 2.11, Transport allows you to define custom serialization/deserialization logic for your own classes, enabling you to seamlessly pass complex domain objects across the server/client boundary.
The Problem Transport Solves
Consider this common scenario:
// ❌ This will throw an error!
// lib/models/User.ts
export class User {
constructor(
public id: number,
public name: string,
public email: string
) {}
greet() {
return `Hello, ${this.name}!`
}
}
// routes/+page.server.ts
import { User } from '$lib/models/User'
export async function load() {
const user = new User(1, 'Alice', 'alice@example.com')
return {
user // Error: Cannot stringify arbitrary non-POJOs
}
} Without Transport, SvelteKit cannot serialize the User class instance. You would receive an error: "Cannot stringify arbitrary non-POJOs".
How Transport Works
The Transport hook is defined in src/hooks.js (or src/hooks.ts) and exports a transport object containing transporters for your custom types.
Basic Structure
// src/hooks.ts
import type { Transport } from '@sveltejs/kit'
export const transport: Transport = {
TypeName: {
encode: (value) => {
// Return false if value is not an instance of TypeName
// Otherwise, return a serializable representation
},
decode: (data) => {
// Convert the serialized data back into an instance
}
}
} The Encode/Decode Pattern
encode: Runs on the server. Receives a value and must return:- A non-falsy value (object or array) if the value is an instance of your type
false(or any falsy value) if it’s not an instance of your type
decode: Runs in the browser. Receives the encoded data and reconstructs the original instance.
Important: The encoded value must be serializable by devalue. This means it should be a plain object, array, or contain only serializable types (strings, numbers, Dates, Maps, etc.).
Real-World Use Cases
1. Domain Models with Methods
One of the most common use cases is transporting domain models that have both data and behavior. In traditional full-stack applications, you often end up with “anemic domain models” - plain objects that lose their methods when serialized from the server to the client. This forces you to either duplicate logic on both sides or work with plain data objects that don’t encapsulate business logic.
The Problem: Imagine you have a User class with methods like greet(), getInitials(), or hasPermission(). When you return this from a server load function, it gets serialized to JSON, and the client receives a plain object without any methods. You’d need to either:
- Write separate utility functions on the client (
getUserInitials(user),userGreeting(user)) - Manually reconstruct the class instance on the client
- Give up on using classes altogether
The Solution: With Transport, you can maintain rich domain models with methods that work identically on both server and client. The class instances are automatically serialized on the server and reconstructed with all their methods intact on the client.
When to Use This Pattern:
- User profiles with computed properties (initials, display names, full names, permission checks)
- Business entities with validation and transformation logic (validateEmail, formatAddress)
- Domain objects that need to maintain invariants (ensuring a date range is valid, keeping prices non-negative)
- Models that benefit from encapsulation and OOP principles (hiding implementation details)
Real-World Benefits:
- Keep your code DRY (Don’t Repeat Yourself) - write business logic once
- Type safety across the full stack - TypeScript ensures consistency
- Easier testing - test your domain logic independently of framework code
- Better developer experience - IDE autocomplete works with methods, not just data
This approach is particularly powerful when combined with TypeScript’s type system, as you get compile-time checking that ensures your domain logic is used correctly on both sides of the server/client boundary.
// lib/models/User.ts
export class User {
constructor(
public id: number,
public name: string,
public email: string,
public createdAt: Date
) {}
greet() {
return `Hello, ${this.name}!`;
}
isNew() {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return this.createdAt > oneWeekAgo;
}
getInitials() {
return this.name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase();
}
}
// src/hooks.ts
import type { Transport } from '@sveltejs/kit';
import { User } from '$lib/models/User';
export const transport: Transport = {
User: {
encode: (value) =>
value instanceof User && {
id: value.id,
name: value.name,
email: value.email,
createdAt: value.createdAt
},
decode: (data) =>
new User(data.id, data.name, data.email, data.createdAt)
}
};
// routes/+page.server.ts
export async function load() {
const user = new User(
1,
'Alice Smith',
'alice@example.com',
new Date()
);
return { user };
}
// routes/+page.svelte
<script lang="ts">
let { data } = $props();
// user is automatically decoded back to a User instance!
// All methods are available
</script>
<h1>{data.user.greet()}</h1>
<p>Initials: {data.user.getInitials()}</p>
<p>New user: {data.user.isNew() ? 'Yes' : 'No'}</p> 2. Mathematical Types (Vectors, Matrices)
Perfect for games, data visualization, or scientific computing applications. Mathematical objects like vectors, matrices, quaternions, and complex numbers benefit enormously from having their operations encapsulated as methods rather than scattered as utility functions.
The Challenge: When building interactive experiences—whether it’s a particle system, a physics simulation, or a data visualization—you deal with hundreds or thousands of mathematical operations. Without proper encapsulation, your code becomes cluttered with utility function calls that obscure the actual logic.
Why Mathematical Types Need Transport: Traditional approaches either require you to perform all calculations on the server (limiting interactivity) or duplicate your entire math library on both sides. Transport solves this by allowing you to define your mathematical types once and use them everywhere.
Consider the difference in code clarity:
// With Transport - clean and intuitive
const force = velocity.normalize().multiply(speed)
const distance = positionA.subtract(positionB).magnitude()
// Without Transport - verbose utility functions
const force = multiplyVector(normalizeVector(velocity), speed)
const distance = vectorMagnitude(subtractVectors(positionA, positionB)) Common Use Cases:
- Game engines - Physics calculations, collision detection, spatial transformations, movement systems
- Data visualization - Coordinate transformations, projections, zoom/pan calculations
- Scientific computing - Numerical simulations, linear algebra operations, signal processing
- Animation - Interpolation, easing functions, path calculations, Bézier curves
- 3D graphics - Rotation matrices, camera controls, mesh transformations
Real-World Scenario: Imagine building a real-time particle system where the server computes initial positions and velocities, but the client handles the animation. With Transport, you can:
- Calculate initial particle states on the server using Vector math
- Send the particles to the client where they automatically become Vector instances
- Update positions each frame using the same Vector methods
- Never duplicate code or worry about synchronization
This pattern is especially powerful when combined with SvelteKit’s SSR—you can perform complex calculations on the server (leveraging more CPU power) and then continue those calculations seamlessly in the browser.
// lib/math/Vector.ts
export class Vector {
constructor(
public x: number,
public y: number
) {}
magnitude() {
return Math.sqrt(this.x * this.x + this.y * this.y)
}
normalize() {
const mag = this.magnitude()
return new Vector(this.x / mag, this.y / mag)
}
add(other: Vector) {
return new Vector(this.x + other.x, this.y + other.y)
}
dot(other: Vector) {
return this.x * other.x + this.y * other.y
}
}
// src/hooks.ts
import type { Transport } from '@sveltejs/kit'
import { Vector } from '$lib/math/Vector'
export const transport: Transport = {
Vector: {
encode: (value) => value instanceof Vector && [value.x, value.y],
decode: ([x, y]) => new Vector(x, y)
}
}
// routes/simulation/+page.server.ts
export async function load() {
const particles = [
{ position: new Vector(10, 20), velocity: new Vector(1, -0.5) },
{ position: new Vector(50, 30), velocity: new Vector(-0.8, 1.2) }
]
return { particles }
} 3. Money/Currency Objects
Essential for e-commerce applications where precision matters. One of the cardinal sins in financial software is using floating-point numbers for currency calculations. JavaScript’s infamous 0.1 + 0.2 !== 0.3 problem can lead to serious accounting errors, customer complaints, and even legal issues when dealing with money.
The Floating-Point Problem:
// ❌ This is why you need a Money class
let price = 0.1
let tax = 0.2
console.log(price + tax) // 0.30000000000000004
// This compounds with multiple operations
let total = 0
for (let i = 0; i < 10; i++) {
total += 0.1
}
console.log(total) // 0.9999999999999999 A Money class solves multiple critical problems:
- Precision: Store amounts as integers (cents) to completely avoid floating-point arithmetic errors
- Currency safety: Type system prevents accidentally adding USD to EUR, avoiding costly mistakes
- Formatting: Consistent, localized display across your application (
$1,234.56vs1.234,56 €) - Immutability: Money operations return new instances, preventing accidental mutations that could corrupt financial data
- Business logic: Tax calculations, discount rules, rounding strategies, and currency conversions stay encapsulated and testable
When You Need This Pattern:
- E-commerce platforms - Shopping carts, product pricing, checkout calculations, order totals
- Financial applications - Accounting systems, invoicing, payment processing, financial reports
- SaaS billing - Subscription management, usage-based pricing, proration, credit systems
- Point-of-sale systems - Transaction handling, receipt generation, cash drawer management
- Multi-currency applications - International marketplaces, forex platforms, travel booking
Real-World Impact: Without a Money class, you’d pass around plain numbers and need to:
- Remember to round correctly at every calculation point
- Handle formatting logic in every component that displays prices
- Write tests for every arithmetic operation to catch precision bugs
- Manually validate currency compatibility before calculations
- Risk regulatory issues with incorrect financial calculations
With Transport, your Money objects work identically whether calculating totals on the server or displaying prices in the browser. The same rounding rules, the same precision guarantees, the same formatting—all automatically maintained across the server/client boundary.
// lib/types/Money.ts
export class Money {
// Store cents to avoid floating point issues
private cents: number;
constructor(
amount: number,
public currency: string = 'USD'
) {
this.cents = Math.round(amount * 100);
}
get amount() {
return this.cents / 100;
}
add(other: Money) {
if (this.currency !== other.currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number) {
return new Money(this.amount * factor, this.currency);
}
format() {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: this.currency
}).format(this.amount);
}
}
// src/hooks.ts
import type { Transport } from '@sveltejs/kit';
import { Money } from '$lib/types/Money';
export const transport: Transport = {
Money: {
encode: (value) =>
value instanceof Money && {
amount: value.amount,
currency: value.currency
},
decode: (data) =>
new Money(data.amount, data.currency)
}
};
// routes/checkout/+page.server.ts
export async function load() {
return {
cart: {
items: [
{ name: 'Widget', price: new Money(29.99) },
{ name: 'Gadget', price: new Money(49.99) }
],
shipping: new Money(5.00),
tax: new Money(6.75)
}
};
}
// routes/checkout/+page.svelte
<script lang="ts">
let { data } = $props();
const subtotal = data.cart.items.reduce(
(sum, item) => sum.add(item.price),
new Money(0)
);
const total = subtotal
.add(data.cart.shipping)
.add(data.cart.tax);
</script>
<div>
<p>Subtotal: {subtotal.format()}</p>
<p>Shipping: {data.cart.shipping.format()}</p>
<p>Tax: {data.cart.tax.format()}</p>
<p><strong>Total: {total.format()}</strong></p>
</div> 4. Result Types (for Error Handling)
Implement functional error handling with Result types. Traditional try/catch error handling can make code flow difficult to follow, hides errors in type signatures, and forces you to handle errors at the wrong abstraction level. The Result pattern, popularized by Rust and functional programming languages, provides a more explicit and composable approach to error handling.
The Problem with Traditional Error Handling:
// ❌ Errors are invisible in the type signature
async function getUser(id: string): Promise<User> {
// Might throw DatabaseError, ValidationError, NetworkError...
// Caller has no idea what errors to handle
}
// Caller is forced to either:
try {
const user = await getUser('123')
// Hope for the best...
} catch (error) {
// What type of error? Who knows! 🤷
} Why Result Types are Superior: Instead of throwing exceptions that can be caught anywhere (or worse, forgotten), Result types make error handling explicit in your type signatures. A function that returns Result<User, DatabaseError> clearly communicates that it might fail and what kind of error to expect.
// ✅ Errors are explicit in the type signature
async function getUser(id: string): Promise<Result<User, DatabaseError>> {
// Types force you to handle both cases
}
// Caller must handle both success and failure
const result = await getUser('123')
if (result.isSuccess) {
const user = result.value // Type: User
} else {
const error = result.error // Type: DatabaseError
} Key Benefits:
- Explicit error handling: Impossible to ignore errors—they’re part of the type signature
- Composability: Chain operations with
map,flatMap,andThenand other combinators - No try/catch needed: Cleaner code flow without exception handling boilerplate everywhere
- Type safety: Compiler ensures you handle both success and failure cases
- Railway-oriented programming: Build robust data pipelines where errors automatically propagate
- Better refactoring: Changing error types is caught at compile time, not runtime
When to Use This Pattern:
- API responses - Structured, type-safe error handling from server to client
- Database operations - Explicit handling of query failures, connection issues
- Validation pipelines - Chaining multiple validation steps with error accumulation
- External service calls - Managing third-party API failures gracefully with retry logic
- Data transformations - Multi-step processes where any step can fail
- File I/O operations - Handling read/write failures without exceptions
Real-World Scenario: Imagine building a user registration flow that validates email, checks for duplicates, creates the account, and sends a welcome email. Each step can fail differently:
Result.ok(formData)
.map(validateEmail) // ValidationError
.flatMap(checkDuplicate) // DatabaseError
.flatMap(createAccount) // DatabaseError
.flatMap(sendWelcome) // EmailError
.match(
(user) => console.log('Success:', user),
(error) => handleError(error) // All error types handled
) With Transport, you can return Result types from server load functions and have them automatically reconstructed in the browser with all their methods intact. This maintains your error handling strategy across the full stack, ensuring consistent, type-safe error management everywhere.
// lib/types/Result.ts
export class Result<T, E> {
constructor(
private _value?: T,
private _error?: E,
public readonly isSuccess: boolean = true
) {}
static ok<T, E>(value: T): Result<T, E> {
return new Result<T, E>(value, undefined, true)
}
static err<T, E>(error: E): Result<T, E> {
return new Result<T, E>(undefined, error, false)
}
get value(): T {
if (!this.isSuccess) throw new Error('Called value on error Result')
return this._value!
}
get error(): E {
if (this.isSuccess) throw new Error('Called error on success Result')
return this._error!
}
map<U>(fn: (value: T) => U): Result<U, E> {
if (!this.isSuccess) return Result.err(this.error)
return Result.ok(fn(this.value))
}
unwrapOr(defaultValue: T): T {
return this.isSuccess ? this.value : defaultValue
}
}
// src/hooks.ts
import type { Transport } from '@sveltejs/kit'
import { Result } from '$lib/types/Result'
export const transport: Transport = {
Result: {
encode: (value) =>
value instanceof Result && {
value: value.isSuccess ? value.value : undefined,
error: value.isSuccess ? undefined : value.error,
isSuccess: value.isSuccess
},
decode: (data) => (data.isSuccess ? Result.ok(data.value) : Result.err(data.error))
}
}
// routes/api/users/+server.ts
import { Result } from '$lib/types/Result'
export async function GET() {
try {
const users = await db.users.findMany()
return json(Result.ok(users))
} catch (error) {
return json(Result.err(error.message))
}
} 5. Color Objects
For design systems, theming, or graphics applications. Colors are deceptively complex—they can be represented in multiple formats (hex, RGB, HSL, RGBA), need validation to ensure values are in valid ranges, and often require transformations like lightening, darkening, or adjusting opacity.
The String Soup Problem:
// ❌ Without a Color class - fragile and error-prone
const primary = '#3b82f6'
const lightPrimary = lightenHex(primary, 20) // Custom function needed
const primaryRgba = hexToRgba(primary, 0.5) // Another function
const isAccessible = checkContrast(primary, '#ffffff') // More utilities
// What if someone passes "rgb(59, 130, 246)"?
// What if values go out of range?
// How do you test all these utilities? Why a Color Class Matters: When you’re juggling hex strings, RGB tuples, and opacity values across your codebase, it’s easy to introduce bugs. Is #fff the same as #ffffff? Should rgb(300, 50, 50) be clamped or throw an error? A Color class answers these questions once, consistently.
Key Benefits:
- Format conversion: Seamlessly convert between hex, RGB, RGBA, HSL—no memorization of conversion formulas
- Automatic validation: RGB values automatically clamped to 0-255, alpha to 0-1, preventing invalid colors
- Chainable transformations:
color.lighten(20).withAlpha(0.5)reads like English - Immutability: Color operations return new instances, preventing accidental mutations in shared theme objects
- Type safety: TypeScript ensures you’re always working with valid Color objects, not random strings
- Consistency: Same color logic on server (SSR theme generation) and client (interactive color pickers)
When to Use This Pattern:
- Design systems - Maintaining consistent color palettes, generating tints/shades, brand color management
- Theming engines - Dynamic theme switching, dark mode, user-customizable themes
- Data visualization - Programmatic color scales (red→yellow→green), category colors, heat maps
- Graphics applications - Canvas rendering, image processing, CSS filter generation
- Accessibility tools - WCAG contrast checking, color blindness simulation, readable text colors
- CSS-in-JS systems - Runtime color manipulation for styled components
Real-World Example: Imagine building a design system where you define a primary color, and the system automatically generates:
- 10 shades from light to dark
- Hover and active states (slightly darker)
- Disabled state (reduced opacity)
- Text color that meets WCAG contrast requirements
- Focus ring color (primary with adjusted alpha)
With a Color class and Transport, you define these rules once on the server, generate the complete palette during SSR, and have all the same color manipulation methods available when users interact with color pickers or theme switchers in the browser.
// lib/types/Color.ts
export class Color {
constructor(
public r: number,
public g: number,
public b: number,
public a: number = 1
) {
this.r = Math.max(0, Math.min(255, r))
this.g = Math.max(0, Math.min(255, g))
this.b = Math.max(0, Math.min(255, b))
this.a = Math.max(0, Math.min(1, a))
}
static fromHex(hex: string) {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return new Color(r, g, b)
}
toHex() {
const toHex = (n: number) => n.toString(16).padStart(2, '0')
return `#${toHex(this.r)}${toHex(this.g)}${toHex(this.b)}`
}
toRgba() {
return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})`
}
lighten(amount: number) {
return new Color(this.r + amount, this.g + amount, this.b + amount, this.a)
}
withAlpha(alpha: number) {
return new Color(this.r, this.g, this.b, alpha)
}
}
// src/hooks.ts
import type { Transport } from '@sveltejs/kit'
import { Color } from '$lib/types/Color'
export const transport: Transport = {
Color: {
encode: (value) => value instanceof Color && [value.r, value.g, value.b, value.a],
decode: ([r, g, b, a]) => new Color(r, g, b, a)
}
}
// routes/theme/+page.server.ts
export async function load() {
return {
theme: {
primary: new Color(59, 130, 246),
secondary: new Color(139, 92, 246),
background: new Color(255, 255, 255),
text: new Color(17, 24, 39)
}
}
} 6. Performance Optimization: Deduplication
For large datasets with repeated objects, you can optimize serialization to dramatically reduce payload size and memory usage. This is a more advanced use case that addresses a specific performance problem: when you have many objects that share references to the same data, naive serialization will duplicate that data, leading to bloated payloads, slow page loads, and potentially hitting memory limits in serverless environments.
The Problem: Data Duplication at Scale
// ❌ Without optimization - wasteful serialization
const users = [
{ id: 1, name: 'Alice', org: { id: 'org1', name: 'Acme Corp', plan: 'Enterprise' } },
{ id: 2, name: 'Bob', org: { id: 'org1', name: 'Acme Corp', plan: 'Enterprise' } },
{ id: 3, name: 'Carol', org: { id: 'org1', name: 'Acme Corp', plan: 'Enterprise' } }
// ... 997 more users from 'org1'
]
// The organization data is serialized 1,000 times! Real-World Impact: Imagine building a project management tool that loads 1,000 users, where each user belongs to one of 10 organizations. Without optimization, you’d serialize each organization object roughly 100 times (once per user who belongs to it). If each organization object is 500 bytes, that’s 50KB of redundant data in your HTML payload. With more complex nested relationships (Organizations → Teams → Users → Projects → Tasks), this problem compounds exponentially.
Real Example: Cole Crouter’s team hit Cloudflare Pages’ 128MB memory limit when serializing a large dataset. By implementing Transport-based deduplication, they reduced serialization overhead and got 2-3x faster page load times. Read his excellent blog post.
How the Deduplication Pattern Works:
- Analyze relationships: Identify objects that are shared (like Organization or Category)
- Group data: Organize related objects together (group users by organization ID)
- Encode efficiently: Serialize shared objects once, store references by ID
- Reconstruct relationships: Rebuild the object graph on the client using ID lookups
When This Optimization Matters:
- Large datasets: Admin dashboards loading thousands of records (users, products, transactions)
- Hierarchical data: Organizations → Departments → Teams → Users → Tasks
- Normalized data: Database-style relationships transported to the browser
- Memory constraints: Serverless environments (Cloudflare 128MB, Vercel 250MB limits)
- Network optimization: Mobile users on slow connections need smaller payloads
- Real-time apps: Frequent data refreshes make payload size critical
The Trade-off: This technique adds complexity to your Transport code. Only use it when you’ve profiled and identified that data duplication is actually a bottleneck. For most applications, the standard Transport pattern is sufficient. But when you hit memory limits or slow page loads due to large datasets, this pattern can be a lifesaver.
The key insight: while devalue handles deduplication for plain objects automatically, custom transporters give you complete control to implement domain-specific optimizations for your data structures.
// lib/models/App.ts
export class App {
constructor(
public id: string,
public name: string
) {}
}
export class User {
constructor(
public id: string,
public name: string,
public app: App
) {}
}
// src/hooks.ts
import type { Transport } from '@sveltejs/kit'
import { App, User } from '$lib/models'
export const transport: Transport = {
App: {
encode: (value) =>
value instanceof App && {
id: value.id,
name: value.name
},
decode: (data) => new App(data.id, data.name)
},
// Optimize: serialize User[] by grouping by app
'User[]': {
encode: (value) => {
if (!Array.isArray(value) || !value.every((u) => u instanceof User)) {
return false
}
// Group users by app
const byApp = Object.groupBy(value, (user) => user.app.id)
return {
apps: Object.values(byApp).map((users) => users![0].app),
usersByApp: Object.entries(byApp).map(([appId, users]) => ({
appId,
users: users!.map((u) => ({ id: u.id, name: u.name }))
}))
}
},
decode: (data) => {
const appsById = new Map(data.apps.map((app: any) => [app.id, new App(app.id, app.name)]))
return data.usersByApp.flatMap((group: any) =>
group.users.map((u: any) => new User(u.id, u.name, appsById.get(group.appId)!))
)
}
}
} 7. Date Range Objects
Common in analytics dashboards and reporting tools. Date ranges appear everywhere in business applications—filtering data, scheduling events, defining billing periods, setting availability windows, comparing time periods. While you could represent these as two separate Date objects or timestamp pairs, a DateRange class provides semantic meaning, type safety, and useful operations that prevent bugs.
The Two-Date Problem:
// ❌ Without DateRange - error-prone and verbose
function getMetrics(startDate: Date, endDate: Date) {
// Did the caller swap the dates?
// Are they in the same timezone?
// Is the range inclusive or exclusive?
if (startDate > endDate) {
throw new Error('Invalid date range')
}
// Duplicate this validation everywhere...
}
// Using it is confusing:
getMetrics(endDate, startDate) // Oops! Wrong order Why DateRange Matters: Date handling is notoriously difficult—timezone issues, leap years, daylight saving time, inclusive vs exclusive ranges. By encapsulating this complexity in a class, you handle all edge cases once and gain confidence that your date logic is correct everywhere.
Key Benefits:
- Validation: Ensure start date is before end date at construction time—fail fast with clear errors
- Convenience factories: Create common ranges with readable code:
DateRange.lastNDays(30),DateRange.thisMonth() - Calculations: Compute duration in days/hours, check if a date falls within range, split ranges
- Comparisons: Check if ranges overlap (for scheduling conflicts), are adjacent, or contain each other
- Immutability: Ranges can’t be accidentally modified after creation, preventing subtle bugs
- Business logic: Handle edge cases like timezone normalization, inclusive/exclusive bounds, weekend exclusion
- Readability:
campaign.period.contains(today)vscampaign.start <= today && today <= campaign.end
Common Use Cases:
- Analytics dashboards - Date range pickers (“Last 7 days”, “This quarter”), metric filtering, comparative analysis (“This week vs last week”), chart time scales
- Booking systems - Hotel reservations with check-in/check-out, meeting room scheduling, equipment rental periods, detecting double-bookings
- Reporting tools - Financial periods (quarters, fiscal years), performance reviews, compliance audit windows, custom date ranges
- Scheduling applications - Employee availability calendars, shift management, vacation tracking, blackout periods
- SaaS billing - Subscription periods, trial windows, usage-based billing cycles, proration calculations
- Campaign management - Promotional periods, A/B test durations, seasonal offerings
Real-World Example: Imagine building an analytics dashboard where users can select “Last 7 days”, “Last 30 days”, or a custom range. With DateRange:
// Server calculates the range based on user selection
const range =
url.searchParams.get('period') === '7' ? DateRange.lastNDays(7) : DateRange.lastNDays(30)
// Query database with the range
const metrics = await db.getMetrics(range.start, range.end)
// Client receives the DateRange object and can:
// - Display it: "Showing data for Dec 20 - Dec 27"
// - Filter CSV exports based on the same range
// - Check if user's selected date is in range for highlighting
// - Calculate "days remaining" for trial periods When you load analytics data on the server, the DateRange object can be passed to the client where it continues to work for client-side filtering, display logic, and date calculations. This keeps your date handling logic consistent across environments and eliminates the “it works on server but breaks in browser” timezone bugs.
// lib/types/DateRange.ts
export class DateRange {
constructor(
public start: Date,
public end: Date
) {
if (start > end) {
throw new Error('Start date must be before end date')
}
}
static today() {
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date()
end.setHours(23, 59, 59, 999)
return new DateRange(start, end)
}
static lastNDays(n: number) {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - n)
return new DateRange(start, end)
}
get days() {
const diff = this.end.getTime() - this.start.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
contains(date: Date) {
return date >= this.start && date <= this.end
}
overlaps(other: DateRange) {
return this.start <= other.end && other.start <= this.end
}
}
// src/hooks.ts
import type { Transport } from '@sveltejs/kit'
import { DateRange } from '$lib/types/DateRange'
export const transport: Transport = {
DateRange: {
encode: (value) => value instanceof DateRange && [value.start, value.end],
decode: ([start, end]) => new DateRange(start, end)
}
}
// routes/analytics/+page.server.ts
export async function load({ url }) {
const period = url.searchParams.get('period') || '30'
const range = DateRange.lastNDays(parseInt(period))
const metrics = await db.getMetrics(range.start, range.end)
return { range, metrics }
} Advanced Patterns
Nested Custom Types
Transport automatically handles nesting, so custom types can contain other custom types.
// lib/models/Order.ts
export class OrderItem {
constructor(
public productId: string,
public quantity: number,
public price: Money
) {}
get total() {
return this.price.multiply(this.quantity)
}
}
export class Order {
constructor(
public id: string,
public items: OrderItem[],
public createdAt: Date
) {}
get total() {
return this.items.reduce((sum, item) => sum.add(item.total), new Money(0))
}
}
// src/hooks.ts
export const transport: Transport = {
Money: {
/* ... */
},
OrderItem: {
encode: (value) =>
value instanceof OrderItem && {
productId: value.productId,
quantity: value.quantity,
price: value.price // Money is automatically encoded
},
decode: (data) => new OrderItem(data.productId, data.quantity, data.price)
},
Order: {
encode: (value) =>
value instanceof Order && {
id: value.id,
items: value.items, // OrderItem[] is automatically encoded
createdAt: value.createdAt
},
decode: (data) => new Order(data.id, data.items, data.createdAt)
}
} Using with Remote Functions
Transport works seamlessly with SvelteKit’s remote functions (query, command, form).
// lib/server/products.remote.ts
import { query } from '$app/server';
import { Product } from '$lib/models/Product';
export const getProduct = query(
v.string(),
async (id) => {
const data = await db.products.findUnique({ where: { id } });
return new Product(data.id, data.name, new Money(data.price));
}
);
// routes/products/[id]/+page.svelte
<script lang="ts">
import { getProduct } from '$lib/server/products.remote';
let { data } = $props();
// Product is automatically decoded with all methods available
const product = getProduct(data.id);
</script>
{#if product.ready}
<h1>{product.current.name}</h1>
<p>{product.current.price.format()}</p>
{/if} Best Practices
1. Keep Encoded Data Minimal
Only encode the data needed to reconstruct the object. Don’t encode computed properties.
// ✅ Good - only encode data
encode: (value) =>
value instanceof User && {
id: value.id,
name: value.name
}
// ❌ Bad - encoding computed values
encode: (value) =>
value instanceof User && {
id: value.id,
name: value.name,
initials: value.getInitials() // Don't do this!
} 2. Use Type Guards in Encode
Always check if the value is an instance of your type before encoding.
encode: (value) => {
if (!(value instanceof MyType)) return false
return {
// ... encoding logic
}
} 3. Handle Edge Cases
Consider null, undefined, and validation in your decode function.
decode: (data) => {
// Validate the data structure
if (!data || typeof data.id !== 'number') {
throw new Error('Invalid User data')
}
return new User(data.id, data.name)
} 4. Don’t Encode Falsy Values
The encode function uses falsy values as a signal that the value is not an instance. Be careful with 0, "", etc.
// ❌ Bad - 0 is falsy!
encode: (value) => value instanceof Counter && value.count
// ✅ Good - wrap in object/array
encode: (value) => value instanceof Counter && [value.count] Common Pitfalls
1. Forgetting instanceof Check
// ❌ Bad - will encode everything!
encode: (value) => ({ data: value })
// ✅ Good - check type first
encode: (value) => value instanceof MyType && { data: value.data } 2. Encoding Non-Serializable Data
// ❌ Bad - Functions can't be serialized
export class Component {
onClick = () => console.log('clicked')
}
encode: (value) =>
value instanceof Component && {
onClick: value.onClick // Won't work!
} 3. Circular References in Custom Types
While devalue handles circular references in plain objects, be careful with custom types.
export class Node {
parent?: Node
children: Node[] = []
}
// This could cause issues - be explicit
encode: (value) =>
value instanceof Node && {
children: value.children
// Don't encode parent to avoid circularity
} Testing Your Transporters
// lib/types/Money.test.ts
import { describe, it, expect } from 'vitest'
import { Money } from './Money'
import { transport } from '../hooks'
describe('Money transporter', () => {
it('should encode and decode correctly', () => {
const original = new Money(99.99, 'USD')
const encoded = transport.Money.encode(original)
expect(encoded).toEqual({
amount: 99.99,
currency: 'USD'
})
const decoded = transport.Money.decode(encoded)
expect(decoded).toBeInstanceOf(Money)
expect(decoded.amount).toBe(99.99)
expect(decoded.currency).toBe('USD')
expect(decoded.format()).toBe('$99.99')
})
it('should return false for non-Money values', () => {
expect(transport.Money.encode({})).toBe(false)
expect(transport.Money.encode(null)).toBe(false)
expect(transport.Money.encode(42)).toBe(false)
})
}) Conclusion
The Transport hook is a powerful feature that enables you to maintain rich domain models across the server/client boundary in SvelteKit. By defining custom serialization logic, you can:
- Keep your business logic encapsulated in classes
- Maintain type safety across the full stack
- Optimize data transfer for large datasets
- Build more maintainable and testable applications
The key is to think of Transport as a bridge between your server-side domain models and client-side needs, allowing you to write cleaner, more object-oriented code while SvelteKit handles the serialization complexity for you.