JSON in SvelteKit: load Functions, +server.ts, and Type Safety
Last updated:
SvelteKit returns JSON from three distinct entry points: load functions in +page.server.ts (SSR data loading), +server.ts API endpoints (REST-style routes), and form actions in +page.server.ts. Each has different serialization rules. The load function returns a plain JavaScript object — SvelteKit serializes it automatically using devalue, which handles Date, Map, Set, BigInt, and circular references beyond what JSON.stringify supports. +server.ts endpoints return a Response with json() from @sveltejs/kit, which calls JSON.stringify and sets the Content-Type: application/json header. Form actions return data accessible via form in the component. TypeScript types flow from load return values to page components via generated PageData types — no manual type casting needed when you define the return type in the load function. This guide covers load function JSON patterns, +server.ts endpoint construction, Zod validation, streaming with deferred responses, and client-side fetch usage.
Returning JSON from load Functions
Bottom line: the load function in +page.server.ts returns a plain object — SvelteKit serializes it with devalue and makes it available as data in the page component. Define the return type explicitly so that the generated PageData type flows to your component with zero manual casting.
SvelteKit's type generation runs during svelte-check or on every HMR update in development. The generated file is at .svelte-kit/types/src/routes/$types.d.ts and exports PageServerLoad and PageData for each route. When your load function returns { products: Product[], total: number }, the component's data prop is typed as { products: Product[]; total: number } — no generics or casts needed. The data is serialized once on the server and hydrated on the client; the client never makes a second network request for the same load data. Use error() from @sveltejs/kit to throw an HTTP error response that SvelteKit renders as its error page.
// src/routes/products/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
import { db } from '$lib/server/db'
interface Product {
id: number
name: string
price: number
inStock: boolean
}
// Explicit return type → generates PageData in .svelte-kit/types
export const load: PageServerLoad = async ({ fetch, params, url }) => {
const category = url.searchParams.get('category') ?? 'all'
// SvelteKit-enhanced fetch: relative URLs work, credentials forwarded
const products = await db.getProducts({ category }) as Product[]
if (!products) {
// SvelteKit renders the nearest +error.svelte with status 500
error(500, 'Failed to load products')
}
// Returns plain object — SvelteKit serializes with devalue
// devalue handles Date, Map, Set, BigInt — not just JSON-safe values
return {
products,
total: products.length,
category,
fetchedAt: new Date(), // devalue preserves this as a real Date, not a string
}
}<!-- src/routes/products/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types'
// PageData is auto-generated — products: Product[], total: number, etc.
export let data: PageData
// data.fetchedAt is a real Date object (not a string) thanks to devalue
const formatted = data.fetchedAt.toLocaleDateString()
</script>
<h1>Products ({data.total})</h1>
<p>Fetched: {formatted}</p>
{#each data.products as product (product.id)}
<div>
<span>{product.name}</span>
<span>${product.price}</span>
{#if !product.inStock}<span>Out of stock</span>{/if}
</div>
{/each}JSON API Endpoints with +server.ts
Bottom line: +server.ts files create REST-style API routes. Export a named GET, POST, PUT, or DELETE function. Use json() from @sveltejs/kit to return JSON responses and await request.json() to parse incoming request bodies.
The json() helper is equivalent to new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }) — it handles the header and serialization in a single call. For error responses, useerror(404, 'Not found') or error(400, { message: 'Invalid input' }). Pass an options object as the second argument to json() to set additional headers, such as Cache-Control or Access-Control-Allow-Origin for public API endpoints that accept cross-origin requests. Type your handler with RequestHandler from ./$types for a correctly typed RequestEvent parameter.
// src/routes/api/products/+server.ts
import { json, error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { db } from '$lib/server/db'
import { z } from 'zod'
interface Product {
id: number
name: string
price: number
}
// GET /api/products
// json() = JSON.stringify + Content-Type: application/json header
export const GET: RequestHandler = async ({ url }) => {
const limit = Number(url.searchParams.get('limit') ?? '20')
const products = await db.getProducts({ limit }) as Product[]
// json() sets the header and serializes — no manual JSON.stringify needed
return json({ products, total: products.length })
}
// POST /api/products
// await request.json() parses the incoming JSON body
const CreateProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
})
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json()
const result = CreateProductSchema.safeParse(body)
if (!result.success) {
// error(400, ...) sends a JSON error response with status 400
error(400, { message: 'Invalid input', fields: result.error.flatten().fieldErrors })
}
const product = await db.createProduct(result.data)
// Status 201 Created: pass as the second arg to json()
return json(product, { status: 201 })
}
// CORS headers for a public API endpoint
export const OPTIONS: RequestHandler = async () => {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
}// src/routes/api/products/[id]/+server.ts — dynamic route
import { json, error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { db } from '$lib/server/db'
export const GET: RequestHandler = async ({ params }) => {
const id = Number(params.id)
if (isNaN(id)) error(400, 'id must be a number')
const product = await db.getProduct(id)
if (!product) error(404, 'Product not found')
return json(product)
}
export const DELETE: RequestHandler = async ({ params }) => {
const id = Number(params.id)
await db.deleteProduct(id)
return json({ deleted: true })
}TypeScript Type Safety End-to-End
Bottom line: SvelteKit generates PageData and PageServerLoad types from the return type of your load function. Define the return type explicitly — the component's data prop is then fully typed with no manual casting. For +server.ts, use the generated RequestHandler type for typed RequestEvent.
The type flow works in 3 steps: (1) You annotate export const load: PageServerLoad = async () => { return { user, posts } }, (2) SvelteKit's type generator creates PageData { user: User; posts: Post[] } in .svelte-kit/types, (3) Your component imports import type { PageData } from './$types' and declares export let data: PageData. In SvelteKit 2.x, the satisfies keyword on the return value validates the shape inline while preserving precise inference. For server-only data fetching, put database calls in $lib/server/db.ts — SvelteKit prevents importing $lib/server modules in client-side code at build time. Form action data types require manual typing with ActionData from ./$types.
// $lib/server/db.ts — server-only module (cannot be imported in .svelte files directly)
// SvelteKit enforces this: importing $lib/server/* from client code is a build error
import { DB_URL } from '$env/static/private' // server-only env var
export async function getUser(id: number): Promise<User | null> {
// ...database query
}
export async function getPosts(userId: number): Promise<Post[]> {
// ...database query
}// src/routes/dashboard/+page.server.ts
import type { PageServerLoad, Actions } from './$types'
import { error, fail } from '@sveltejs/kit'
import { getUser, getPosts } from '$lib/server/db'
interface User { id: number; name: string; email: string }
interface Post { id: number; title: string; body: string }
// SvelteKit 2.x: explicit return type gives typed PageData
export const load: PageServerLoad = async ({ locals, params }) => {
const userId = locals.user?.id
if (!userId) error(401, 'Unauthorized')
// Parallel fetches — Promise.all for data with similar latency
const [user, posts] = await Promise.all([
getUser(userId),
getPosts(userId),
])
if (!user) error(404, 'User not found')
// satisfies validates shape while keeping precise inference (SvelteKit 2.x)
return {
user, // type: User
posts, // type: Post[]
postCount: posts.length, // type: number
} satisfies { user: User; posts: Post[]; postCount: number }
}
// Form action — ActionData type is manually typed
export const actions: Actions = {
updateProfile: async ({ request, locals }) => {
const formData = await request.formData()
const name = formData.get('name')?.toString()
if (!name || name.length < 2) {
// fail() returns ActionData — accessible as `form` in the component
return fail(400, { name, error: 'Name must be at least 2 characters' })
}
await updateUserName(locals.user!.id, name)
return { success: true }
},
}<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
import type { PageData, ActionData } from './$types'
// data: PageData — fully typed, user: User, posts: Post[], postCount: number
export let data: PageData
// form: ActionData — typed from the actions return values
export let form: ActionData
</script>
<h1>Welcome, {data.user.name}</h1>
<p>{data.postCount} posts</p>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form method="POST" action="?/updateProfile">
<input name="name" value={data.user.name} />
<button type="submit">Save</button>
</form>Zod Validation for Incoming JSON
Bottom line: in a +server.ts POST handler, parse the body with await request.json() and validate with Zod's safeParse. Return error(400, result.error.flatten()) on failure. Use z.infer<typeof schema> to derive the TypeScript type so the validated data is fully typed downstream.
safeParse never throws — it returns { success: true, data: T } or { success: false, error: ZodError }. The error.flatten() call produces { fieldErrors: Record<string, string[]>, formErrors: string[] } — already JSON-serializable, ready to pass to error(400, ...). For Zod with form actions in +page.server.ts, use fail() instead of error(): fail() returns ActionData while error() triggers SvelteKit's error page. The zod-form-data library provides helpers for parsing FormData directly with Zod schemas. The sveltekit-superforms library (superforms) goes further — it handles both client and server validation with a single Zod schema, progressive enhancement, and typed form actions.
// src/routes/api/users/+server.ts
import { json, error } from '@sveltejs/kit'
import { z } from 'zod'
import type { RequestHandler } from './$types'
import { db } from '$lib/server/db'
// 1. Define the Zod schema
const CreateUserSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
metadata: z.record(z.string()).optional(),
})
// 2. Derive the TypeScript type from the schema — single source of truth
type CreateUserInput = z.infer<typeof CreateUserSchema>
// { name: string; email: string; role: 'admin' | 'editor' | 'viewer'; metadata?: Record<string, string> }
export const POST: RequestHandler = async ({ request }) => {
// 3. Parse raw body — request.json() throws on malformed JSON
let raw: unknown
try {
raw = await request.json()
} catch {
error(400, 'Request body must be valid JSON')
}
// 4. Validate with safeParse — never throws
const result = CreateUserSchema.safeParse(raw)
if (!result.success) {
// flatten() → { fieldErrors: { name: ['...'], email: ['...'] }, formErrors: [] }
error(400, result.error.flatten())
}
// 5. result.data is typed as CreateUserInput
const input: CreateUserInput = result.data
const user = await db.createUser(input)
return json(user, { status: 201 })
}// src/routes/profile/+page.server.ts — Zod with form actions
import { fail } from '@sveltejs/kit'
import { z } from 'zod'
import type { Actions } from './$types'
const UpdateProfileSchema = z.object({
displayName: z.string().min(2).max(50),
bio: z.string().max(500).optional(),
})
export const actions: Actions = {
update: async ({ request, locals }) => {
const formData = await request.formData()
// Convert FormData to a plain object before Zod parsing
const raw = Object.fromEntries(formData)
const result = UpdateProfileSchema.safeParse(raw)
if (!result.success) {
// fail() returns ActionData — NOT an error page
return fail(422, {
errors: result.error.flatten().fieldErrors,
values: raw, // return submitted values to repopulate the form
})
}
await updateProfile(locals.user!.id, result.data)
return { success: true }
},
}Streaming and Deferred JSON Responses
Bottom line: SvelteKit 2.x supports deferred() for streaming slow data after the initial HTML. Return a deferred promise from load and use `{#await data.slow}` in the component to show a loading state while the slow data arrives.
The streaming wire protocol works as follows: the server sends the initial HTML including all non-deferred data (serialized via devalue), then keeps the HTTP connection open. Each resolved deferred promise is sent as an additional JSON chunk embedded in a <script> tag that the SvelteKit runtime reads and injects into the component tree. This gives users visible content in under 200ms for fast data even when slow data takes 2+ seconds. Compare to Promise.all(): withPromise.all(), the page waits for all fetches to resolve before sending HTML — the total wait is max(200ms, 2000ms) = 2000ms. With deferred(), users see the fast data in ~200ms and the slow data fills in at ~2000ms. Streaming requires the server to support HTTP chunked transfer encoding — it works on Node.js adapters and most edge runtimes, but check your deployment target. Use AbortSignal from the RequestEvent to cancel slow fetches on navigation.
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { deferred } from '@sveltejs/kit'
import { db } from '$lib/server/db'
export const load: PageServerLoad = async ({ fetch, depends }) => {
// Fast: resolves in ~50ms — included in initial HTML
const userPromise = db.getCurrentUser()
// Medium: ~200ms — also included in initial HTML (no deferred needed)
const recentPostsPromise = db.getRecentPosts({ limit: 5 })
// Slow: analytics query takes ~2s — stream it after initial HTML
// deferred() wraps the promise; SvelteKit streams it as a JSON chunk
const analyticsPromise = deferred(db.getAnalyticsSummary())
// Promise.all for fast+medium — they finish before HTML is sent
const [user, recentPosts] = await Promise.all([userPromise, recentPostsPromise])
return {
user, // available immediately: ~50ms
recentPosts, // available immediately: ~200ms
analytics: analyticsPromise, // streams in ~2s after initial HTML
}
}<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types'
export let data: PageData
</script>
<!-- Fast data renders immediately — no waiting -->
<h1>Welcome, {data.user.name}</h1>
<section>
<h2>Recent Posts</h2>
{#each data.recentPosts as post (post.id)}
<article>{post.title}</article>
{/each}
</section>
<!-- Deferred data: show a skeleton while streaming -->
<section>
<h2>Analytics</h2>
{#await data.analytics}
<!-- Loading state — shown for ~2s while stream is in flight -->
<div class="skeleton">Loading analytics...</div>
{:then analytics}
<!-- Rendered once the streamed JSON chunk arrives -->
<dl>
<dt>Page views</dt><dd>{analytics.pageViews}</dd>
<dt>Unique visitors</dt><dd>{analytics.uniqueVisitors}</dd>
</dl>
{:catch error}
<p>Analytics unavailable: {error.message}</p>
{/await}
</section>Client-Side JSON Fetching in SvelteKit
Bottom line: use the native fetch API in onMount or event handlers for client-side JSON requests to +server.ts endpoints. Use invalidate() from $app/navigation to refresh load data without a full page reload. The SvelteKit-enhanced fetch inside load functions supports relative URLs and forwards credentials automatically.
There are 2 distinct fetch contexts in SvelteKit. The fetch injected into load functions is enhanced: it resolves relative URLs against the current origin, forwards cookies for same-origin requests, and can be mocked in tests. In +page.svelte components, use the standard global fetch for client-side interactions — buttons, forms, real-time polling. After a mutation (POST, PUT, DELETE), call invalidate('/api/products') to tell SvelteKit to re-run any load functions that depend on that URL, refreshing data reactively. For server-sent events, use EventSource pointing to a +server.ts that returns a ReadableStream withtext/event-stream content type — each event can carry a JSON payload parsed with JSON.parse(event.data).
<!-- src/routes/products/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte'
import { invalidate } from '$app/navigation'
import type { PageData } from './$types'
export let data: PageData // SSR load data — hydrated, no extra fetch needed
// Client-side state for interactions
let searchResults: Product[] = []
let searching = false
// Client-side fetch — called on user interaction
async function searchProducts(query: string) {
searching = true
try {
const res = await fetch(`/api/products/search?q=${encodeURIComponent(query)}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
searchResults = await res.json()
} catch (err) {
console.error('Search failed:', err)
} finally {
searching = false
}
}
// After a POST/DELETE, invalidate() re-runs load() and updates data
async function deleteProduct(id: number) {
await fetch(`/api/products/${id}`, { method: 'DELETE' })
// Re-runs the load function — data.products refreshes reactively
await invalidate('/api/products')
}
// SWR-style polling: refresh every 30s
onMount(() => {
const timer = setInterval(() => invalidate('/api/products'), 30_000)
return () => clearInterval(timer)
})
</script>
<input
type="search"
on:input={(e) => searchProducts(e.currentTarget.value)}
placeholder="Search products..."
/>
{#if searching}
<p>Searching...</p>
{:else if searchResults.length > 0}
{#each searchResults as p (p.id)}
<div>{p.name} — ${p.price}</div>
{/each}
{:else}
{#each data.products as p (p.id)}
<div>
{p.name}
<button on:click={() => deleteProduct(p.id)}>Delete</button>
</div>
{/each}
{/if}// src/routes/api/events/+server.ts — server-sent JSON events
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ request }) => {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
// Send a JSON event every 5s
const interval = setInterval(() => {
const payload = JSON.stringify({ time: new Date().toISOString(), users: 42 })
controller.enqueue(encoder.encode(`data: ${payload}
`))
}, 5000)
// Clean up when the client disconnects
request.signal.addEventListener('abort', () => {
clearInterval(interval)
controller.close()
})
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}<!-- EventSource client — in a +page.svelte component -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
interface LiveData { time: string; users: number }
let liveData: LiveData | null = null
let source: EventSource
onMount(() => {
source = new EventSource('/api/events')
source.onmessage = (event) => {
liveData = JSON.parse(event.data) as LiveData
}
})
onDestroy(() => source?.close())
</script>
{#if liveData}
<p>Live users: {liveData.users} at {liveData.time}</p>
{/if}FAQ
How do I return JSON from a SvelteKit load function?
Return a plain object from +page.server.ts. SvelteKit serializes it using devalue, which handles Date, Map, Set, and BigInt beyond what JSON.stringify supports. Annotate the load function with export const load: PageServerLoad and the return type flows to PageData in the component automatically — no casting needed. Access it as export let data: PageData in +page.svelte.
How do I create a JSON API endpoint in SvelteKit?
Create a +server.ts file in your routes directory and export named functions for each HTTP method. Use json(data) from @sveltejs/kit to return a JSON response — it sets the Content-Type: application/json header and calls JSON.stringify in one step. For incoming JSON, use await request.json(). Return error responses with error(404, 'Not found') from @sveltejs/kit. Type your handlers with RequestHandler from ./$types.
How does SvelteKit serialize data from load functions?
SvelteKit uses devalue — not JSON.stringify — to serialize load function return values. devalue handles types that JSON cannot: Date (deserialized back as a real Date, not a string), Map, Set, BigInt, undefined, NaN, Infinity, and circular references. This means returning a Date from load gives you an actual Date object in the component — not a string that needs new Date(str). In contrast, +server.ts endpoints use json() which calls standard JSON.stringify, so Date values become ISO strings.
How do I add TypeScript types to SvelteKit load data?
Import PageServerLoad from './$types' (generated by SvelteKit) and annotate your load function: export const load: PageServerLoad = async () => { ... }. SvelteKit generates a PageData type that mirrors the return type. In the component, declare export let data: PageData — TypeScript infers all fields automatically. No manual casting or generics needed. For +server.ts, import RequestHandler from './$types' for typed RequestEvent parameters.
How do I validate JSON in a SvelteKit +server.ts endpoint?
Parse the body with const body = await request.json(), then validate with Zod: const result = schema.safeParse(body). On failure, return error(400, result.error.flatten()) — flatten() produces a JSON-serializable object with fieldErrors and formErrors. Use z.infer<typeof schema> to derive the TypeScript type so result.data is correctly typed after a successful parse. For form actions, use fail(400, { ... }) instead of error() — fail() returns ActionData without triggering the error page.
How do I stream JSON data in SvelteKit?
Use deferred() from @sveltejs/kit in your load function: return { fast: fastQuery(), slow: deferred(slowQuery()) }. The initial HTML includes the fast data; the deferred data streams as additional JSON chunks once the promise resolves. In the component, use `{#await data.slow}` to show a loading state. Streaming is most beneficial when one data source is significantly slower than others — e.g., 200ms vs 2s. For data with similar latency, Promise.all() is simpler. Streaming requires HTTP chunked transfer encoding support — works on Node.js adapters and most edge runtimes.
Key Terms
load function- An exported async function in
+page.server.tsor+page.tsthat fetches data before the page renders. Its return value becomes thedataprop in the page component, typed asPageData. +server.ts- A SvelteKit route file that creates REST-style API endpoints. Exports named HTTP method functions (
GET,POST, etc.) that returnResponseobjects. devalue- The serialization library SvelteKit uses for load function data. Extends JSON with support for
Date,Map,Set,BigInt,undefined,NaN, and circular references. PageData- A TypeScript type auto-generated by SvelteKit's type system from the return type of the route's
loadfunction. Imported from'./$types'in the component. deferred()- A SvelteKit 2.x utility that wraps a slow promise for streaming. The initial HTML is sent without waiting for the deferred data; the resolved value streams as a JSON chunk and fills in the
`{#await}`block in the component. RequestHandler- The TypeScript type for a
+server.tsHTTP method handler. Imported from'./$types', it types theRequestEventparameter includingrequest,params,url, andlocals.
Validate your SvelteKit JSON responses
Paste a JSON object from a SvelteKit load function or +server.ts endpoint into Jsonic to validate the structure, spot missing fields, and format nested data.
Further reading and primary sources
- SvelteKit load functions — Official SvelteKit documentation for load functions: PageServerLoad, LayoutServerLoad, fetch enhancement, error(), and deferred() for streaming
- SvelteKit +server.ts routes — Official SvelteKit reference for +server.ts files: HTTP method exports, json(), error(), RequestHandler type, and dynamic route segments
- SvelteKit TypeScript types — Official SvelteKit reference for generated types: PageData, PageServerLoad, RequestHandler, ActionData, and the ./$types import convention
- devalue — The serialization library used by SvelteKit for load function data — supports Date, Map, Set, BigInt, circular references, and other types JSON.stringify cannot handle
- SvelteKit form actions — Official SvelteKit guide to form actions in +page.server.ts: fail(), ActionData, progressive enhancement, and server-side validation patterns