JSON in Qwik City: routeLoader$, server$, and Type Safety
Last updated:
Qwik City returns JSON data through three mechanisms: routeLoader$ for page-level data fetching, server$ for arbitrary server functions called from components, and route handlers in src/routes/.../index.ts for REST-style JSON APIs. routeLoader$ runs on the server for every request and exposes its return value to any component in the route tree via a typed signal — calling useProductLoader() (the hook generated from the loader) returns a Signal<Product>. Qwik's resumability model means the component never re-hydrates — the loader's JSON payload is embedded in the initial HTML as serialized state, and Qwik restores execution context on interaction without re-running component code. server$ is more flexible: a function annotated with server$() can be called from any component event handler, runs exclusively on the server, and returns typed JSON. Route handlers export onGet, onPost, etc., and return json(data) from @builder.io/qwik-city. This guide covers routeLoader$, server$, route handler JSON endpoints, Zod validation, TypeScript types, and useResource$ for client-side JSON fetching.
routeLoader$ for Page JSON Data
Bottom line: routeLoader$ is the primary server-to-component data channel in Qwik City. It runs on every navigation, serializes its return value into the initial HTML as JSON, and exposes it as a typed signal with no extra network requests.
Define a loader in src/routes/products/index.tsx by calling routeLoader$ and exporting the result — Qwik City generates a typed hook from the export name. The loader receives a RequestEvent argument that contains params (route parameters), request (the full Request object for headers and URL access), and a redirect() method for conditional redirects. Multiple loaders can coexist in one route file — each produces its own independently typed signal. In components, call the hook (e.g. useProductLoader()) and access the data via.value, which is typed as the loader's return type. Qwik generates this type automatically from the function's return annotation — no manual type casting required.
// src/routes/products/index.tsx
import { component$ } from "@builder.io/qwik"
import { routeLoader$, type RequestHandler } from "@builder.io/qwik-city"
interface Product {
id: number
name: string
price: number
stock: number
}
interface User {
id: number
role: "admin" | "user"
}
// --- routeLoader$ with route params ---
export const useProductLoader = routeLoader$(async (requestEvent) => {
const { productId } = requestEvent.params // typed from route file name
// Access the full Request object (headers, URL)
const authHeader = requestEvent.request.headers.get("authorization")
// Conditional redirect — throws internally, stops execution
if (!authHeader) {
throw requestEvent.redirect(302, "/login")
}
const product: Product = await fetch(
`https://api.example.com/products/${productId}`
).then((r) => r.json())
return product // TypeScript infers Signal<Product> from this return type
})
// --- Multiple loaders in one route file ---
export const useCurrentUser = routeLoader$(async (requestEvent) => {
const user: User = await getCurrentUser(requestEvent.request)
return user
})
// --- Component accesses both loaders ---
export default component$(() => {
const product = useProductLoader() // Signal<Product>
const user = useCurrentUser() // Signal<User>
return (
<div>
<h1>{product.value.name}</h1>
<p>Price: ${product.value.price.toFixed(2)}</p>
<p>Stock: {product.value.stock} units</p>
{user.value.role === "admin" && (
<button onClick$={() => console.log("edit")}>Edit Product</button>
)}
</div>
)
})
// Placeholder for getCurrentUser
async function getCurrentUser(request: Request): Promise<User> {
return { id: 1, role: "admin" }
}server$ for On-Demand Server Functions
Bottom line: server$() wraps any async function so it runs on the server when called from the client. The function body never appears in client bundles, making it safe for database queries and secret API calls triggered by user interactions.
Unlike routeLoader$, which is declarative and runs on every route render, server$ is imperative — you call it explicitly from event handlers like onClick$ or onSubmit$. This makes it the right tool for mutations, paginated data loads, and user-triggered fetches. The function signature is preserved: parameters are serialized and sent to the server, the return value is serialized back. For streaming, define an async generator inside server$ and yield JSON strings — the client consumes them with for await...of. At the network level, server$ uses a POST request to a generated endpoint; in development, Qwik DevTools shows these calls in the network panel. 3 key constraints: arguments must be JSON-serializable, return values must be JSON-serializable, and this inside the function refers to the RequestEvent.
import { component$, useSignal } from "@builder.io/qwik"
import { server$ } from "@builder.io/qwik-city"
interface ProductDetail {
id: string
name: string
description: string
price: number
}
// --- Basic server$ function: called on demand from component ---
const getProduct = server$(async (id: string): Promise<ProductDetail> => {
// This code runs only on the server — safe for DB calls
const product = await db.products.findById(id)
return product
})
// --- server$ with streaming (async generator) ---
const streamAnalysis = server$(async function* (productId: string) {
yield JSON.stringify({ status: "processing", step: 1, total: 3 })
const basicData = await fetchBasicInfo(productId)
yield JSON.stringify({ status: "processing", step: 2, total: 3, data: basicData })
const analysis = await runAnalysis(productId)
yield JSON.stringify({ status: "done", step: 3, total: 3, result: analysis })
})
export default component$(() => {
const product = useSignal<ProductDetail | null>(null)
const streamOutput = useSignal<string[]>([])
const loading = useSignal(false)
return (
<div>
{/* Imperative call from event handler */}
<button
onClick$={async () => {
loading.value = true
product.value = await getProduct("prod_123")
loading.value = false
}}
>
{loading.value ? "Loading..." : "Load Product"}
</button>
{product.value && (
<div>
<h2>{product.value.name}</h2>
<p>${product.value.price.toFixed(2)}</p>
</div>
)}
{/* Streaming: consume the async generator */}
<button
onClick$={async () => {
streamOutput.value = []
for await (const chunk of await streamAnalysis("prod_123")) {
const parsed = JSON.parse(chunk)
streamOutput.value = [...streamOutput.value, `Step ${parsed.step}/${parsed.total}: ${parsed.status}`]
}
}}
>
Run Streaming Analysis
</button>
{streamOutput.value.map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
)
})
// Placeholder implementations
const db = { products: { findById: async (id: string): Promise<ProductDetail> => ({ id, name: "Widget", description: "A widget", price: 9.99 }) } }
async function fetchBasicInfo(id: string) { return { id } }
async function runAnalysis(id: string) { return { score: 0.95 } }Route Handler JSON Endpoints
Bottom line: route handlers in src/routes/api/... export onGet, onPost, etc., and use the json() helper from RequestEvent to send typed JSON responses. They are designed for external API consumers, not internal component calls.
Each handler file maps to a URL based on its file path. A file at src/routes/api/products/index.ts handles /api/products. Route parameters come from folder names with brackets: src/routes/api/products/[id]/index.ts provides params.id. The RequestEvent object carries 5 key helpers: json(status, data) for JSON responses, text(status, body), html(status, markup), redirect(status, url), and error(status, message). Query strings are available via requestEvent.url.searchParams.get('q'). A layout.ts file in a parent directory applies middleware (auth checks, CORS headers) to all child routes — this is the standard pattern for API authentication in Qwik City.
// src/routes/api/products/index.ts
import type { RequestHandler } from "@builder.io/qwik-city"
interface Product {
id: number
name: string
price: number
}
// --- GET /api/products?q=widget&limit=20 ---
export const onGet: RequestHandler = async ({ json, url }) => {
const q = url.searchParams.get("q") ?? ""
const limit = parseInt(url.searchParams.get("limit") ?? "10", 10)
const products = await searchProducts(q, limit)
json(200, { data: products, total: products.length })
}
// --- POST /api/products (create) ---
export const onPost: RequestHandler = async ({ json, request, error }) => {
const body = await request.json()
// Basic validation — see Zod section for full validation
if (!body.name || typeof body.price !== "number") {
throw error(400, "name (string) and price (number) are required")
}
const product = await createProduct(body)
json(201, { data: product })
}
// src/routes/api/products/[id]/index.ts
// --- GET /api/products/:id ---
export const onGetById: RequestHandler = async ({ json, params, error }) => {
const product = await getProductById(parseInt(params.id, 10))
if (!product) {
throw error(404, `Product ${params.id} not found`)
}
json(200, { data: product })
}
// --- DELETE /api/products/:id ---
export const onDelete: RequestHandler = async ({ json, params, error }) => {
const deleted = await deleteProduct(parseInt(params.id, 10))
if (!deleted) throw error(404, "Not found")
json(200, { deleted: true, id: params.id })
}
// src/routes/api/layout.ts — middleware for all /api/* routes
// export const onRequest: RequestHandler = async ({ request, error, next }) => {
// const token = request.headers.get("authorization")?.split(" ")[1]
// if (!token || !verifyToken(token)) throw error(401, "Unauthorized")
// await next()
// }
// Placeholder implementations
async function searchProducts(q: string, limit: number): Promise<Product[]> { return [] }
async function createProduct(body: unknown): Promise<Product> { return { id: 1, name: "New", price: 0 } }
async function getProductById(id: number): Promise<Product | null> { return null }
async function deleteProduct(id: number): Promise<boolean> { return true }Zod Validation for JSON Input
Bottom line: validate incoming JSON in route handlers and server$ functions with Zod's safeParse. Return structured 400 errors using result.error.flatten() so clients receive field-level error detail.
Install with npm install zod. In a route handler, read the body with await request.json(), then call schema.safeParse(body). The safeParse path never throws — it returns { success: true, data: T } or { success: false, error: ZodError }. Use result.error.flatten() to produce a flat object of field-level error arrays, suitable as a JSON response body. Derive TypeScript types with z.infer<typeof schema> so the schema is the single source of truth for both runtime validation and compile-time types — no duplicate interface definitions. For routeAction$ (form submissions), Qwik provides zod$() from @builder.io/qwik-city/middleware/request-handler to wire Zod schemas directly into actions, giving built-in field-level error signals in components via action.value.fieldErrors.
// src/routes/api/products/index.ts
import { z } from "zod"
import type { RequestHandler } from "@builder.io/qwik-city"
// --- Zod schema as the single source of truth ---
const createProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
category: z.enum(["electronics", "clothing", "food"]),
tags: z.array(z.string()).optional().default([]),
})
// Derive TypeScript type — no duplicate interface
type CreateProductInput = z.infer<typeof createProductSchema>
const updateProductSchema = createProductSchema.partial() // all fields optional for PATCH
// --- POST handler with Zod validation ---
export const onPost: RequestHandler = async ({ json, request }) => {
let body: unknown
try {
body = await request.json()
} catch {
json(400, { error: "Request body must be valid JSON" })
return
}
const result = createProductSchema.safeParse(body)
if (!result.success) {
// flatten() returns { formErrors: string[], fieldErrors: Record<string, string[]> }
json(400, { errors: result.error.flatten().fieldErrors })
return
}
// result.data is fully typed as CreateProductInput
const product = await createProduct(result.data)
json(201, { data: product })
}
// --- server$ with Zod validation ---
import { server$ } from "@builder.io/qwik-city"
const createProductServer = server$(async (input: unknown) => {
const result = createProductSchema.safeParse(input)
if (!result.success) {
throw new Error(JSON.stringify(result.error.flatten().fieldErrors))
}
return await createProduct(result.data)
})
// --- routeAction$ with built-in Zod integration ---
// import { routeAction$, zod$ } from "@builder.io/qwik-city"
// export const useCreateProduct = routeAction$(
// async (data, requestEvent) => {
// // data is typed as CreateProductInput — Zod already validated it
// const product = await createProduct(data)
// return { success: true, product }
// },
// zod$(createProductSchema)
// )
// In component: action.value?.fieldErrors?.name[0] — per-field error strings
async function createProduct(data: CreateProductInput) {
return { id: Date.now(), ...data }
}useResource$ for Client JSON Fetching
Bottom line: useResource$ fetches data reactively — it re-runs automatically when any tracked signal changes. The built-in cleanup callback aborts in-flight requests on unmount or re-run, preventing memory leaks with no extra bookkeeping.
Call track(() => signal.value) inside useResource$ to declare dependencies — the resource re-runs whenever those values change, similar to useEffect in React but with automatic cancellation. The cleanup function receives a callback that runs before each re-run and on unmount. Pass it an AbortController.abort method to cancel the previous fetch before starting a new one — this prevents race conditions where a slower earlier response arrives after a faster later one. Render the resource state with the <Resource> component, which provides three render props: onPending (loading state), onResolved (success with typed data), andonRejected (error with the rejection value). The critical difference from server$: useResource$ can run partly client-side (for SSR it runs server-side, then re-runs on the client when dependencies change); server$ always executes server-side regardless of where it is called.
import { component$, useSignal, Resource, useResource$ } from "@builder.io/qwik"
interface Product {
id: number
name: string
price: number
description: string
}
export default component$(() => {
const productId = useSignal("prod_1")
const searchQuery = useSignal("")
// --- useResource$: re-runs when productId.value changes ---
const productResource = useResource$(async ({ track, cleanup }) => {
// track declares the dependency — resource re-runs when productId changes
const id = track(() => productId.value)
// cleanup runs before each re-run and on unmount
const controller = new AbortController()
cleanup(() => controller.abort())
const response = await fetch(`/api/products/${id}`, {
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json() as Promise<Product>
})
// --- useResource$ tracking multiple signals ---
const searchResource = useResource$(async ({ track, cleanup }) => {
const q = track(() => searchQuery.value)
// Debounce: skip empty queries
if (q.length < 2) return []
const controller = new AbortController()
cleanup(() => controller.abort())
const url = new URL("/api/products", location.href)
url.searchParams.set("q", q)
return fetch(url.toString(), { signal: controller.signal })
.then((r) => r.json() as Promise<Product[]>)
})
return (
<div>
{/* Selector updates productId signal → triggers resource re-run */}
<select
value={productId.value}
onChange$={(e) => { productId.value = (e.target as HTMLSelectElement).value }}
>
<option value="prod_1">Product 1</option>
<option value="prod_2">Product 2</option>
<option value="prod_3">Product 3</option>
</select>
{/* Resource component renders the 3 states */}
<Resource
value={productResource}
onPending={() => <div>Loading product...</div>}
onResolved={(product) => (
<div>
<h2>{product.name}</h2>
<p>${product.price.toFixed(2)}</p>
<p>{product.description}</p>
</div>
)}
onRejected={(err) => (
<div style={{ color: "red" }}>
Error: {err instanceof Error ? err.message : "Unknown error"}
</div>
)}
/>
{/* Search with automatic cancellation */}
<input
placeholder="Search products..."
value={searchQuery.value}
onInput$={(e) => { searchQuery.value = (e.target as HTMLInputElement).value }}
/>
<Resource
value={searchResource}
onPending={() => <span>Searching...</span>}
onResolved={(products) => (
<ul>{(products ?? []).map((p) => <li key={p.id}>{p.name}</li>)}</ul>
)}
onRejected={() => <span>Search failed</span>}
/>
</div>
)
})TypeScript Types and Serialization
Bottom line: Qwik's serialization system converts routeLoader$ return values to HTML-embedded JSON automatically. All returned values must be JSON-serializable — 4 types are prohibited: Date objects, undefined, functions, and circular references.
Qwik embeds serialized state in a <script type="qwik/json"> tag in the initial HTML. On resumption, Qwik restores this state without re-running component code. This means Date objects must be converted to ISO strings before returning from a loader — the loader always controls what gets serialized. Replace undefined with null (undefined is not valid JSON). Functions cannot be serialized — use QRL<T> for lazy-loaded function references, which Qwik handles specially. Wrap values that should not be serialized (DOM references, class instances with methods) with noSerialize() — Qwik excludes these from the serialization pass and restores them as undefined after resumption, preventing hydration errors. The Signal<T> type returned by loader hooks is generic — T is inferred automatically from the loader's return type annotation, so the TypeScript compiler catches mismatches between what the server returns and what the component reads.
import { component$, noSerialize, useSignal, type NoSerialize } from "@builder.io/qwik"
import { routeLoader$ } from "@builder.io/qwik-city"
// --- Safe DTO: all fields are JSON-serializable ---
interface ProductDto {
id: number
name: string
price: number
tags: string[]
createdAt: string // ISO string — NOT Date
metadata: Record<string, string | number | boolean | null>
relatedIds: number[]
}
// --- WRONG: Date objects are NOT serializable ---
// interface BadProductDto {
// id: number
// createdAt: Date // ❌ Date cannot be serialized to qwik/json
// callback: () => void // ❌ functions cannot be serialized
// ref: HTMLElement // ❌ DOM references cannot be serialized
// }
// --- routeLoader$: return type is automatically inferred ---
export const useProductLoader = routeLoader$(async (requestEvent): Promise<ProductDto> => {
const raw = await db.findProduct(requestEvent.params.id)
return {
id: raw.id,
name: raw.name,
price: raw.price,
tags: raw.tags,
createdAt: raw.createdAt.toISOString(), // Date → ISO string
metadata: raw.metadata ?? {}, // undefined → {}
relatedIds: raw.relatedIds ?? [],
}
})
// --- Signal<T> from loader hook ---
// useProductLoader() returns Signal<ProductDto>
// Access with .value: product.value.createdAt is typed as string
// --- QRL<T> for lazy-loaded functions ---
// import type { QRL } from "@builder.io/qwik"
// const handler: QRL<(id: number) => void> = $((id) => console.log(id))
// --- noSerialize() for values that must not be serialized ---
interface ComponentState {
data: ProductDto | null
chartInstance: NoSerialize<{ destroy: () => void }> | undefined
}
export default component$(() => {
const state = useSignal<ComponentState>({
data: null,
chartInstance: undefined,
})
const product = useProductLoader()
return (
<div>
<p>Product: {product.value.name}</p>
<p>Created: {new Date(product.value.createdAt).toLocaleDateString()}</p>
<p>Tags: {product.value.tags.join(", ")}</p>
<button
onClick$={() => {
// noSerialize wraps a Chart.js instance so Qwik skips it during serialization
const chart = createChartSomehow()
state.value = {
...state.value,
chartInstance: noSerialize(chart), // excluded from qwik/json embedding
}
}}
>
Initialize Chart
</button>
</div>
)
})
// Placeholder
const db = { findProduct: async (id: string) => ({ id: 1, name: "Widget", price: 9.99, tags: ["sale"], createdAt: new Date(), metadata: {}, relatedIds: [] }) }
function createChartSomehow() { return { destroy: () => {} } }FAQ
How do I fetch JSON data in Qwik City?
Qwik City offers 3 mechanisms. Use routeLoader$ for page-level data: it runs server-side on every navigation, embeds the JSON in the initial HTML, and returns a typed Signal<T> accessible in any component in the route tree — 0 additional network requests for initial data. Use server$ for on-demand server calls triggered from event handlers: call it like a regular async function, and it runs exclusively on the server. Use useResource$ for reactive fetching that re-runs when tracked signals change, with automatic cancellation via AbortSignal.
How do I create a JSON API endpoint in Qwik City?
Create a file at src/routes/api/your-path/index.ts and export handler functions: onGet, onPost, onPut, onDelete. Each handler receives a RequestEvent with helpers including json(status, data) for JSON responses, error(status, message) for errors, and url.searchParams for query strings. Access route parameters from bracket-named folders via params.id. For a POST handler, read the body with await request.json(). Parent folder layout.ts files handle middleware like auth for all child routes.
What is routeLoader$ and how does it differ from server$?
routeLoader$ is declarative — it runs automatically on every route render before the component tree, serializes its return value into the HTML as JSON, and exposes it as a typed signal. There are 0 additional network requests for initial data. server$ is imperative — you call it explicitly from event handlers, it fires a POST request to a generated server endpoint, and returns a typed result. Key tradeoffs: routeLoader$ supports conditional redirects and runs once per navigation; server$ supports async generator streaming and runs on demand. Use routeLoader$ for data the page always needs on load; use server$ for mutations, paginated loads, and user-triggered fetches.
How do I validate JSON in a Qwik City route handler?
Read the body with await request.json(), then call schema.safeParse(body). Return json(400, { errors: result.error.flatten().fieldErrors }) on failure — this gives clients a field-level error object. On success, result.data is typed as z.infer<typeof schema>. For routeAction$ (form submissions), use zod$(schema) as the second argument — Qwik validates the FormData automatically and surfaces per-field errors via action.value.fieldErrors in components. For server$ functions, validate input at the top of the function body and throw a typed error on failure.
How does Qwik serialize JSON for the client?
Qwik embeds routeLoader$ return values as JSON inside a <script type="qwik/json"> tag in the initial HTML. On first user interaction, Qwik restores the execution context from this serialized state without re-running component code — this is resumability, not hydration. The result: 0 KB of component JS executes on page load. Serialization constraints: no Date objects (use ISO strings), no undefined (use null), no functions (use QRL<T>), no circular references. Wrap non-serializable values with noSerialize() to exclude them from the JSON pass — Qwik restores them as undefined after resumption.
How do I stream JSON from a Qwik server$ function?
Define an async generator inside server$: use async function* syntax and yield JSON strings. On the client, consume with for await (const chunk of await streamFn()) { JSON.parse(chunk) }. Each yielded value is sent as a streaming chunk over the network connection. The generator can yield multiple times — useful for LLM token streaming (yield one token at a time), long-running task progress (yield after each step), and real-time data pushes. The streaming connection closes automatically when the generator function returns or throws. This pattern requires 0 additional dependencies — no WebSockets or SSE libraries needed.
Key Terms
routeLoader$- A Qwik City function that declares a server-side data loader for a route. It runs on every navigation, serializes its return value into the initial HTML as JSON, and generates a typed hook (e.g.
useProductLoader()) that returns aSignal<T>in components. server$- A Qwik City function wrapper that marks an async function to run exclusively on the server. The wrapped function can be called from component event handlers — Qwik transparently serializes arguments, makes a POST request to a generated endpoint, and deserializes the response.
useResource$- A Qwik hook for reactive data fetching. It accepts a function that declares signal dependencies via
track()and acleanupcallback. The resource re-runs automatically when tracked signals change, and the cleanup callback aborts previous in-flight requests. RequestHandler- The TypeScript type for Qwik City route handler functions (
onGet,onPost, etc.). TheRequestEventargument providesjson(),error(),redirect(),params,url, andrequestproperties. Signal<T>- A reactive container type in Qwik. Signals returned by
routeLoader$hooks are typed asSignal<T>whereTis inferred from the loader's return type. Access the current value with.value. Qwik tracks signal reads and re-renders only the components that read a changed signal. noSerialize()- A Qwik utility that wraps a value to exclude it from the resumability serialization pass. Use it for DOM references, class instances with methods, or any value that cannot be JSON-serialized. After resumption,
noSerialize()-wrapped values are restored asundefined.
Building with other meta-frameworks?
The same server-side data loading patterns — loaders, server functions, typed signals — appear in SolidStart and SvelteKit with different APIs. See the equivalent guides for JSON in SolidStart, JSON in SvelteKit, and JSON in Astro.
For general API design patterns that apply across all frameworks, see the JSON API design guide.
Open JSON Validator on jsonic.ioFurther reading and primary sources
- Qwik City routeLoader$ docs — Official Qwik City documentation for routeLoader$ — declaring server-side data loaders, accessing RequestEvent, typed signals, and conditional redirects
- Qwik City server$ docs — Official Qwik documentation for server$: calling server functions from components, streaming with async generators, and differences from routeLoader$
- Qwik City route handlers — Official Qwik City documentation for onGet, onPost, and other RequestHandler exports — building REST API endpoints within a Qwik City project
- Qwik resumability vs hydration — Official Qwik explanation of resumability: how JSON state is serialized into HTML and restored on interaction without executing component code
- Zod documentation — Official Zod documentation for schema definition, safeParse, error formatting, and TypeScript type inference with z.infer<typeof schema>