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.ts or +page.ts that fetches data before the page renders. Its return value becomes the data prop in the page component, typed as PageData.
+server.ts
A SvelteKit route file that creates REST-style API endpoints. Exports named HTTP method functions (GET, POST, etc.) that return Response objects.
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 load function. 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.ts HTTP method handler. Imported from './$types', it types the RequestEvent parameter including request, params, url, and locals.

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.

Open JSON Validator

Further reading and primary sources

  • SvelteKit load functionsOfficial SvelteKit documentation for load functions: PageServerLoad, LayoutServerLoad, fetch enhancement, error(), and deferred() for streaming
  • SvelteKit +server.ts routesOfficial SvelteKit reference for +server.ts files: HTTP method exports, json(), error(), RequestHandler type, and dynamic route segments
  • SvelteKit TypeScript typesOfficial SvelteKit reference for generated types: PageData, PageServerLoad, RequestHandler, ActionData, and the ./$types import convention
  • devalueThe 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 actionsOfficial SvelteKit guide to form actions in +page.server.ts: fail(), ActionData, progressive enhancement, and server-side validation patterns