JSON in SolidStart: API Routes, createAsync, and Type Safety

Last updated:

SolidStart returns JSON from two entry points: API routes in src/routes/api/ and createAsync() server functions in src/routes/ page files. API routes export HTTP method handlers (GET, POST, PUT, DELETE) that return a Response — use the standard Response.json(data) or the json() helper from @solidjs/start/server. Server functions defined with the "use server" directive run exclusively on the server and return typed JavaScript values directly to the client component — no manual JSON.stringify or fetch needed. SolidStart uses createAsync() to call these server functions from components, and the result is a reactive signal that updates automatically on navigation. TypeScript types flow from server function return types to the component without any manual casting. This guide covers API route JSON handlers, server function patterns with "use server", Zod validation for incoming JSON, streaming responses, and end-to-end type safety.

API Routes for JSON Endpoints

Bottom line: place a file in src/routes/api/, export named functions for each HTTP method, and return json(data) from @solidjs/start/server. SolidStart maps the filename directly to the URL path — no router configuration required.

The json() helper accepts a value and an optional init object (same shape as ResponseInit). It sets Content-Type: application/json and calls JSON.stringify internally. For error responses, pass { status: 404 } as the second argument. Path parameters come from bracket-named files: a file at src/routes/api/products/[id].ts receives event.params.id as a string. To read an incoming JSON body, call await event.request.json() — this parses the raw body once. Add CORS headers by including them in the ResponseInit headers object. Authorization checks belong at the top of the handler, before any database access; return a 401 response immediately if the check fails.

// src/routes/api/products/index.ts
import { json } from "@solidjs/start/server"
import type { APIEvent } from "@solidjs/start/server"
import { db } from "~/lib/db"

// GET /api/products
export async function GET(event: APIEvent) {
  // Authorization guard
  const auth = event.request.headers.get("Authorization")
  if (!auth?.startsWith("Bearer ")) {
    return json({ error: "Unauthorized" }, { status: 401 })
  }

  const products = await db.product.findAll()
  return json({ products, count: products.length })
}

// POST /api/products
export async function POST(event: APIEvent) {
  const body = await event.request.json()
  const product = await db.product.create(body)
  return json({ product }, { status: 201 })
}
// src/routes/api/products/[id].ts  — path parameter route
import { json } from "@solidjs/start/server"
import type { APIEvent } from "@solidjs/start/server"
import { db } from "~/lib/db"

export async function GET(event: APIEvent) {
  const { id } = event.params   // string from the URL segment

  const product = await db.product.findById(Number(id))
  if (!product) {
    return json({ error: "Product not found" }, { status: 404 })
  }

  return json({ product })
}

export async function DELETE(event: APIEvent) {
  const { id } = event.params
  await db.product.delete(Number(id))
  return json({ deleted: true })
}

// CORS headers for a public API route
export async function GET_public(event: APIEvent) {
  const data = await db.product.findAll()
  return new Response(JSON.stringify(data), {
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    },
  })
}

Server Functions with "use server"

Bottom line: declare a function with "use server" as its first statement, then call it from a component with createAsync(). SolidStart strips the function body from the client bundle and replaces every call site with an RPC stub — no fetch, no JSON.parse, no boilerplate.

Server functions are the preferred data-loading pattern for component-bound data. The return value must be serializable (plain objects, arrays, primitives, Date, Map, Set). The client receives the value typed exactly as the server function's return type — a function returning Promise<Product[]> produces an accessor of type Product[] | undefined in the component. Wrap the consuming JSX in <Suspense> to render a fallback while the data loads. For form submissions, use the action() helper to create a server action and useAction() to call it from the component — this pattern replaces the need for a POST API route in most internal use cases.

// src/lib/products.ts — server functions
import { db } from "~/lib/db"
import { action, cache } from "@solidjs/router"

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

// Plain server function
export const getProducts = async (): Promise<Product[]> => {
  "use server"
  return db.product.findAll()
}

// Server function with a parameter
export const getProduct = async (id: number): Promise<Product | null> => {
  "use server"
  return db.product.findById(id)
}

// Server action for form submission
export const createProduct = action(async (formData: FormData) => {
  "use server"
  const name = formData.get("name") as string
  const price = Number(formData.get("price"))
  return db.product.create({ name, price })
})
// src/routes/products.tsx — component using server functions
import { createAsync } from "@solidjs/router"
import { Suspense, For } from "solid-js"
import { getProducts, createProduct } from "~/lib/products"

export default function ProductsPage() {
  // createAsync calls the server function and returns a reactive accessor
  // products() is typed as Product[] | undefined
  const products = createAsync(() => getProducts())

  return (
    <main>
      <h1>Products</h1>

      {/* Suspense handles the undefined (loading) state */}
      <Suspense fallback={<p>Loading…</p>}>
        <ul>
          <For each={products()}>
            {(product) => (
              <li>
                {product.name} — {'$'}{product.price.toFixed(2)}
              </li>
            )}
          </For>
        </ul>
      </Suspense>

      {/* Form action — no fetch required */}
      <form method="post" action={createProduct}>
        <input name="name" placeholder="Product name" required />
        <input name="price" type="number" step="0.01" required />
        <button type="submit">Add product</button>
      </form>
    </main>
  )
}

TypeScript Type Safety End-to-End

Bottom line: type your server function return types explicitly, and createAsync() propagates them to the component automatically. The full type chain — database model to server function to component — requires zero manual as casts.

When a server function returns Promise<User>, createAsync() returns Accessor<User | undefined>. The undefined state represents the loading period before the first resolved value. For API route handlers, import APIEvent from @solidjs/start/server to type the event parameter — it exposes event.request, event.params, event.locals, and event.nativeEvent. Use RouteDefinition to declare typed route params for the router. Zod schemas generate TypeScript types with z.infer — define the schema once and derive both the runtime validator and the compile-time type from it.

// --- Typed server function return ---
export interface User {
  id: number
  email: string
  role: "admin" | "member"
}

// Explicit return type annotation keeps the contract visible
export const getUser = async (id: number): Promise<User | null> => {
  "use server"
  return db.user.findById(id)
}

// --- In a component ---
import { createAsync } from "@solidjs/router"
import type { Accessor } from "solid-js"
import { getUser } from "~/lib/users"

export default function ProfilePage(props: { params: { id: string } }) {
  // user: Accessor<User | null | undefined>
  const user = createAsync(() => getUser(Number(props.params.id)))

  // TypeScript knows user()?.email is string | undefined
  return <p>Email: {user()?.email ?? "Loading…"}</p>
}
// --- Typed API route handler ---
import type { APIEvent } from "@solidjs/start/server"
import { json } from "@solidjs/start/server"

interface CreateUserBody {
  email: string
  role: "admin" | "member"
}

export async function POST(event: APIEvent) {
  // event.request is the standard Request object
  const body = await event.request.json() as CreateUserBody
  const user = await db.user.create(body)
  return json(user, { status: 201 })
}

// --- RouteDefinition for typed params ---
import type { RouteDefinition } from "@solidjs/router"

export const route: RouteDefinition = {
  preload: ({ params }) => {
    // params.id is string — preload runs before the component mounts
    return getUser(Number(params.id))
  },
}

// --- Zod-derived types flow to server functions ---
import { z } from "zod"

const productSchema = z.object({
  name: z.string().min(1).max(100),
  price: z.number().positive(),
  tags: z.array(z.string()).max(5),
})

// z.infer gives the TypeScript type without a duplicate interface
type ProductInput = z.infer<typeof productSchema>

export const createProduct = async (input: ProductInput) => {
  "use server"
  return db.product.create(input)   // input is fully typed
}

Zod Validation for Incoming JSON

Bottom line: parse the request body with event.request.json(), then call schema.safeParse(body). Return a 400 response with structured field errors on failure; continue to the database only on success.

Define Zod schemas in a shared module so the same schema validates API route input, server function arguments, and client-side form fields. safeParse never throws — it returns { success: true, data } or { success: false, error }. Call error.flatten() to get field-level error messages suitable for returning in a JSON response. For FormData submissions (via action()), extract values with Object.fromEntries(formData) before passing to safeParse. A single Zod schema generates 3 artifacts at zero extra cost: the TypeScript type via z.infer, the runtime validator for the server, and the validation rules for client-side UI feedback.

// src/lib/schemas.ts — shared schemas
import { z } from "zod"

export const createProductSchema = z.object({
  name: z.string().min(1, "Name is required").max(100),
  price: z.number({ invalid_type_error: "Price must be a number" }).positive(),
  category: z.enum(["electronics", "clothing", "food"]),
  tags: z.array(z.string()).max(5).default([]),
})

// Derived TypeScript type — no duplicate interface needed
export type CreateProductInput = z.infer<typeof createProductSchema>
// src/routes/api/products/index.ts — API route with Zod validation
import { json } from "@solidjs/start/server"
import type { APIEvent } from "@solidjs/start/server"
import { createProductSchema } from "~/lib/schemas"
import { db } from "~/lib/db"

export async function POST(event: APIEvent) {
  let body: unknown
  try {
    body = await event.request.json()
  } catch {
    return json({ error: "Invalid JSON body" }, { status: 400 })
  }

  const result = createProductSchema.safeParse(body)
  if (!result.success) {
    // result.error.flatten() groups errors by field name
    return json(
      { errors: result.error.flatten().fieldErrors },
      { status: 400 }
    )
  }

  // result.data is fully typed as CreateProductInput
  const product = await db.product.create(result.data)
  return json({ product }, { status: 201 })
}
// src/lib/products.ts — server action with FormData validation
import { action } from "@solidjs/router"
import { z } from "zod"
import { db } from "~/lib/db"

const formSchema = z.object({
  name: z.string().min(1, "Name is required"),
  price: z.string().transform(Number).pipe(z.number().positive()),
})

export const createProduct = action(async (formData: FormData) => {
  "use server"
  // FormData values are always strings — transform price to number via Zod
  const raw = Object.fromEntries(formData)
  const result = formSchema.safeParse(raw)

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors }
  }

  const product = await db.product.create(result.data)
  return { product }
})

Streaming JSON Responses

Bottom line: SolidStart streams HTML via SolidJS's <Suspense> boundaries — each suspended component streams in when its createAsync() resolves, without blocking the rest of the page. For real-time data, use Server-Sent Events (SSE) from an API route.

SolidJS's fine-grained reactivity means that 3 independent createAsync() calls in one component resolve in parallel — the component renders each piece as it arrives, and only the specific DOM nodes that depend on each signal update. There are no cascading re-renders. For lazy loading below-the-fold data, wrap it in its own <Suspense> boundary so it does not block the above-the-fold render. For real-time push — live prices, notifications, progress — return a ReadableStream from an API route with content type text/event-stream. Each SSE event is a JSON payload formatted as data: {...}\n\n. On the client, consume events with the browser's native EventSource API.

// Multiple createAsync calls load in parallel — no waterfall
import { createAsync } from "@solidjs/router"
import { Suspense } from "solid-js"
import { getProducts, getCategories, getFeatured } from "~/lib/data"

export default function ShopPage() {
  // All 3 fire simultaneously — SolidJS does not batch them sequentially
  const products   = createAsync(() => getProducts())
  const categories = createAsync(() => getCategories())
  const featured   = createAsync(() => getFeatured())

  return (
    <div>
      {/* featured resolves first → renders immediately */}
      <Suspense fallback={<div>Loading featured…</div>}>
        <FeaturedBanner item={featured()} />
      </Suspense>

      {/* categories in sidebar, products in grid — independent Suspense */}
      <div class="layout">
        <Suspense fallback={<p>Loading categories…</p>}>
          <Sidebar categories={categories()} />
        </Suspense>

        <Suspense fallback={<p>Loading products…</p>}>
          <ProductGrid products={products()} />
        </Suspense>
      </div>
    </div>
  )
}
// src/routes/api/prices/live.ts — SSE endpoint for real-time price updates
import type { APIEvent } from "@solidjs/start/server"

export function GET(event: APIEvent) {
  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder()

      // Push a price update every 2 seconds
      const interval = setInterval(async () => {
        const priceUpdate = {
          symbol: "BTC",
          price: 68000 + Math.random() * 1000,
          ts: Date.now(),
        }
        // SSE format: "data: <payload>

"
        const message = `data: ${JSON.stringify(priceUpdate)}

`
        controller.enqueue(encoder.encode(message))
      }, 2000)

      // Clean up when the client disconnects
      event.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",
    },
  })
}
// src/routes/prices.tsx — consuming SSE in a SolidJS component
import { createSignal, onCleanup } from "solid-js"

interface PriceUpdate { symbol: string; price: number; ts: number }

export default function PricePage() {
  const [price, setPrice] = createSignal<PriceUpdate | null>(null)

  // EventSource reconnects automatically on disconnect
  const es = new EventSource("/api/prices/live")
  es.onmessage = (event) => {
    setPrice(JSON.parse(event.data) as PriceUpdate)
  }

  // SolidJS cleanup runs when the component unmounts
  onCleanup(() => es.close())

  return (
    <div>
      <h1>Live BTC Price</h1>
      {price()
        ? <p>{'$'}{price()!.price.toFixed(2)}</p>
        : <p>Connecting…</p>
      }
    </div>
  )
}

Caching and Revalidation

Bottom line: wrap server functions with query(fn, key) to deduplicate concurrent calls and cache results across navigations. After a mutation, call revalidate(key) to clear the cache entry and trigger a background refetch.

query() is SolidStart's built-in data cache (available in v1.x via @solidjs/router). The cache key is a string — keep it unique per function. On the server, query() deduplicates concurrent calls within the same request (request-scoped memoization). On the client, it persists across navigations until explicitly invalidated. This is the primary difference from createAsync() alone: navigating away and back does not re-fetch if the cache entry is still valid. For mutations, use action() combined with a call to revalidate(key) inside the action body. For HTTP-level caching on API routes, set Cache-Control headers directly in the Response — CDNs and browsers respect them without any SolidStart-specific configuration.

// src/lib/products.ts — query() cache with revalidation
import { query, action, revalidate } from "@solidjs/router"
import { db } from "~/lib/db"

// query() wraps the server function and assigns a cache key
export const getProducts = query(async () => {
  "use server"
  return db.product.findAll()
}, "products")   // cache key — must be unique across the app

export const getProduct = query(async (id: number) => {
  "use server"
  return db.product.findById(id)
}, "product")

// action() with revalidate — invalidates cache after mutation
export const createProduct = action(async (formData: FormData) => {
  "use server"
  const name  = formData.get("name") as string
  const price = Number(formData.get("price"))

  await db.product.create({ name, price })

  // Invalidate the "products" cache so the next read fetches fresh data
  await revalidate("products")
})

export const deleteProduct = action(async (id: number) => {
  "use server"
  await db.product.delete(id)
  // Invalidate both the list and the individual item cache
  await revalidate("products")
  await revalidate("product")
})
// src/routes/api/products/index.ts — HTTP cache headers on API routes
import { json } from "@solidjs/start/server"
import type { APIEvent } from "@solidjs/start/server"
import { db } from "~/lib/db"

export async function GET(event: APIEvent) {
  const products = await db.product.findAll()

  // CDN caches for 60 s, stale-while-revalidate for 30 more seconds
  return new Response(JSON.stringify({ products }), {
    headers: {
      "Content-Type": "application/json",
      "Cache-Control": "public, max-age=60, stale-while-revalidate=30",
    },
  })
}

// --- createAsync vs createResource comparison ---
// createAsync: simpler, works with query() cache, no manual refetch
//   const products = createAsync(() => getProducts())
//
// createResource: lower-level, supports refetch() and mutate()
//   const [products, { refetch }] = createResource(getProducts)
//   refetch()  →  fires a new request regardless of cache
//
// Prefer createAsync + query() for most data loading.
// Use createResource when you need programmatic refetch outside of actions.

FAQ

How do I create a JSON API route in SolidStart?

Create a file inside src/routes/api/ and export named functions matching HTTP method names: GET, POST, PUT, DELETE, or PATCH. Each handler receives an APIEvent and returns a Response. Use the json() helper from @solidjs/start/server to set Content-Type: application/json automatically. For path parameters, name the file with brackets: src/routes/api/products/[id].ts and read event.params.id. Return json({ error: "Not found" }, { status: 404 }) for error responses. Access the raw request body with await event.request.json() in POST and PUT handlers.

What is the difference between API routes and server functions in SolidStart?

API routes in src/routes/api/ are standard HTTP endpoints — any consumer can call them, they expose full HTTP semantics (status codes, headers, methods), and they're suitable for mobile apps or third-party integrations. Server functions with "use server" are internal: they run on the server, return typed JavaScript values, and are called from SolidJS components via createAsync() — no fetch, no JSON.parse, no status code handling. Choose API routes for public endpoints; choose server functions for component data loading where you want TypeScript types and zero serialization boilerplate.

How do I use "use server" to fetch JSON in SolidStart?

Declare a function with "use server" as the first statement: const getData = async () => { "use server"; return db.items.findAll() }. In your component, call it with createAsync(): const items = createAsync(() => getData()). The result is a reactive accessor — items() — typed as the function's return type or undefined while loading. Wrap the JSX that reads items() in a <Suspense> boundary. SolidStart handles all serialization automatically — you return a plain JS value and the component receives it typed.

How do I validate JSON in a SolidStart API route?

Parse the request body with const body = await event.request.json(), then validate with a Zod schema using schema.safeParse(body). If result.success is false, return json({ errors: result.error.flatten() }, { status: 400 }). For server actions that receive FormData, call Object.fromEntries(formData) first, then pass the object to safeParse. Define the schema in a shared module and use z.infer to derive the TypeScript type — one schema covers runtime validation, type inference, and client-side form feedback.

How do I add TypeScript types to SolidStart server data?

Annotate the server function's return type explicitly: const getUser = async (id: number): Promise<User | null> => { "use server"; ... }. Then createAsync(() => getUser(id)) returns an Accessor<User | null | undefined> — no type assertions needed. For API routes, import and use the APIEvent type. For route params, export a RouteDefinition with a preload function. Use z.infer<typeof schema> to derive types from Zod schemas and annotate server function parameters with them.

How do I cache JSON data in SolidStart?

Wrap server functions with query(fn, "cache-key") from @solidjs/router. The cache key deduplicates concurrent calls and persists results across client-side navigations. After a mutation, call revalidate("cache-key") inside an action() to invalidate the entry. For HTTP-level caching on public API routes, set Cache-Control headers in the Response: public, max-age=60, stale-while-revalidate=30. The query() cache is request-scoped on the server (deduplication only) and cross-navigation on the client.

Building with SvelteKit or Nuxt instead?

The same patterns — typed server data, Zod validation, streaming — apply across all modern meta-frameworks. See the equivalent guides for JSON in SvelteKit, JSON in Nuxt, and JSON in React.

Open JSON Validator

Key Terms

"use server"
A SolidStart directive placed as the first statement inside a function. It causes the build tool to strip the function body from the client bundle and replace every call site with an RPC stub that sends a request to the server. The return value is automatically serialized and sent back to the client.
createAsync()
A SolidJS function that calls an async function (typically a server function) and returns a reactive Accessor. The accessor value is undefined while loading and the resolved value after. It integrates with SolidJS's fine-grained reactivity — only the specific DOM nodes that read the accessor update when the value changes.
query()
A SolidStart helper that wraps a server function and assigns a cache key. It deduplicates concurrent calls on the server (request-scoped) and persists results across navigations on the client. Invalidated by calling revalidate(key).
APIEvent
The type of the argument passed to SolidStart API route handlers. It exposes event.request (the standard Request object), event.params (URL path parameters), event.locals (middleware-set values), and event.nativeEvent (the underlying platform event). Import from @solidjs/start/server.
RouteDefinition
An exported constant from a route file that SolidStart reads to configure the route. The preload property runs before the route component mounts and can fire server function calls to warm the cache, reducing the time to first data on navigation.
json() helper
A utility function from @solidjs/start/server that creates a Response with Content-Type: application/json and calls JSON.stringify on the first argument. Accepts a ResponseInit as the second argument for setting status codes and additional headers.

Further reading and primary sources

  • SolidStart API routesOfficial SolidStart documentation for file-based API routes: handler exports, APIEvent type, path parameters, and response helpers
  • SolidStart server functionsOfficial SolidStart documentation for "use server" functions, serialization rules, and calling server functions from components
  • createAsync and querySolidJS Router documentation for the query() caching primitive and createAsync() reactive data loading API
  • SolidJS fine-grained reactivityCore SolidJS documentation explaining how fine-grained reactivity works, why only dependents update, and how Suspense integrates with async signals
  • Zod documentationOfficial Zod documentation for schema definition, safeParse, z.infer for TypeScript type derivation, and error formatting with flatten()