JSON in Svelte and SvelteKit

Last updated:

Svelte and SvelteKit offer several idiomatic ways to work with JSON: client-side fetching with the { #await } template block, server-side fetching via the load() function, JSON API endpoints in +server.ts files, and reactive state management with the Svelte 5 $state rune. This guide covers each pattern with complete TypeScript examples.

Fetching JSON in a Svelte 5 Component

In Svelte 5, declare reactive state with $state and fetch JSON in onMount. The { #await } block provides a declarative way to handle the three states of an async operation directly in the template.

<!-- Products.svelte (Svelte 5) -->
<script lang="ts">
  import { onMount } from 'svelte'

  interface Product {
    id: number
    name: string
    price: number
    inStock: boolean
  }

  let products = $state<Product[]>([])
  let loading = $state(true)
  let error = $state<string | null>(null)

  onMount(async () => {
    try {
      const res = await fetch('/api/products')
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      products = await res.json()
    } catch (e) {
      error = e instanceof Error ? e.message : 'Unknown error'
    } finally {
      loading = false
    }
  })
</script>

{#if loading}
  <p>Loading products...</p>
{:else if error}
  <p class="text-red-600">Error: {error}</p>
{:else}
  <ul>
    {#each products as product (product.id)}
      <li>{product.name} — ${product.price}</li>
    {/each}
  </ul>
{/if}

Alternatively, use the { #await } block to keep the fetch logic in the template without tracking state variables manually:

<!-- AwaitBlock.svelte -->
<script lang="ts">
  interface User { id: number; name: string; email: string }

  // Declare the promise at module level so Svelte tracks it
  const promise = fetch('/api/users').then(r => r.json() as Promise<User[]>)
</script>

{#await promise}
  <p>Loading users...</p>
{:then users}
  {#each users as user (user.id)}
    <p>{user.name} ({user.email})</p>
  {/each}
{:catch err}
  <p class="text-red-600">Failed: {err.message}</p>
{/await}

SvelteKit load() Function (Server-Side)

The load() function in +page.server.ts runs exclusively on the server before the page renders. Use event.fetch — SvelteKit's enhanced fetch — rather than the global fetch to get cookie forwarding and relative URL support.

// src/routes/products/+page.server.ts
import type { PageServerLoad } from './$types'

interface Product {
  id: number
  name: string
  price: number
  category: string
}

export const load: PageServerLoad = async (event) => {
  // Use event.fetch — forwards cookies and supports relative URLs
  const res = await event.fetch('/api/products')

  if (!res.ok) {
    // SvelteKit error() throws a typed error with an HTTP status
    const { error } = await import('@sveltejs/kit')
    throw error(res.status, 'Failed to load products')
  }

  const products: Product[] = await res.json()

  return { products }
}
<!-- src/routes/products/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types'

  // data is fully typed from the load() return value
  let { data } = $props<{ data: PageData }>()
</script>

<h1>Products ({data.products.length})</h1>
<ul>
  {#each data.products as product (product.id)}
    <li>{product.name} — ${product.price} ({product.category})</li>
  {/each}
</ul>

For parallel fetches, use Promise.all inside load(). SvelteKit also supports streaming with defer for slow data sources:

// Parallel fetch with Promise.all
export const load: PageServerLoad = async (event) => {
  const [productsRes, categoriesRes] = await Promise.all([
    event.fetch('/api/products'),
    event.fetch('/api/categories'),
  ])

  const [products, categories] = await Promise.all([
    productsRes.json(),
    categoriesRes.json(),
  ])

  return { products, categories }
}

SvelteKit +server.ts JSON Endpoints

A +server.ts file in any route folder creates a server-only HTTP handler. Export named functions for each HTTP method. Use the json() helper from $app/server to return JSON with the correct Content-Type header.

// src/routes/api/products/+server.ts
import { json } from '$app/server'
import type { RequestHandler } from './$types'

const products = [
  { id: 1, name: 'Keyboard', price: 99 },
  { id: 2, name: 'Mouse', price: 49 },
]

// GET /api/products
export const GET: RequestHandler = async (event) => {
  const search = event.url.searchParams.get('q') ?? ''
  const filtered = search
    ? products.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
    : products

  return json(filtered)
}

// POST /api/products
export const POST: RequestHandler = async (event) => {
  const body = await event.request.json()

  if (!body.name || typeof body.price !== 'number') {
    return json({ error: 'name and price are required' }, { status: 400 })
  }

  const newProduct = { id: Date.now(), ...body }
  products.push(newProduct)

  return json(newProduct, { status: 201 })
}
// src/routes/api/products/[id]/+server.ts
import { json, error } from '$app/server'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async (event) => {
  const id = Number(event.params.id)
  const product = products.find(p => p.id === id)

  if (!product) throw error(404, 'Product not found')

  return json(product)
}

export const DELETE: RequestHandler = async (event) => {
  const id = Number(event.params.id)
  const idx = products.findIndex(p => p.id === id)

  if (idx === -1) throw error(404, 'Product not found')

  products.splice(idx, 1)
  return new Response(null, { status: 204 })
}

Handling Loading and Error States

SvelteKit provides dedicated +loading.svelte and +error.svelte files for route-level loading and error UI. For component-level async state, combine $state with explicit flags or use the { #await } block.

// src/routes/+error.svelte — catches errors thrown in load()
<script lang="ts">
  import { page } from '$app/stores'
</script>

<h1>{$page.status} — {$page.error?.message}</h1>
<a href="/">Go home</a>
<!-- Component-level error state with $state rune -->
<script lang="ts">
  interface ApiState<T> {
    data: T | null
    loading: boolean
    error: string | null
  }

  async function fetchJson<T>(url: string): Promise<ApiState<T>> {
    try {
      const res = await fetch(url)
      if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
      return { data: await res.json(), loading: false, error: null }
    } catch (e) {
      return {
        data: null,
        loading: false,
        error: e instanceof Error ? e.message : 'Unknown error',
      }
    }
  }

  let state = $state<ApiState<Product[]>>({ data: null, loading: true, error: null })

  // Update state when fetch resolves
  fetchJson<Product[]>('/api/products').then(result => {
    state = result
  })
</script>

{#if state.loading}
  <div class="animate-pulse">Loading...</div>
{:else if state.error}
  <p class="text-red-600">Error: {state.error}</p>
{:else if state.data}
  <ProductList products={state.data} />
{/if}

TypeScript Interfaces for JSON Data

Define TypeScript interfaces for your JSON shapes and wire them through the SvelteKit type generation system. The generated PageData and PageServerLoad types from ./$types carry your return types automatically.

// src/lib/types.ts — shared type definitions
export interface Product {
  id: number
  name: string
  price: number
  category: Category
  tags: string[]
  meta: {
    createdAt: string  // ISO 8601 date string from JSON
    updatedAt: string
  }
}

export interface Category {
  id: number
  name: string
  slug: string
}

export interface PaginatedResponse<T> {
  data: T[]
  total: number
  page: number
  perPage: number
  hasNext: boolean
}
// src/routes/products/+page.server.ts
import type { PageServerLoad } from './$types'
import type { PaginatedResponse, Product } from '$lib/types'

export const load: PageServerLoad = async (event) => {
  const page = event.url.searchParams.get('page') ?? '1'

  const res = await event.fetch(`/api/products?page=${page}`)
  const result: PaginatedResponse<Product> = await res.json()

  // TypeScript now fully types data.products as Product[]
  return { products: result.data, total: result.total, page: Number(page) }
}
// Type guard for untrusted external JSON
function isProduct(value: unknown): value is Product {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value &&
    typeof (value as Record<string, unknown>).id === 'number' &&
    typeof (value as Record<string, unknown>).name === 'string'
  )
}

// Usage
const raw: unknown = await res.json()
if (!isProduct(raw)) throw new Error('Invalid product response')
// raw is now Product
console.log(raw.name)

Posting JSON with Form Actions

SvelteKit form actions in +page.server.ts are the idiomatic way to submit data. They work with or without JavaScript, handle CSRF automatically, and support progressive enhancement via use:enhance.

// src/routes/products/new/+page.server.ts
import { redirect, fail } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'

export const actions: Actions = {
  // Default action — called by <form method="POST">
  default: async (event) => {
    const formData = await event.request.formData()
    const name = formData.get('name') as string
    const price = Number(formData.get('price'))

    if (!name || isNaN(price) || price <= 0) {
      return fail(400, { error: 'Invalid product data', name, price })
    }

    // Post JSON to an API route
    const res = await event.fetch('/api/products', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, price }),
    })

    if (!res.ok) {
      const { message } = await res.json()
      return fail(res.status, { error: message })
    }

    throw redirect(303, '/products')
  },
}
<!-- src/routes/products/new/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms'
  import type { ActionData } from './$types'

  let { form } = $props<{ form: ActionData }>()
</script>

<form method="POST" use:enhance>
  {#if form?.error}
    <p class="text-red-600">{form.error}</p>
  {/if}
  <input name="name" value={form?.name ?? ''} placeholder="Product name" required />
  <input name="price" type="number" value={form?.price ?? ''} placeholder="Price" required />
  <button type="submit">Create Product</button>
</form>

For a purely JSON-based POST from client-side JavaScript (without HTML form submission), call the endpoint directly:

// Client-side JSON POST
async function createProduct(name: string, price: number) {
  const res = await fetch('/api/products', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, price }),
  })

  if (!res.ok) {
    const err = await res.json()
    throw new Error(err.error ?? 'Failed to create product')
  }

  return res.json()  // Returns the created product
}

Stores vs $state Rune for JSON State

Svelte 5 introduces the $state rune as the preferred reactivity primitive for component-local state, replacing the implicit reactivity of plain let variables. Svelte stores remain relevant for cross-component and cross-page shared state.

// Svelte 4 approach (still valid in Svelte 5 with compat mode)
<script lang="ts">
  import { writable } from 'svelte/store'

  const products = writable<Product[]>([])

  async function loadProducts() {
    const res = await fetch('/api/products')
    products.set(await res.json())
  }
</script>

<!-- Access store value with $ prefix -->
{#each $products as product}
  <li>{product.name}</li>
{/each}
// Svelte 5 approach — $state rune (preferred for local state)
<script lang="ts">
  let products = $state<Product[]>([])
  let selectedId = $state<number | null>(null)

  // $derived replaces $: reactive declarations
  let selected = $derived(products.find(p => p.id === selectedId) ?? null)

  // $effect replaces $: statements with side effects
  $effect(() => {
    // Runs whenever selectedId changes
    if (selectedId) console.log('Selected:', selectedId)
  })
</script>
// src/lib/stores/cart.ts — writable store for shared state across components
import { writable, derived } from 'svelte/store'

export interface CartItem { productId: number; name: string; price: number; qty: number }

export const cart = writable<CartItem[]>([])

export const cartTotal = derived(cart, $cart =>
  $cart.reduce((sum, item) => sum + item.price * item.qty, 0)
)

export function addToCart(item: Omit<CartItem, 'qty'>) {
  cart.update(items => {
    const existing = items.find(i => i.productId === item.productId)
    if (existing) {
      return items.map(i => i.productId === item.productId ? { ...i, qty: i.qty + 1 } : i)
    }
    return [...items, { ...item, qty: 1 }]
  })
}

Key Definitions

load function
A function exported from +page.server.ts or +page.ts that runs before a page renders. Its return value is passed as the data prop to the corresponding +page.svelte component. Server load functions have access to cookies, environment variables, and direct database connections.
+server.ts
A SvelteKit file convention for creating server-only HTTP handlers (API routes). Exports named functions matching HTTP verbs (GET, POST, PUT, DELETE, etc.). Can coexist with +page.svelte in the same route folder to serve both HTML and JSON at the same path.
$state rune
A Svelte 5 compile-time directive that declares a reactive state variable: let count = $state(0). Replaces the implicit reactivity of let declarations from Svelte 4. Supports fine-grained reactivity for objects and arrays, meaning only the changed properties trigger DOM updates.
event.fetch
SvelteKit's enhanced fetch available inside load() and +server.ts handlers. Supports relative URLs during server-side rendering, forwards the user's cookies to same-origin requests, and de-duplicates concurrent requests for the same resource. Use it instead of the global fetch inside SvelteKit server contexts.
form action
A named export inside the actions object in +page.server.ts that handles HTML form submissions. Called when a <form method="POST"> is submitted. Works without JavaScript (progressive enhancement) and automatically receives CSRF protection. Enhanced with use:enhance for client-side interactivity without a full page reload.

FAQ

How do I fetch JSON in a Svelte component?

In Svelte 5, declare reactive state with the $state rune: let data = $state(null). In onMount, call fetch(), await response.json(), and assign the result to your state variable. Svelte automatically re-renders template expressions that read that variable. For async display directly in the template, use the { #await promise } block — it has three branches: loading (no keyword), { :then } for resolved data, and { :catch } for errors. In SvelteKit, prefer the load() function in +page.server.ts over onMount fetching to avoid client-side waterfalls.

What is the SvelteKit load() function?

The load() function is exported from +page.server.ts (server-only) or +page.ts (universal). It runs before the page component renders and its return value is available as the data prop. Server load functions have access to cookies, databases, and environment variables — use them for any fetch requiring secrets. Whatever load() returns is automatically passed as props.data to the +page.svelte component and is fully typed via the generated PageServerLoad type.

What is event.fetch in SvelteKit and why should I use it?

event.fetch is SvelteKit's special fetch implementation available inside load() and server route handlers. It supports relative URL requests during SSR, forwards the user's cookies and auth headers to same-origin requests automatically, and de-duplicates requests during server-side rendering. Using global fetch() inside a server load function misses these benefits and may fail in some deployment environments. Always destructure event.fetch from the load function argument for any HTTP calls made inside load().

How do I create a JSON API endpoint in SvelteKit?

Create a file named +server.ts inside any route folder. Export named functions matching HTTP methods: GET, POST, PUT, PATCH, DELETE. Return a Response using SvelteKit's json() helper from '$app/server' — it serializes the value and sets Content-Type: application/json automatically. For POST endpoints, parse the body with await event.request.json(). SvelteKit +server.ts files can coexist with +page.svelte in the same route folder.

What is the $state rune in Svelte 5 and how is it different from let?

In Svelte 4, any let declaration in a component script was implicitly reactive. In Svelte 5, reactivity is explicit: use the $state rune — let count = $state(0). Plain let variables are no longer reactive by default. The $state rune enables fine-grained reactivity: deeply nested object properties update the DOM only when they change. For derived values, use $derived. For side effects that run when state changes, use $effect.

How do I post JSON data in SvelteKit using form actions?

SvelteKit form actions are defined in +page.server.ts as named exports under an actions object. Each action receives a RequestEvent and can call event.request.formData() for HTML form submissions or event.request.json() for JSON POST. Form actions automatically handle CSRF protection, work without JavaScript (progressive enhancement), and integrate with use:enhance for client-side interactivity. Use fail(status, data) to return validation errors and redirect(303, url) on success.

How do I type JSON data with TypeScript in SvelteKit?

Define an interface matching the JSON shape and use it as the generic type for your state variable. In +page.server.ts, import PageServerLoad from ./$types — a generated type that provides full type safety for the load function signature and its return value. The return type flows automatically into the data prop of +page.svelte, typed via PageData from ./$types. For untrusted external APIs, use a type guard before narrowing to your interface.

Should I use Svelte stores or the $state rune for JSON state?

For component-local JSON state, prefer the $state rune in Svelte 5 — it is simpler, requires no import, and integrates with fine-grained reactivity. For state shared across multiple components or persisted across navigation, use a Svelte writable store or SvelteKit page data via load(). Avoid creating stores just to hold server-fetched data — the SvelteKit load() system already handles caching, invalidation, and streaming for that use case.

Further reading and primary sources